前言

最近正在看《你不知道的 JavaScript》上卷,看完闭包章节后记录自己的一些所得,文笔有些拙劣,很多内容都是对书中内容的借鉴,主要是供自己日后复习使用。

基本概念

JavaScript 对于初学者来说很容易上手,很容易写出能够运行的代码,因为 JavaScript 相对于其他语言来说比较“自由”,也就是说没有过多的静态检查。但任何语言想要深入都很不易,想要深入 JavaScript,闭包就是必须要迈过去的坎。其实你或多或少都使用过这个技术,只是自己不清楚而已。日常使用的事件回调中就涉及了闭包。这里引用书中的一段话来形容对于闭包的描述:

闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识的创建闭包。闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿来识别、拥抱和影响闭包的思维环境。

好了,直接来看书中对于闭包的定义:

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

看不懂?没关系,下面来引用一个经典的代码例子来解释闭包:

1
2
3
4
5
6
7
8
9
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); //2

在这段代码里,我们定义了一个函数 foo,他会返回一个内部定义的函数 bar,函数 bar 的词法作用域能够访问 foo 的内部作用域(词法作用域就是定义在词法阶段的作用域,也就是说是由你在写代码时将变量和块作用域写在哪来决定的),然后我们调用 foo 函数,将返回值赋值给变量 baz 并调用,能够正常执行。实际上就是通过不同的标识符引用调用了内部的函数 bar。

但这段代码我们发现了一个问题,bar 是在自己定义的词法作用域之外被调用的。我们知道 JavaScript 有垃圾回收机制,用来释放不再使用的空间,在这里 foo 函数显然是不会在使用的,所以 foo 函数在被执行完之后,内部作用域应该会被销毁。但事实上内部作用域依然存在,所以是谁在持有对 foo 函数内部作用域的引用?答案显而易见——bar 函数。由于 bar 函数声明在 foo 函数内部,它拥有涵盖 foo 函数内部作用域的闭包,使得该作用域能一直存活,以供 bar 函数在之后的任何时间进行引用。

bar 函数依然持有对该作用域的引用,而这个引用就叫做闭包。

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

我们日常生活中其实就写过很多包含闭包的例子,比如定时器代码:

1
2
3
4
5
6
function wait(data) {
setTimeout(function timer() {
console.log(data);
},1000);
}
wait();

上述代码中 timer 函数就持有对 wait 函数内部作用域的引用,也就是闭包,所以也存在对于变量 data 的引用。

闭包和循环

看下面这个代码:

1
2
3
4
5
6
for(var i = 1; i < 6; i++>) {
setTimeout(function timer() {
console.log(i);
},i * 1000);
}
// 6 6 6 6 6

我们期望这个代码的输出是 1,2,3,4,5,每秒一个,但实际上他会输出 5 个 6,每秒一个。这是为啥呢?首先延迟函数的回调会在循环结束之后才执行,我们希望的是每次迭代都可以捕获一个 i 的副本,但其实不是,所有的函数都在一个共享的全局作用域内,所以只存在一个 i,循环结束时他的值为 6,因此会输出五次 6。

上述分析过后,我们知道了问题所在,在每次迭代时都能保存一个 i 的副本就行,那怎么实现呢?第一个办法就是 IIFE(立即执行函数):

1
2
3
4
5
6
7
8
9
for(var i = 1; i < 6; i++) {
(function(){
var j = i;
setTimeout(function timer() {
console.log(j);
},j * 1000);
})();
}
// 1 2 3 4 5

改进一下:

1
2
3
4
5
6
7
8
for(var i = 1; i < 6; i++) {
(function(i){
setTimeout(function timer() {
console.log(i);
},i * 1000);
})(i);
}
//1 2 3 4 5

因为 IIFE 可以通过声明并立即执行一个函数来创建作用域,我们在每次声明作用域的时候都将当前迭代的 i 的值传进去,这样就能实现每次每次迭代都能保存一个 i 副本,从而使得代码的逻辑和我们预想的相同。

如果你了解 ES6,还可以使用 let 来声明一个变量,我们知道 ES6 通过引入 let 关键字来实现块级作用域的创建。

1
2
3
4
5
6
7
for(var i = 1; i < 6; i++) {
let j = i;
setTimeout(function timer() {
console.log(j);
},j * 1000);
}
//1 2 3 4 5

改进一下:

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

引用书中的描写:

for 循环头部的 let 声明会有一个特殊行为,这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

小结

闭包在我们日常使用中经常出现,在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包。其实闭包实际上就是函数持有对内部作用域的引用,而这个引用就是闭包。对待闭包我们需要理解和使用,不需要刻意创建,就像书中所说,拥有一种根据你自己的意愿来识别、拥抱和影响闭包的思维环境!