闭包
文章目录

ECMAScript 最易让人误解的一点是, 它支持闭包 (closure) .

闭包, 指的是词法表示 包括不被计算的变量的函数 , 也就是说 函数可以使用函数之外定义的变量 .

简单的闭包实例

在 ECMAScript 中使用全局变量是一个简单的闭包实例. 请思考下面这段代码:

1
2
3
4
5
6
7
var sMessage = "hello world";

function sayHelloWorld() {
alert(sMessage);
}

sayHelloWorld();

在上面这段代码中, 脚本被载入内存后, 并没有为函数 sayHelloWorld() 计算变量 sMessage 的值. 该函数捕获 sMessage 的值只是为了以后的使用, 也就是说, 解释程序知道在调用该函数时要检查 sMessage 的值. sMessage 将在函数调用 sayHelloWorld() 时 (最后一行) 被赋值, 显示消息 “hello world”.

复杂的闭包实例

在一个函数中定义另一个会使闭包变得更加复杂. 例如:

var iBaseNum = 10;

function addNum(iNum1, iNum2) {
  function doAdd() {
    return iNum1 + iNum2 + iBaseNum;
  }
  return doAdd();
}

这里, 函数 addNum() 包括函数 doAdd() (闭包) . 内部函数是一个闭包, 因为它将获取外部函数的参数 iNum1 和 iNum2 以及全局变量 iBaseNum 的值. addNum() 的最后一步调用了 doAdd(), 把两个参数和全局变量相加, 并返回它们的和.

这里要掌握的重要概念是, doAdd() 函数根本不接受参数, 它使用的值是从执行环境中获取的.

可以看到, 闭包是 ECMAScript 中非常强大多用的一部分, 可用于执行复杂的计算.

提示: 就像使用任何高级函数一样, 使用闭包要小心, 因为它们可能会变得非常复杂.

闭包与循环

前言

https://developer.mozilla.org/zh-CN/docs/JavaScript/Guide/Closures
MDN 上描述闭包的章节阐述了一个由于闭包产生的常见错误, 代码片段是这样的

1
2
3
4
5
6
for (var i = 0; i < helpText.length; i++) {
var item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}

简言之就是循环中为不同的元素绑定事件, 事件回调函数里如果调用了跟循环相关的变量, 则这个变量取循环的最后一个值.

由于绑定的回调函数是一个匿名函数, 所以文中把造成这个现象的原因归结为 这个函数是一个闭包, 携带的作用域为外层作用域, 当事件触发的时候, 作用域中的变量已经随着循环走到最后了.

注: 闭包 = 函数 + 创建该函数的环境

我对此产生了很多疑问, 如果说闭包是函数和创建时的环境, 那么事件绑定的时候 (也就是这个匿名函数创建的时候) , 循环中的环境应该是循环当次, 为什么直接到最后一次了呢? 下面我们就一步一步分析, 究竟是什么原因造成的.


简单循环中的 i

为了搞懂这个问题, 我们从最简单的循环开始

1
2
3
for (var i = 0; i < 5; i++) {
console.log(i)
}

毫无疑问, i 会被逐次打印出来

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

这里, i 也会被逐次打印出来, 因为 js 里, 外层函数作用域会影响内层, 而内层不会影响外层. 基于这个原理, 我们也可以加多少层都没关系:

1
2
3
4
5
6
7
8
for (var i = 0; i < 5; i++) {
var a = function(){
return function(){
console.log(i)
}
}
a()()
}

每一层匿名函数和变量 i 都组成了一个闭包, 但是这样在循环中并没有问题, 因为函数在循环体中立即被执行了. setTimeout和事件则不太一样, 详见下文.


setTimeout 在循环里

-setTimeout在循环中会怎样呢?

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

不出所料, 这里果然出问题了, 打印出来的结果为 5 个 5, 遇到了前言中所述的由于闭包所引起的常见错误.

根据内部可调用外部作用域的原理, setTimeout的回调函数里面调用了外层的 i, i 和回调函数组成了闭包. i 在循环执行之前是 0, 循环之后是 5.

一切都顺理成章, 很好理解, 问题就是为什么setTimeout的回调不是每次取循环时的值, 而取最后一次的值, 难道setTimeout回调是在循环体外触发的?

会不会是时间的问题, 我们把setTimeout的回调延迟设为 0 毫秒试一下.

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

这并没有解决问题

另注: 其实setTimeout的延迟时间是存在最小值的, 根据浏览器的不同有可能是 4ms 或者 5ms, 这意味着就算setTimeout设为 0, 还是有一小段的延迟的.
详见: https://developer.mozilla.org/en-US/docs/Web/API/Window.setTimeout#Notes

为了测试究竟是不是时间的问题, 我采用了下面这种更加残暴的方式:

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

循环 100 次, 一次普通调用, 一次在setTimeout里面调用, 如果存在延迟, 那么setTimeout出来的结果会在一个中间点, 很难是 100.

执行出来的结果是这样的:

实验发现, 无论如何setTimeout都在最后执行, 这证实了我们之前遇到的问题, 因为setTimeout在循环结束才执行, 所以回调函数调用的 i 取值必然是循环的最后一次.

-setTimeout为什么会在最后执行呢, 这是因为setTimeout的一种机制, setTimeout是从任务队列结束的时候开始计时的, 如果前面有进程没有结束, 那么它就等到它结束再开始计时. 在这里, 任务队列就是它自己所在的循环. 循环结束setTimeout才开始计时, 所以无论如何,setTimeout里面的 i 都是最后一次循环的 i.

解决办法如下:

1
2
3
4
5
6
7
8
for (var i = 0; i < 5; i++) {
var a = function(v){
return function(){
console.log(v)
}
}
setTimeout(a(i),0)
}

很多人能利用上面的方法解决这个问题, 因为setTimeout第一个参数需要一个函数, 所以返回一个函数给它, 返回的同时把 i 作为参数传进去, 通过形参 v 缓存了 i, 并带进返回的函数里面.

下面这个方法则不行:

1
2
3
4
5
6
7
8
9
10
for (var i = 0; i < 5; i++) {
var a = function(v){
return function(){
console.log(v)
}
}
setTimeout(function(){
a(i)
},0)
}

这里的问题是, 回调函数没有立即执行, 本身又没有传入参数缓存 .

总结: 例子中遇到setTimeout的问题, 罪魁祸首是回调等待循环队列结束造成的, 解决的办法是给回调函数传一个实参缓存循环的数据.


循环中的事件

循环中的事件和setTimeout类似, 也会涉及闭包问题, 事件的 listener, 会和循环相关的变量形成一个闭包, 在执行 listener 的时候, 变量取最后一次循环的值.

1
2
3
4
5
6
for (var i = 0; i < 5; i++) {
var a = function(){
console.log(i)
}
document.body.addEventListener('click',a)
}

但是和setTimeout不一样的是, 事件是需要触发的, 而绝大多数情况下, 触发的时候循环已经结束了, 所以循环相关的变量就是最后一次的取值 , 比如上例中, 点击 body 以后 console 5 次 5, 通过addEventListener添加的事件是可以叠加的.

考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
for (var i = 0; i < 2; i++) {
var a = function(){
console.log(i)
}
document.body.addEventListener('click',a)
}

for (var i = 0; i < 5; i++) {
var a = function(){
console.log(i)
}
document.body.addEventListener('click',a)
}

答案是:

2 次 5 和 5 次 5, 因为两次循环使用了同样的全局变量 i, 你点击的时候这个 i 已经变成了 5, 不管事件是在两次循环里绑定的还是五次循环里绑定的, 点击回调只认全局变量 i, 跟在哪绑定的没关系.

如果我们想要 2 次 2 和 5 次 5, 就需要把前一次循环放到函数作用域里或者把其中一个 i 换成别的变量名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(function(){
for (var i = 0; i < 2; i++) {
var a = function(){
console.log(i)
}
document.body.addEventListener('click',a)
}

})()
for (var i = 0; i < 5; i++) {
var a = function(){
console.log(i)
}
document.body.addEventListener('click',a)
}

至于解法, 和setTimeout类似, 也是通过 listner 形参缓存循环中的变量, 以下代码中, 函数 a 返回一个函数, 因为addeventlistner第二个参数接受的是函数, 所以要这么写, 而要执行的内容, 写在返回的这个函数体内.

1
2
3
4
5
6
7
8
for (var i = 0; i < 5; i++) {
var a = function(v){
return function(){
console.log(v)
}
}
document.body.addEventListener('click',a(i))
}

总结

闭包并没有那么复杂, 可以简单的理解为函数体和外部作用域的一种关联.

  • setTimeout和绑定事件在循环经常会带来意想不到的效果, 取决于这两个函数的特殊机制, 闭包不是主因.
  • 如果想在setTimeout和绑定事件保存住循环过程中产生的变量, 需要通过函数的实参传进函数体.