理解闭包

本文系原创,转载前请邮件联络作者:scorpionlc@yeah.net或直接在评论中留言等候回复

闭包对于我而言是一个难点,但闭包又是一个很有用的知识点,很多高级应用都需要依赖闭包。
所以在参考一些文章加上大量练习后,我来写一写自己理解闭包的过程,首先是弄清楚以下几个知识点。


>>> Part 1. 变量的作用域

JS中,变量的作用域只有两种:全局作用域、函数作用域。对应的变量也只有两种:全局变量、局部变量。

函数内部可以直接读取全局变量。

1
2
3
4
5
var a = 1;
function f(){
console.log(a);
}
f(); // 1

但是函数外部无法读取到函数内部的局部变量。

1
2
3
4
function f(){
var a = 1;
}
console.log(a); // Uncaught ReferenceError: a is not defined

这一个Part是比较好理解的。


>>> Part 2. 如何从外部读取到局部变量?

在禄永老师的公开课中,老师将从外部读取局部变量这一情况称作“伟大的逃脱”。总结而言,有两种方法来实现。

  • 返回值的方法:函数作为返回值
1
2
3
4
5
6
7
8
9
function f1(){
var a = 1;
function f2(){
console.log(a);
}
return f2;
}
var result = f1();
result(); // 1

函数f2包裹在函数f1内,根据作用域链的原理:子对象会一级一级向上寻找父对象的变量,f1所有的局部变量都可以被f2访问到,反之则不行。因此只要把f2作为返回值,就可以在f1外部读取到其中的内部变量。

  • 句柄的方法:定义全局变量
1
2
3
4
5
6
7
8
9
10
11
var innerHandler = null;
function outerFunc(){
var outerVar = 1;
function innerFunc(){
console.log(outerVar);
var innerVar = 2;
}
innerHandler = innerFunc;
}
outerFunc();
innerHandler(); // 1

这一方法首先定义了一个值为null的全局变量innerHandler,然后让innerHandler等于函数内部的函数,函数内部的函数则可以通过作用域链访问到父对象的变量outerVar,之后在外部调用innerHandler的时候,就可以访问到outerFunc函数中的内部变量outerVar


>>> Part 3. 闭包

网络上有千万种对闭包的解释,其实闭包就是上面例子中的两个函数:f2以及innerFunc。书面解释就是:能够读取其他函数内部变量的函数

在JS中,因为父函数内部的子函数才能够读取局部变量,因此闭包的常见形式就是:定义在函数内部的函数。前者是后者的充分不必要条件。

一言以蔽之,闭包就是连接函数内部外部的渠道。


>>> Part 4. 对示例代码段的解答

  • 第一段代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function outerFn() {
console.log("Outer function");
function innerFn() {
var innerVar = 0;
innerVar++;
console.log("Inner function\t");
console.log("innerVar = "+innerVar+"");
}
return innerFn;
}
var fnRef = outerFn();
fnRef();
fnRef();
var fnRef2 = outerFn();
fnRef2();
fnRef2();

在这一段代码当中,innerFn不是一个闭包,因为它并不需要读取其他函数的内部变量,唯一的变量innerVar就在innerFn函数内部。在第一个fnRef()之后,结果就是首先输出Outer function,然后输出Inner function,由于innerVar是函数innerFn的内部变量且自增,因此从0变为1,再输出innerVar = 1.

这时候需要明白,当再次运行fnRef()时,由于fnRef本身已经变成了函数innerFn,所以其输出结果就不再有Outer Function这一句,而是直接输出:Inner function以及innerVar = 1.原因是此时的innerVar是一个内部变量,其作用域限定在innerFn函数中,每次调用执行innerFn函数,innerVar都会被重写。

对于下面的fnRef2(),也是同理。最后的输出结果见下图:

  • 第二段代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var globalVar = 0;
function outerFn() {
console.log("Outer function");
function innerFn() {
globalVar++;
console.log("Inner function\t");
console.log("globalVar = " + globalVar + "");
}
return innerFn;
}
var fnRef = outerFn();
fnRef();
fnRef();
var fnRef2 = outerFn();
fnRef2();
fnRef2();

