0%

探寻闭包本质

来听我讲讲关于闭包的故事吧!


前段时间读红宝书,边读边写读书笔记。在读到第七章关于函数表达式部分时,我总觉得书里的引入和讲解都让人有点摸不着头脑,倒也不是因为先入为主地认为它难,就是感觉它在把所有内容混杂在一起,没有一以贯之的主线和条理,因此当时写的读书笔记也有点混乱。

这样混乱的理解对于爱钻牛角尖的我来说,完全是心里过不去的一道坎儿。在多方探寻之后,我去读了读《你不知道的JavaScript》中的相关部分,发现它引入的点竟然和我的想法不谋而合!于是反复读了几遍,感觉收获很多,我喜欢这样说人话的书!

既然有所理解,那就记下来吧。Here we go~

不说闭包

我们先从JavaScript的作用域链开始讲起。
我们知道,JavaScript中有一种叫做作用域链的机制。所谓作用域链,就是程序在执行并需要获取一个变量时,在代码中寻找该变量值的一条路径:先从当前作用域找值,无果则去最近的父作用域找值,无果再向上一层找值…直到到达范围最大的全局作用域为止。因此很容易理解下面的代码:

1
2
3
4
5
6
function father() {
var a = 2;
function son() {
console.log(a);
}
}

内层son()的内部没有去定义a,但是却可以拿到外层father()a。但仅有函数和变量的声明并不能log出a的值(那是当然!),因此我们需要执行一下son()函数。那么问题来了,怎么执行呢?
笨蛋,当然是直接调用呀!但是很快我们发现,外部无法直接调用son(),它只能在 father()内被访问到,所以我们可以在father()内调用son(),再在全局作用域上调用father(),这样就能完美地log出a了:

1
2
3
4
5
6
7
8
function father() {
var a = 2;
function son() {
console.log(a);
}
son();
}
father();

此处son受限于father,只能在father的视线范围(词法作用域)内被允许执行,并在被执行的过程中透露father的信息,那么我们在想,有什么方法可以让son逃脱father的视线去被执行呢?
…brainstorming…
公布答案!这里的核心思想在于把son给送到外面,怎么送呢?

  • 把内部函数return出去
1
2
3
4
5
6
7
8
9
function father() {
var a = 2;
function son() {
console.log(a);
}
return son;
}
var outerson = father();
outerson();
  • 把内部函数作为其他函数的参数传出去
1
2
3
4
5
6
7
8
9
10
11
function father() {
var a = 2;
function son() {
console.log(a);
}
deliver(son);
}
function deliver(func) {
func();
}
father();
  • 把内部函数赋值给全局变量来共享出去
1
2
3
4
5
6
7
8
9
10
var outerson;
function father() {
var a = 2;
function son() {
console.log(a);
}
outerson = son;
}
father();
outerson();

上面所有方法都是在通过内部函数来泄露外部函数的信息,不同的是,第一个情况下,内部函数是在当前词法作用域内执行的,而后面三个情况下,内部函数则是在当前词法作用域之外执行的。
好了,上面的道理我都懂,但是闭包到底是什么呢?

闭包是什么?

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

多读几遍,不知道你会不会恍然大悟。闭包就是基于词法作用域书写代码时所产生的自然结果,我们甚至不需要为了利用它们而有意地创建闭包,我们要做的是去识别、了解闭包的工作原理以及规避一些潜在的副作用。
通常人们并不把第一种情况视为闭包,因为它没什么特殊之处,但后面几种就有意思多了,譬如可以找到它们的特性:

  • 闭包可以使其外部作用域一直存活而不被垃圾回收
  • 闭包使得函数可以继续访问定义时的词法作用域

常用闭包举例

闭包在代码里很常见,我们不妨来识别识别。

1
2
3
4
5
6
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}
wait("Hello closure!");

在这里,内部函数timer()具有涵盖外部函数wait()作用域的闭包,因此还保有对变量message的引用。
执行wait()函数时,setTimeout()会在1s后将timer()添加到任务队列。
timer()要执行时,也就是wait()执行1s后,它的内部作用域并不会消失,timer()依然保有wait()作用域的闭包。
在整个过程中,词法作用域都是保持完整的,而这就是闭包。
再举一个例子:

1
2
3
4
5
6
7
function setupBot(name, selector) {
$(selector).click(function activator() {
console.log("Activating: " + name);
});
}
setupBot("Closure Bot 1", "#bot_1");
setupBot("Closure Bot 2", "#bot_2");

这也是闭包噢!内部的activator()获取了外部setupBot()name
引用一下书里的总结吧:如果将函数当作一等公民并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者其他的异步或同步任务中,只要使用了回调函数,实际上就是在使用闭包。

常见闭包错误

闭包之所以会被觉得难懂,是因为它时常和其他东西混杂在一起,譬如延迟执行的setTimeout和立即执行的IIFE、块级作用域和函数作用域、for循环等。

立即执行函数

对于立即执行的函数来说,for循环和闭包的杂糅是不会造成歧义的,譬如以下的两种写法,都会输出相同的内容:

1
2
3
4
5
6
for(var i = 0; i < 5; i++) {
function shout(arg) {
console.log(arg);
}
shout(i); // 0 1 2 3 4
}
1
2
3
4
5
6
for(var i = 0; i < 5; i++) {
function shout() {
console.log(i);
}
shout(); // 0 1 2 3 4
}

在第一种写法中,循环中的每次迭代都会给自己捕获一个i的副本,因为每次的shout()都是有参数传进去的,所以相当于shout(0)shout(1)shout(4)
在第二种写法中,循环中的每次迭代都会指向同一个i,也就是外部的i,相当于shout(i)shout(i)shout(i)。不过由于是立即执行而非延迟执行,几个shout(i)处在i为不同值的不同时刻,因此也能得到和第一种写法一样的效果。

延迟执行函数

但对于延迟执行的函数来说,for循环和闭包的杂糅就让事情变得复杂了些。
举一个最为常见的例子:

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

猜猜它会怎样输出?它会以每秒一次的频率输出五次5。
首先要明确,这里循环中的每次迭代都会指向同一个i,也就是外部的i,不过对于立即执行和延迟执行是有区别的。
setTimeout()本身是立即执行的,所以参照上面的第二种写法,相当于setTimeout(timer, i*1000)setTimeout(timer, i*1000)setTimeout(timer, i*1000),几个setTimeout(timer, i*1000)处在i为不同值的不同时刻,相当于setTimeout(timer, 0*1000)setTimeout(timer, 1*1000)setTimeout(timer, 4*1000),因此它会在0s、1s…4s后分别执行timer(),共5次。
timer()就是延迟执行了,延迟函数的回调会在循环结束时才执行,(即使是延迟0s,回调函数依然是在循环结束后才被执行),这个时候的i就已经是5了,所以timer()内部log到的就是从外面获取到的i,也就是5了。
如果想要达到预期的每隔一秒按顺序log出0-4的效果,我们需要让每个timer()有自己的i的副本。做法的话,要么就直接作为参数传进去,要么就在内外作用域之间再加一层作用域并把每轮迭代的i放进去。

  • 直接传参
1
2
3
4
5
6
for(var i = 0; i < 5; i++) {
function timer(arg) {
console.log(arg);
}
setTimeout(timer(i), i * 1000);
}

当然,把函数的定义放到外面也是一样的。

1
2
3
4
5
6
function timer(arg) {
console.log(arg);
}
for(var i = 0; i < 5; i++) {
setTimeout(timer(i), i * 1000);
}
  • 加一层作用域并传参

可以使用IIFE来新增作用域:

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

在这里,每次迭代的时候,arg都会取到i的副本。当然,还可以把i作为新作用域的参数传进来。

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

除了使用IIFE新增一层函数作用域,还可以使用ES6的let来新增一层块级作用域。

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

在这里,let声明的arg拥有块级作用域,每次迭代都会有自己专属的块,块内的arg也都会取到i的副本。当然还可以对它进行改写,for循环头部的let声明还会有一个特殊的行为,该行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每次迭代都会使用上一个迭代结束时的值来初始化这个变量。

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

*发现小问题
在控制台跑上面六段代码时,我发现直接传参的两段代码都会直接log出0、1、2、3、4,也就是虽然输出结果对了,但是并没有延迟执行,而加一层作用域并传参的四段代码都能正确地每间隔1s按顺序log出0、1、2、3、4。
查了下,setTimeout()没有延迟执行的原因是:如果第一个参数直接传入一个可执行代码,那它就会立即执行。

1
2
3
4
5
6
// 立即执行
setTimeout(console.log("hello"), 1000);

// 延迟执行
setTimeout('console.log("hello")', 1000);
setTimeout(function() {console.log("hello")}, 1000);

总结

其实就我的理解来看,闭包就是函数对其外部作用域的引用,而使用闭包容易出错的原因就在于引用。我们可以控制引用的指向,但是我们不能控制引用的内容,因为同一个引用可能是被多方共享操纵的,可能会在不同阶段呈现出不同的值。因此,想要明确自己通过引用获得什么值,要做的就是厘清一系列的操作时间线。而如果想要摆脱引用,那就得自己创建一个副本,这样就可以把需要的内容给定格住了。
闭包其实有好多应用,譬如模块化就是基于闭包的。不过讲到应用,就需要另起一篇啦。
至此,故事就讲完啦,希望能有所收获!