26

前端进击的巨人(三):从作用域走进闭包

 5 years ago
source link: https://segmentfault.com/a/1190000017948999?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

进击的巨人第三篇,本篇就作用域、作用域链、闭包等知识点,一一击破。

Yfa26za.png!web

作用域

——《你不知道的JavaScript上卷》

作用域有点像圈地盘,大家划好区域,然后各自经营管理,井水不犯河水。

var globaValue = '我是全局作用域';
function foo() {
    var fooValue = '我是foo作用域';
    function bar() {
        var barValue = '我是bar作用域';
    }
}

function other() {
    var otherValue = '我是other作用域';
}

rMFVjab.png!web

作用域的变量声明

不同作用域下命名相同的变量不会发生冲突, "就近原则" 选取。

var name = '任何名字';
function getName() {
    var name = '以乐之名';
    console.log(name);    // '以乐之名'
}
console.log(name);        // '任何名字'

作用域的类型

执行上下文环境有:全局、函数、eval。那么作用域也有三种,ES6新增了块级作用域。

  1. 全局作用域
  2. 函数作用域
  3. eval作用域(不推荐使用eval,暂时忽略)
  4. 块级作用域(ES6新增)

全局作用域

JavaScript中全局环境只有一个,对应的全局作用域也只有一个。没有用 var/let/const 声明的变量默认都会成为全局变量。

function foo() {
    a = 10;
};
console.log(a);    // 10 变全局变量(意外由此发生)

函数作用域

ES6之前,想要实现局部作用域的方式,都是是通过在函数中声明变量来实现的,所以也称函数作用域,支持嵌套多个。

var a = 20;
function foo() {
    var a = 10;
    console.log(a);    // 10;
}

函数中声明变量时,建议在函数起始部分声明所有变量,方便查看,切记要用 var/let/const 声明,防止手抖将局部变量变成成全局变量。

function getClient() {
    var name;
    var phone;
    var sex;
}

块级作用域

我们先来理解什么是块?所谓块,其实就是被大括号 {} 包裹的代码部分。

if (true) {
    // 这里就是块了,也可称代码块
}

ES6前没有块级作用域的概念,所以 {} 中并没有自己的作用域。如果我们想在ES5的环境下构建块级作用域,一般都是是通过立即执行函数来实现的。

var name = '任何名字';
(function(window) {
    var name = '以乐之名';
    console.log(name);    // '以乐之名'
}(window));
console.log(name);        // '任何名字'

ES5借助函数作用域来实现块级作用域的方式,会让我们的代码充斥大量的立即执行函数(IIFE),不便于代码的阅读。好的代码的就跟好的文章一样,让阅读的人读来舒畅明了。

为此,ES6新增块级作用域的概念,使用 let/const 声明变量的方式,即可将其作用域指定在代码块中,跟函数作用域一样支持嵌套。

let i = 0;
for (let i = 0; i < 10; i++){
    console.log(i);
}
i;    // 0

let/const 不允许变量提升,必须 "先声明再使用" 。这种限制,称为 "暂时性死区" 。这也能让我们在代码编写阶段变得更加规范化,执行跟书写顺序保持一致。

作用域链(变量查询规则)

变量被作用域所管理,那么变量在作用域中的查找规则,就是所谓的作用域链。

——《JavaScript高级程序涉及》

"在当前执行环境开始查找使用到的变量,如果找到,则返回其值。如果找不到,会逐层往上级(父作用域)查找,直到全局作用域"。

var money = 100;
function foo() {
    function bar() {
        console.log(money);
    }
    bar();
}
foo();

7bUBv2f.png!web

自由变量

变量我们见的不少,但"自由变量"听着是不是挺唬人的。其实对它,我们并不陌生。

"自由变量:当前执行环境使用到,但并未在当前执行环境声明的变量(函数参数arguments排除)"

函数调用时,进入执行上下文创建阶段,会对 argument 进行隐式的变量声明。

var outer = '我是外面变量';
function foo() {
    var inner = '我是里面变量,不是自由变量';
    console.log(outer);   
    // 这里用到了outer,但outer并不在函数foo中声明,所以outer就是foo中的自由变量
}

"自由变量的作用域由词法环境决定,也就是它的作用域在代码书写阶段就已经确定了,而不是在代码编译执行阶段确定。"

"自由变量的值是在代码执行时确定的,变量变量变量,值肯定要变,所以自由变量的值只有在程序运行阶段才能确定。"

闭包

开篇第一文我们就执行环境,执行栈做出了详解,有所遗忘的可再温习。执行栈是我们理解闭包原理基础中的基础。

函数调用栈过程的图再晒出来,顺便温习下。

function foo () {
    function bar () {
        return 'I am bar';
    }
    return bar();
}
foo();

7JNVFfq.png!web

函数调用时入栈,调用结束出栈。执行函数时,会创建一个变量对象去存储函数中的变量,方法,参数 arguments 等,结束调用时,该变量对象就会被销毁。(理想的情况下,不理想的情况就是出现 "闭包" 调用了)。

