深入理解闭包的概念
闭包
关于闭包,目前有如下说法:
- 闭包是函数和声明该函数的词法环境的组合(MDN)
- 函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内。这种特性在计算机科学文献中被称为闭包(JavaScript权威指南)
- 闭包,指的是词法表示包括不被计算的变量的函数,也就是说,函数可以使用函数之外定义的变量(W3school)
- 闭包是指有权访问另一个函数作用域中的变量的函数(JavaScript高级程序设计)
根据排列顺序也可以看出,我个人对这些说法的认同程度。其实大家说的都是同一个东西,只是描述是否精确的问题。
为了充分理解以上的说法,要先理解一些术语:
词法作用域
简单来说,词法作用域就是:根据变量定义时所处的位置,来确定变量的作用范围。(词法解析,通过阅读包含变量定义在内的数行源码就能知道变量的作用域)
举例而言,定义在全局的变量,它的作用范围是全局的,所以被称为全局变量;定义在函数内部的变量,它的作用范围是局部的,所以被称为局部变量。
作用域链
函数在创建时,会同时保存它的作用域链。——这个保存的作用域链包含了该函数所处的作用域对象的集合。因为所有函数都在全局作用域下声明,所以这个保存的作用域链一定包含全局作用域对象(global)。此外,如果函数是在其他函数内部声明的,那它保存的作用域链中除了global之外,还包含它创建时所处的局部作用域对象。(在chrome中直接标识为closure,在firefox中则标识为块)。显然,这个作用域链实际上是一个指向作用域对象集合的指针列表。
函数在执行时,会创建一个执行环境、执行时作用域链以及活动对象。——活动对象(activation object)是指当前作用域对象(处于活动状态的,它包含arguments、this以及所有局部变量)。执行时作用域链实际上是函数创建时保存的作用域链的一个复制,但它更长,因为活动对象被推入了执行时作用域链的前端。每次函数在执行时都会创建一个新的执行环境(execution context),它对应着一个全新的执行时作用域链。
根据JavaScript的垃圾回收机制:一般情况下,函数在执行完毕后,执行环境(包括执行时作用域链)将自动被销毁,占用的内存将被释放。
垃圾回收机制
JavaScript 是一门具有自动垃圾回收机制的语言。
这种机制的原理是找出那些不再继续使用的变量,然后释放其占用的内存。目前,找出不再继续使用的变量的策略有两种:标记清除(主流浏览器)和引用计数(IE8及以下)。
标记清除:垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记;然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记;最后,垃圾收集器销毁那些带标记的值并回收它们所占用的内存空间。垃圾收集器会按照固定的时间间隔周期性地执行这一操作。
引用计数:当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是 1。如果同一个值又被赋给另一个变量,则该值的引用次数加 1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数减 1。当这个值的引用次数变成 0 时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为零的值所占用的内存。(引用计数的失败之处在于它无法处理循环引用)
现在,什么是闭包呢?
——“闭包是函数和声明该函数的词法环境的组合”(MDN)
function a(){ console.log('1'); } a();
以上例子:函数a,和它创建时所在的全局作用域,构成一个闭包。于是有人说每个函数实际上都是一个闭包,但准确来讲,应该是每个函数和它创建时所处的作用域构成一个闭包。
但这个闭包叫什么名字呢?
在chrome和firefox调试中,将函数a所在作用域的名字,作为闭包的名字;在JavaScript高级程序设计中则将函数a的名字,作为闭包的名字。这样一来,每个函数都是一个闭包的说法似乎又“准确”了一些。
其实我们书写的所有js代码,都处在全局作用域这个大大的闭包之中,只是我们意识不到它作为一个闭包存在着。
function a(){ var b = 1; function c(){ console.log(b); } return c } var d = a(); d(); // 1
以上例子:除了函数a和全局作用域构成一个闭包以外,函数c和局部作用域(函数a的作用域)也构成一个闭包。
先不关注这些函数内部的逻辑,我们只看结构:
函数a声明了,然后在var d = a();
这一句执行。通过以上对词法作用域、作用域链以及垃圾回收机制的理解,我们可以得出以下结论:
函数a在声明时保存了一个作用域链,在它执行时又创建了一个执行环境(以及执行时作用域链)。一般情况下,当函数a执行完毕,它的执行环境将被销毁。但在这个例子里,函数a中的变量c,被return
突破作用域的限制赋值给了变量d,而变量c是一个函数,它使用了它创建时所处的作用域(函数a的作用域)中的变量b,这意味着,在函数d执行完毕之前,函数c以及它创建时所处的作用域中变量(变量b)不可以被销毁。
这打断了函数a执行环境的销毁进程,它被保存了下来,以备函数d调用时使用。看看被保存的是什么?一个函数c和它创建时所在的作用域。一个闭包。
function a(){ var b = 1; function c(){ b++; console.log(b); } return c } var d = a(); d(); // 2 d(); // 3 var e = a(); e(); // 2 e(); // 3
以上例子,函数a被执行了两次并分别赋值给了d、e,显然,函数a的两次执行创建了两个执行环境,它们本该被销毁,但由于函数c的存在(有权访问另一个函数内部变量的函数),它们被保存下来。函数d的两次执行,使用同一个执行环境中的变量b,所以b递增了;由于函数e使用的是另一个执行环境中的变量b,所以它重新开始递增。
所以,什么是闭包呢?
闭包是一个函数和它创建时所在作用域的组合。在我们日常应用中,通常是将一个函数定义在另一个函数的内部并从中返回,以使它成为一个在函数外部仍有权限访问函数内部作用域的函数。
jQuery就是定义在一个匿名自执行函数内部的函数,当它被赋值给全局作用域变量$
和jQuery
时,在全局作用域使用$
和jQuery
方法,就能够访问到那个匿名自执行函数的内部作用域(其中包含的变量等)。在jQuery这个例子中,内部函数jQuery和其所在的匿名自执行函数作用域就构成一个闭包。
一个经典的例子:
// html <ul><li></li><li></li><li></li></ul> var lis = document.querySelector('ul').children; for (var i = 0; i < lis.length; i++) { lis[i].addEventListener('click', function(){ console.log(i); }) } var event = document.createEvent('MouseEvent'); event.initEvent('click', false, false); for (var j = 0; j < lis.length; j++) { lis[j].dispatchEvent(event); }
为页面上的所有li标签绑定点击函数,点击后输出自身的序号。在以上例子中,显然将输出 3, 3, 3;而非 0, 1, 2;
一个通俗的解释是,当点击li标签时,for循环已经执行完毕,i的值已经确定。所以三个li标签点击输出同一个i的值。
我们稍微改动一下代码:
// html <ul><li></li><li></li><li></li></ul> var lis = document.querySelector('ul').children; for (var i = 0; i < lis.length; i++) { (function(i){ lis[i].addEventListener('click', function(){ console.log(i); }) })(i); } var event = document.createEvent('MouseEvent'); event.initEvent('click', false, false); for (var j = 0; j < lis.length; j++) { lis[j].dispatchEvent(event); }
以上例子,当点击li标签时,for循环已经执行完毕,i的值已经确定,可为什么结果会输出 0, 1, 2 呢?
实际上,这是闭包在作怪:
click
事件的匿名函数 跟外层自执行匿名函数的作用域构成了一个闭包。在循环中,外层匿名自执行函数本该在执行结束后销毁它的执行环境,释放其内存,但由于它的参数(变量)i 还被事件监听函数引用着,所以这个执行环境无法被销毁,它将被保存着。每一次的循环,匿名自执行函数都将执行一次,并保存一个执行环境;当循环结束,类似的执行环境共有三个,每一个里面的变量i的值都是不同的。
回到第一个例子,匿名事件函数实际上和声明它的全局作用域也构成了一个闭包,但在三次循环中,i 都未曾离开这个闭包,它一直递增直至3,三个点击事件函数引用同一个执行环境中的变量i,它们的值必然是相同的。
离开闭包的泥淖,给这个例子一个较为合理的写法:
// html <ul><li></li><li></li><li></li></ul> var lis = document.querySelector('ul').children; var say = function(){ console.log(this.index); } for (var i = 0; i < lis.length; i++) { lis[i].index = i; lis[i].addEventListener('click', say); } var event = document.createEvent('MouseEvent'); event.initEvent('click', false, false); for (var j = 0; j < lis.length; j++) { lis[j].dispatchEvent(event); }
总结:理解闭包的概念是重要的,但我们不应当过多的使用闭包,它有优点,也优缺点,是一把双刃剑。使用闭包可以创建一个封闭的环境,使得我们可以保存私有变量,避免全局作用域命名冲突,加强了封装性;但它常驻内存的特性也对网页的性能造成了比较大的影响,在引用计数的垃圾回收策略下更容易造成内存泄漏。