这里的globalVar是一个外部变量,也是一个全局变量,处于全局作用域下。所以当执行innerFn时,innerFn函数将会访问到一个每次都自增的全局作用域下的活动对象,因此输出的结果会从globalVar = 1一直到globalVar = 4.在执行间歇中,globalVar处于两个函数的作用域之外,天高地远谁也管不了,所以它的值会被保存在内存中,并不会立刻被抹去。最后的输出结果见下图:

  • 第三段代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function outerFn() {
var outerVar = 0;
console.log("Outer function");
function innerFn() {
outerVar++;
console.log("Inner function\t");
console.log("outerVar = " + outerVar + "");
}
return innerFn;
}
var fnRef = outerFn();
fnRef();
fnRef();
var fnRef2 = outerFn();
fnRef2();
fnRef2();

闭包来临了,这里的fnRef是一个闭包innerFn函数,但是此时的变量outerVar来到了父函数的作用域内,不像之前一样处于子函数作用域内或者处于全局作用域下。可以发现,这和Part 2中的例子非常相似。

其原理是:外部函数的调用环境为相互独立的封闭闭包的环境,第二次的fnRef2调用outerFn没有沿用第一次调用fnRefouterVar的值,第二次函数调用的作用域创建并绑定了一个新的outerVar实例,两个闭包环境中的计数器是相互独立,不存在关联的。

进一步来说,在每个封闭闭包环境中,外部函数的局部变量会保存在内存中,并不会在外部函数调用后被自动清除。原因在于:outerFninnerFn的父函数,而innerFn被赋值给一个全局变量,因此innerFn始终在内存当中,而它又依赖于outerFn,所以outerFn也必须始终在内存中,不会再函数被调用后就被抹去,因此闭包也有一点点不好,有可能造成内存泄漏。

所以,结果应该是:outerVar = 1, outerVar = 2, outerVar = 1, outerVar = 2.结果如下图所示:

我写到这自己已经完全明白了,我现在要用自己的理解来理顺一下最经典的问题。


>>> Part 5. 理顺最经典问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="divTest">
<span>0</span>
<span>1</span>
<span>2</span>
<span>3</span>
</div>
<script>
var spans = document.querySelectorAll("#divTest span");
for(var i = 0; i < spans.length; i++){
spans[i].onclick = function(){
console.log(i);
}
}
</script>

最经典的问题是:为什么我点击任何数字,控制台的输出结果永远是4?

这里可使用作用域链来帮助理解,不妨将以上代码转化为:

1
2
3
4
5
// function只是传递给了NodeList类型对象中的元素却并未执行,因为后面无括号
spans[0] = function fn0(){console.log(i)};
spans[1] = function fn1(){console.log(i)};
spans[2] = function fn2(){console.log(i)};
spans[3] = function fn3(){console.log(i)};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
globalContext = {
AO: {
i: undefined, // 0(fn0)1(fn1)2(fn2)3(fn3)4(终止循环)
spans:[0], [1], [2], [3]
},
scope: null
}
fn0[[scope]] = globalContext.AO,
fn1[[scope]] = globalContext.AO,
fn2[[scope]] = globalContext.AO,
fn3[[scope]] = globalContext.AO
fn0Context = {
AO:{
},
scope: fn0[[scope]]
}
fn1Context = {
AO:{
},
scope: fn1[[scope]]
}
fn2Context = {
AO:{
},
scope: fn2[[scope]]
}
fn3Context = {
AO:{
},
scope: fn3[[scope]]
}

最后点击span元素的时候i早已变为4,因此永远输出4.

改进的方法可以使用闭包,也就是:

1
2
3
4
5
6
7
8
var spans = document.querySelectorAll("#divTest span");
for(var i = 0; i < spans.length; i++) {
spans[i].onclick = function(i){
return function (){
console.log(i);
}
}(i);
}

这个闭包也可以用作用域链来理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
globalContext = {
AO:{
i: undefined,
spans: [0], [1], [2], [3]
}
}
fn0.scope = globalContext.AO,
fn1.scope = globalContext.AO,
fn2.scope = globalContext.AO,
fn3.scope = globalContext.AO
fn0Context = {
AO:{
i: 0,
function: anonymous
}
fn0[[scope]] = fn0.scope // globalContext.AO
}
function_anonymousContext = {
AO: {
}
function_anonymous[[scope]] = fn0Context.AO
}
...

>>> Part 6. 闭包的问题

如同刚才的分析一样,当涉及到闭包时,函数中的变量都会被保存在内存中,因此需要避免滥用闭包,否则就有可能导致内存泄露。


>>> 参考资料