你的浏览器不支持canvas

Love You Ten Thousand Years

来一次痛痛快快得闭包挑战

Date: Author: M/J

本文章采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。转载请注明来自小可嗒嗒的博客

闭包在JavaScript是一个非常重要得概念,但我觉得又比较难理解。因为它和其他的语言不一样,其他语言有在函数内部调用其他函数,但是多个函数嵌套好像是没有的。

在这之前,我觉得最好先了解一下作用域与变量提升。

作用域

通常,我们是这样定义的。在函数体外的声明的变量,我们称为全局变量;在函数体内声明的变量,我们称之为局部变量。函数体内可以访问到函数外的变量,反之却不行。这也好理解。

function foo(a){
    var b = a *2;
    function bar(c) {
        console.log (a,b,c);
    }
    bar(b*3);
}
foo(2);

以上代码中:

  • 包含在全局作用域的,只有一个标识符: foo
  • 包含在foo所创建的作用域,有: abarc
  • 包含在bar所创建的作用域内的,只有c

由于abarc都附属于foo()的作用域气泡内,所以无法从foo()外部(这里的外部就是全局作用域)对它们进行访问。由于c附属于bar()的作用域气泡内,所以也无法从foo()的作用域内对它进行访问,全局作用域则更无法访问了,这就是所谓的链式作用域

因为我们是通过函数而创建了一个封闭的作用域,所以也叫做函数作用域

这里我们又开始讨论另一个问题:有名字的函数也意味着会污染全局作用域。所以,在JavaScript中,函数表达式可以是匿名的。匿名的函数表达式书写起来方便快捷,但对代码的可读性/调试不太友好。所以,始终给函数表达式命名是一个最佳实践

如果,我们采用IIFE(立即执行函数表达式),将函数包含在一对()内部,成为一个表示式,通过在末尾加上另外一个()可以立即执行这个函数。这很好地避免了对全局作用域的污染(为什么这么说,因为函数名相当于在一个内部作用域中声明)。IIFE还有很多其他的用法,我们下次可以讨论。

函数声明是不可以匿名的。

问题的重点来了,函数作用域是一个块作用域,那还有其他块作用域吗?比如:

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

C语言中,for循环会创建了一个局部的块作用域,使得for循环之外的作用域是无法访问i变量的(当然,对于C语言,该变成for (int i=0; i < 10; i++))。但是,这里我们会第四行输出10,这说明并没有for循环创建的这个作用域。

所以,当使用var声明变量时,它写在哪里都是一样的(这涉及到变量提升)。

幸好,ES6改变现状,引入了let关键字。可以将变量绑定到所在的任意作用域内(通常是{…}内部)。使用let进行的声明不会在块作用域中进行提升。

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

ES3规范中规定try/catchcatch分句会创建一个块作用域,其中声明的变量仅在catch分句中有效。当然,很少人会注意到,我们也不展开。

const关键字同样可以创建块作用域变量,但其值是固定的常量。

if (true) {
    var a = 2;
    const b = 3;

    a = 3; //正常
    b = 4; //错误
}

console.log(a); //3
console.log(b); //ReferenceError

提升 – hoisting

var关键字声明的变量提升

a = 2;
var a;
console.log(a);  // 结果输出 2

为什么会输出2呢。其实呢,在编译阶段,JavaScript会将变量进行提升,实际是进行如下的处理:

var a; //对变量的声明进行了提升
a = 2;
console.log(a);

那我们再举个例子:

console.log(a);
var a = 2;

会输出什么呢?答案是undefined。因为实际上是这样解释的:

var a;
console.log(a); //undefined
a = 2;

变量a声明了,但是并未赋值,所以值是undefined(未定义状态)。

注意: 每个作用域都会进行提升操作,而不仅仅是全局作用域内。

函数提升

foo();
function foo(){
    console.log(2);
};

因为函数声明被提升了,所以第一行代码可以被执行。

function foo(){
    console.log(2);
};
foo();

到这里,我们来解释一下变数表达式为何不会被提升。

bar(); //TypeError: bar() is not a function

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

上面这段代码被解释成:

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

对于第二行代码来讲,我们只是定义了一个变量bar,但尚未赋值为一个函数。对于引擎来说,我可以找到bar这个变量,但是这个变量做了它不可能做的事情,所以是TypeError的错误。

ReferenceError通常指的是在作用域范围内找不到这个变量,而TypeError是在作用域找到了但是做了它不可能做的事情。

函数优先准则

函数声明和变量声明都会被提升。但函数会首先被提升,然后才是变量。


闭包

闭包是什么?烦死了,我觉得烦,说不清,理还乱。

Professional JavaScript for Web Developers这本大牛的书中,是这样说的:闭包是指有权访问另一个函数作用域中的变量的函数。这可就宽泛了。

你不知道的 JavaScript 一书中是这样解释的:当函数可以记住并访问所在的词法作用域时,就产生了闭包。这个说法就很抽象了。

function foo(){
    var a = 2;
    function bar(){
        console.log(a); //2
    }
    bar();
}
foo();

这是个闭包吗?按照Nicholas C.Zakas(上面这本书的作者)的说法,。因为嵌套的函数bar()可以访问foo()作用域气泡的内容。也许会惊讶,这不就是作用域链的关系吗?我们在前面就解释过了呀!

我觉得,这是一般意义上的闭包。

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar;
}
var baz = foo(); //8
baz(); //闭包

上面这段代码中,在第八行代码foo()被执行后,照理来讲,我们会期待foo()的整个内部作用域被销毁。然而第九行代码baz()被调用后,bar()被正常执行。所以,由于bar()所声明的位置,它拥有的一个包含foo()作用域的闭包,使得该作用域(即abar标识符)可以继续存活,可以供bar()在之后的任何时间进行引用。

所以,即使这个内部函数bar()在其他地方被调用了,但它仍然可以访问上游作用域。

本质上,无论何时何地。如果将函数当作第一级的值类型并到处传递,就会看到闭包在这些函数中的应用。在定时器、时间监听器 Ajax 请求,跨窗口通信, Web Worker 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包。 – 《你不知道的 JavaScript》

《Professional JavaScript for Web Developers》 主要从作用域链的角度解释了闭包的形成。(关于这一点,会在以后补充,这样就能和《你不知道的 JavaScript》有关闭包的内容对应起来了)

循环和闭包

这是个经常碰到的有关闭包和setTimeout()的例子,不说这个例子的实际意义,我觉得还挺好玩的。

for (var i=1; i<=5;i++){
    setTimeout(function timer() {
        console.log(i);
    },i*1000);

这段代码的目的当然是想说:每隔一秒,分别输出数字1~5。

实际输出:每隔一秒的频率输出五次6。

怎么回事?😳

setTimeout()函数是一个延迟函数,timer()是一个回调函数。我们将上面这段代码拆解一下:

...//A
setTimeout (function timer(){
    //C
}, 1000)
...//B

这里,我们有三个要执行的操作ABC。那我们怎么去描述这段代码的运行方式呢?一种是:执行A,然后设定一个延时等待1000毫秒,到时后马上执行C

另一种说:执行A,设定延时1000毫秒,执行B,然后定时到时我们执行C。显然,这种说法(第二种)说话是更准确的。

所以,在等待第一个定时到时时,循环已经进行了5次。这是,i的值已经是6了。而回调函数timer()最终访问的是全局作用域中的i的值。但为什么会以每秒一次的频率呢?我们这么来解释。

第一个操作:  i = 1; 定时1s;
第二个操作:  i = 2; 定时2s;
第三个操作:  i = 3; 定时3s;
第四个操作:  i = 4; 定时4s;
第五个操作:  i = 5; 定时5s;
第六个操作:  i = 6; 循环停止;
第七个操作:  1s的定时到;console.log(6);
第八个操作:  2s的定时到;console.log(6);
第九个操作:  3s的定时到;console.log(6);
第十个操作:  4s的定时到;console.log(6);
第十一个操作:  5s的定时到;console.log(6);

这样是不是好理解多了。但也会想,我抛弃时间,只要打印出1~5就行了。然后改成setTimeout(..., 0)。我让每次循环的定时为0毫秒,那它肯定会立即执行吧。这个想法有点天真。

以前说过,setTimeout()定时器的精度不会太高,只能确定你的回调函数不会在指定的时间间隔之前运行,也就是说也许就是定时时,或者定时后回调函数才会运行,这是一个叫JavaScript任务队列决定的。怎么理解呢? 我记得有个人举了个这样的例子:

我们可以这样的任务队列看成是去KFC就餐。如果你要吃汉堡,点餐员通常不会直接给你配好你要吃的,而是会给你一个凭据,或者说是编号,告诉你这个汉堡大概需要等待多少时间做好。汉堡做好后,通知你取餐时,你也不能马上拿到你的汉堡。因为在你等待的这个时间,又有很多人在队伍里了,所以你需要排在队伍后边等待前面的人取完餐或者点完餐。当然事情真正去KFC并不需要这么折腾,否则多累呀。

所以,第一次循环中你运行到setTimeout()函数时,刚想立即打印i的值,但是,不好意思,第二次循环已经排在你的前面了,第三次,第四次,第五次也排在你的前面。所以你只能默默地等待它们运行完。所以,回调函数依然是在循环结束后才会执行,每次输出一个6

然后,我们就抓狂了。要我咋办?用立即执行函数表达式创建一个封闭的作用域

for (var i = 1; i<=5; i++){
    (function (j) {
        setTimeout(function timer() {
            console.log(j);
        }, j*1000);
    })(i);
}

在迭代内使用IIFE会为每个迭代生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代的内部,每个迭代中都会含有一个含有正确值的变量供我们访问。

它的本质是将块作用域转换成一个可以被关闭的作用域

当然,这不是唯一的解决方法,如果我们使用let关键字来声明变量。

for (let i =1; i<= 5; i++){
    setTimeout(function timer() {
        console.log(i);
    },i*1000);
}

所以,多使用let而不是var是一个最佳实践。

我们来举一个闭包的例子

这个例子所涉及到的仍然是上面的内容。

var gimmeSomething = (function (){
    var nextVal;
    return function () {
        if (nextVal == undefined)
            nextVal = 1;
        else
            nextVal = (3 * nextVal) + 6;
        return nextVal;
    };
})();

console.log(gimmeSomething()); //1
console.log(gimmeSomething()); //9
console.log(gimmeSomething()); //33
console.log(gimmeSomething()); //105

惊奇吧。按照我们其他语言的想法,每次的结果都应该输出1呀。但,这是闭包呀。我们第一次运行gimmeSomething()时,引用调用了内部的匿名函数,这个函数涵盖了立即执行函数的内部作用域的闭包,所以该作用域一直存活,也就是说变量nextVal一直存活。是不是很绕?也许是我自己很绕吧。


匿名函数和闭包

两者没有直接的联系,只不过很多情况下,有匿名函数的地方就有闭包。


闭包的作用

模仿块级作用域

虽然说模仿块级作用域并不是闭包的功劳,而是通过 IIFE 函数实现的。比如:

(function(){
    // 这里是块级作用域
});

但是呢,我们通常将 IIFE 和闭包结合起来,所以说也有闭包的一点功劳。比如下面这个栗子。

function outputNumbers (count){
    (function (){
        for (var i = 0; i < count; ++i){
            // do something
        }
    })();
}

这个例子中,用 IIFE 实现了一个块级作用域,但私有作用域中能够访问变量 count,就是闭包的结果。

在对象中创建私有变量

严格来说,在 JavaScript 中,所有对象的属性都是公有的。但是,倒是有函数作用域这一概念,使得在函数中定义的变量,都可以认为是私有变量,因为不能在函数的外部访问这些变量。

我们在函数内部创建闭包,就可以创建用于访问私有变量的公有方法。举例,定义个构造函数。

function MyObject (){
    // 私有变量和私有函数
    var privateVariable = 10;

    function privateFunction (){
        return false;
    }

    //公有方法
    this.publicMethod = function (){
        privateVariable++;
        return privateFunction();
    };
}

在这个例子中,通过构造函数 MyObject 创建的实例是没有办法访问 privateVariable 和 privateFunction(),只能使用 publicMethod() 这一途径。而公有方法 publicMethod() 由于闭包的关系可以访问到 privateVariable 和 privateFunction()。这就是闭包起的作用。


闭包的缺陷

可能会造成内容泄露。


对于本文内容有问题或建议的小伙伴,欢迎在文章底部留言交流讨论。