深入理解闭包的概念

8次阅读

共计 4308 个字符,预计需要花费 11 分钟才能阅读完成。

闭包
关于闭包,目前有如下说法:

闭包是函数和声明该函数的词法环境的组合(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);
}
总结:理解闭包的概念是重要的,但我们不应当过多的使用闭包,它有优点,也优缺点,是一把双刃剑。使用闭包可以创建一个封闭的环境,使得我们可以保存私有变量,避免全局作用域命名冲突,加强了封装性;但它常驻内存的特性也对网页的性能造成了比较大的影响,在引用计数的垃圾回收策略下更容易造成内存泄漏。

正文完
 0