你不知道的JS(2)深入了解闭包(非常重要)

很久之前就想写一篇关于闭包的博客了,但是总是担心写的不够完全、不够好,不管怎样,还是要把我理解的闭包和大家分享下,比较长,希望耐心看完。

定义

说实话,给闭包下一个定义是很困难的,原因在于javascript设计的时候并没有专门设计闭包这样一个规则,闭包是随着作用域链、函数可以作为一等公民这样的规则而诞生的。

尽管不能下一个很完美的定义,但是我们还是可以给闭包下一个尽量准确的定义。

 

闭包:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。闭包的创建和使用在你的代码中随处可见。

 

哪些是闭包?

来看下面这个例子1:

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

基于词法作用域的查找规则,函数bar() 可以访问外部作用域中的变量a(这个例子中的是一个RHS 引用查询)。

那么这个是闭包吗?很遗憾不是,因为bar函数执行在其定义的词法作用域处。


 

不过稍加修改后就是个闭包了,例子2:

function foo() {
    var a = 2;
    function bar() {
        console.log( a );
    }
    return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。

baz函数执行实际上只是通过不同的标识符引用调用了内部的函数bar()

bar()函数显然可以被正常执行,也就是在自己定义的词法作用域以外的地方执行

根据作用域的规则,函数bar()函数能够访问foo()的内部作用域,因此foo()执行完后,其内部作用域并不会被回收,bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

 

这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。

当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。


 

来看例子3:

function foo() {
    var a = 2;
    function baz() {
        console.log( a ); // 2
    }
    bar( baz );
}
function bar(fn) {
    fn(); // 妈妈快看呀,这就是闭包!
}

是的,这也是个闭包,这里将baz传递出去了在bar()函数中执行,而不是在自己定义的词法作用域中执行,但是它却保留这对定义时词法作用域的引用


 

再看例子4:

var fn;
function foo() {
    var a = 2;
    function baz() {
        console.log( a );
    }
    fn = baz; // 将baz 分配给全局变量
}
function bar() {
    fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2

是的没错,这还是个闭包,无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。


 

那我们看一个难一点的例子5:

function wait(message) {
  setTimeout( function timer() {
    console.log( message );
  }, 1000 );
}
wait( "Hello, closure!" );

这是闭包吗?答案是的,在这里我们向setTimeOut传入timer()函数,并且timer函数可以访问wait的内部作用域,保持着对wait内部作用域的引用,比如里面的message变量。

这时候你肯定会心生疑惑:不对呀?这在哪执行呢?不是说要在定义的词法作用域以外执行吗?

传入的timer函数当然会被执行,只是内部引擎调用执行的

深入到引擎的内部原理中,内置的工具函数setTimeout(..) 持有对一个参数的引用,这个参数也许叫作fn 或者func,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部的timer 函数,而词法作用域在这个过程中保持完整,time函数保持着对wait内部作用域的引用。

 

IIFE(立即执行函数)是闭包吗?

例子6:

var a = 2;
(function IIFE() {
console.log( a );
})();

按照我们的定义来说,这不是闭包。

但是,尽管IIFE 本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建可以被封闭起来的闭包的工具。

因此IIFE 的确同闭包息息相关,即使本身并不会真的使用闭包。

这也是为什么很难给闭包下定义的地方,因为如果从内存或者作用来看,IIFE创建了闭包(也就是在内存中创建了一块区域,这块区域保存着作用域链上作用域的引用,稍后可见例子9),或者说效果等同于创建了闭包。

而如果从闭包的定义来看,这却不是闭包。


 

我们来看例子7:

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

大家都知道这段代码会输出五次6,为什么呢?

因为setTimeOut()是异步函数,也就是等循环结束后才去执行setTimeOut()中的回调函数,而在for循环中,并不存在着块级作用域,也就是这个i声明在全局作用域中,并且自始至终只有一个i(因为var声明会变量声明提升,也就是其实只声明了一次),而在for循环结束后,这个i的值是6。setTimeOut()中的回调函数timer()保持着对i的引用,但是5次timer()函数引用的只是同一个i,所以输出5次6。


 

例子8:

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

这样有效果么?答案是没有的,虽然通过IIFE每次都创建了一个作用域,但是这个作用域是空的(也就是创建了一个空作用域),所以还会沿着词法作用域链去上一层找i,结果找到的还是全局作用域中的i,也就是只有一个i,还是会输出五次6。


 

所以我们需要这样改,来看例子9:

// 它需要有自己的变量,用来在每个迭代中储存i 的值:
for (var i=1; i<=5; i++) {
    (function() {
        var j = i;
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })();
}
// 行了!它能正常工作了!。
// 可以对这段代码进行一些改进:
for (var i=1; i<=5; i++) {
    (function(j) {
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })( i );
}
//当然你也可以这样写
for (var i=1; i<=5; i++) {
    (function(i) {
        setTimeout( function timer() {
            console.log( i );
        }, i*1000 );
    })( i );
}

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

好在ES6出来了let的解决方案,let并不会变量声明提升,并且具有块级作用域的效果,也就是这里会产生5个i的内存空间,被五个timer()函数引用着。

例子10:

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

 

关于闭包的垃圾回收

问题1:闭包会造成内存泄漏吗?

我们常说闭包会造成内存泄漏,这是真的吗?答案是不会的。

之所以之前一直说闭包会造成垃圾泄露是由于IE9 之前的版本对JavaScript 对象(标记清除)和COM 对象(引用计数)使用不同的垃圾收集方法。因此闭包在IE 的这些版本中会导致一些特殊的问题。具体来说,如果闭包的作用域链中保存着一个HTML 元素,那么就意味着该元素将无法被销毁


 

例子11:

function assignHandler(){
    var element = document.getElementById("someElement");
    element.onclick = function(){
        alert(element.id);
    };
}

以上代码创建了一个作为element 元素事件处理程序的闭包,而这个闭包则又创建了一个循环引用。由于匿名函数保存了一个对assignHandler()的活动对象的引用,因此就会导致无法减少element 的引用数。只要匿名函数存在,element 的引用数至少也是1,因此它所占用的内存就永远不会被回收。

解决办法就是把element.id 的一个副本保存在一个变量中,从而消除闭包中该变量的循环引用同时将element变量设为null。


 

例子12:

function assignHandler(){
    var element = document.getElementById("someElement");
    var id = element.id;
    element.onclick = function(){
        alert(id);
    };
    element = null;
}

 

问题2:闭包中没有使用的变量会被回收吗?

答案是会的。

来看例子13:

function foo() {
    var x = {};
    var y = "whatever";

    return function bar() {
        alert(y);
    };
}

var z = foo();

在这里x没有被使用,那么x会被回收吗?答案是的。

理论上来说,bar函数保存着foo作用域中的引用,那么x不应该会被回收。但是现代javascript引擎是非常智能的,对这里进行了优化。

javascript引擎经过逃逸分析(分析函数调用关系,以判断变量是否“逃逸”出当前作用域范围)后判断出来x没有在闭包中使用到,那么它就会把x从堆中的作用域中移除出去。

一般是如何分析呢?很简单,如果闭包中没有引用到这个变量,并且没有使用 eval 或者 new Function,那么javascript引擎可以知道闭包的内存中的作用域不需要这个变量x.

具体测试可以看之前司徒正美的一篇文章:JS闭包测试

或者可以看看stackoverflow上的一篇解答:JavaScript Closures Concerning Unreferenced Variables

 

问题3:闭包中函数里的变量是分配在堆中还是栈中?

在简单的解释器实现里,函数里的变量是分配在堆而不是在栈上的。现代 JS 引擎当然就比较牛逼了,通过逃逸分析是可以知道哪些可以分配在栈上,哪些需要分配在堆上的。

也就是闭包中使用到的变量会分配在堆中,没有使用到的会分配在栈中(针对简单类型而言),以方便回收。

比如例子13的x,没有被闭包使用,不过是一个复杂类型,所以它在内存中是变量x存储在栈中,同时栈中x的值是堆中的对象{}的地址,大概是下面这样

【栈x】—->(堆{})

 

例子13中的y,被闭包使用了,闭包的函数就基于原先的词法作用域单独在堆中分配了内存,也就是闭包保存在了堆,同时其使用的变量也随着闭包一起保存在堆,大概是下面这样。

(堆(闭包(y:“whatever”)))

 

 

好了,以上这就是我的个人理解了,如果有什么疑问或者建议欢迎讨论。