什么是闭包?

闭包是指有权访问另外一个函数作用域的变量的函数。

——《JavaScript高级程序设计》

闭包是指那些能够访问自由变量的函数。

——MDN

闭包的特点首先是函数,其次是它可以访问到父级作用域的变量对象,即使父级函数完成调用后 "理应出栈销毁"

判定闭包出现

  1. 函数作为参数传递
  2. 函数作为返回值传递
function foo() {
    var fooVal = '2019';
    var bar = function() {
        console.log(fooVal);    // bar中使用到了自由变量fooVal
    }
    return bar;                 // 函数作为参数返回
}

var getValue = foo();
getValue();                     // 2019

对函数中谁是闭包,各文档解释不一。在此我们遵照Chrome的方式,暂且称 foo 是闭包。

因为作用域和作用域链规则的限定,子环境的自由变量只能逐层向上到父环境查找。

但是通过闭包,我们在外部环境也可以获取到变量 fooVal ,虽然 foo() 函数执行完成了,但它并没从函数调用栈中销毁,其变量对象存储仍然能被访问到。

实际执行过程请看图:

jAjyU3R.png!web

把上述代码改以下,接着看:

function foo() {
 var fooVal = '2019';
 var bar = function() {
 console.log(fooVal);     // bar中使用到了自由变量fooVal
 }
 return bar;              // 函数作为参数返回
}
var getValue = foo();
var fooVal = '2018';      // 这里的fooVal是全局作用域的变量
getValue();               // 2019

答案与结果不符的小伙伴要回头理解下自由变量了。 "自由变量的作用域在代码书写时(函数创建时)就确定了" ,所以函数中 getValue() 使用的 fooValfoo 的作用域下,而不是在全局作用域下。

答对的小伙伴们再来一道题,加深你的记忆

function fn() {
    var max = 10;
    function bar(x) {
        if (x > max) {    
            console.log(x)
        }
    }
    return bar;
}
var f1 = fn();
var max = 100;

f1(20);                 // 输出20

题目解析: max 作为函数 bar 中的自由变量,它的作用域在函数 bar 创建的时候就确定了,就是函数 fn 中的 max ,所以它的作用域链查找到 fn 中已经结束并返回了,不会再向上找到全局作用域。

注意:栈中存储的不只是闭包中使用到的自由变量,而是父级函数的整个变量对象(父级函数作用域中声明的方法,变量,参数等)

闭包的应用场景

上文中已经阐述了闭包的特点,就是能够让我们跨作用域取值(不局限于父子作用域)。列举两个实际开放中常用的栗子:

  1. 封装回调保存作用域
for(var i = 1; i < 5; i++) {
    setTimeout((function(i){
       return function() {
           console.log(i);        
       } 
    })(i), i * 1000)
}
// 原理:通过自执行函数传参i,然后返回一个函数(闭包)中使用i,使父函数的变量对象一直存在
  1. 私有变量和方法实现模块化
var makePeople = function () {
    var _name = '以乐之名';
    return {
        getName: function () {
            console.log(_name);
        },
        setName: function (name) {
            if (name != 'Hello world') {
                _name = name;
            }
        }
    }
}

var me = makePeople();
me.getName();                   // '以乐之名'
me.setName('KenTsang');         
me.getName();                   // 'KenTsang'

// 原理:私有变量_name没有对外访问权限,但通过闭包使其一直保留在内存中,可以被外部调用

闭包的应用场景还有很多,具体实际情况还需具体分析。

闭包造成的内存泄露

闭包的使用,破坏了函数的出栈过程。解释执行栈的时候,讲到同个函数即使调用自身,创建的变量对象也并非同一个,其内存存储是各自独立的。

栈中只入不出,函数的变量对象没有被有效回收,就会造成浏览器内存占用逐步增加,内存占用过高的情况下,就会导致页面卡顿,甚至浏览器崩溃。这就是我们常说的闭包造成的 "内存泄露"

所以,一名合格的前端,除了会用闭包,还要正确的解除闭包引用。

垃圾回收机制讲解时,通过设置变量值为 null 时可已解除变量的引用,以便下一次垃圾回收销毁它。

function foo() {
 var fooVal = '2019';
 var bar = function() {
 console.log(fooVal);     
 }
 return bar;              
}
var getValue = foo();
var fooVal = '2018';     
getValue();
getValue = null;         // 解除引用,下一次垃圾回收就会回收了

写在结尾

闭包算是前端初学者的一个难点,能解释清除并不容易,涉及到作用域,执行上下文环境、变量对象等等。

零散知识的内聚汇总,正是是系列更文的初衷所在。

知识不是小段子,听完笑过就忘,唯有形成体系,达成闭环,才能深植入记忆中。

参考文档:

本文首发Github,期待Star!

https://github.com/ZengLingYong/blog

作者:以乐之名


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK