本文首发于 洛竹的博客,暂未同步于任何平台。

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

要说明循环闭包,for 循环是最常见的例子。

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

由于很多开发者对闭包的概念认识地不是很清楚,因此当循环内部包含函数定义时,代码格式检查器经常发出警告,我们在这里介绍如何才能正确使用闭包并发挥它的威力,但是代码格式检查器并没有那么灵敏,它会假设你并不真正了解自己在做什么,所以无论如何都会发出警告。

正常情况下,我们对这段代码行为的预期是分别输出数字 1-5,每秒一次,每次一个。

但实际上,这段代码在运行时会以每秒一次的频率输出五次 6.

这是为什么?

首先解释 6 是从哪里来的。这个循环的终止条件是 i 不在 <=5。条件首次成立时 i 的值是 6。因此,输出显示的是循环结束时 i 的最终值。

仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是 setTimeout(..., 0),所有的回调函数依然是在循环结束后才被执行,因此会每次输出一个 6 出来。

这里引申出一个更深入的问题,代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢?

缺陷是我们试图假设循环中的每个迭代在运行时都会给自己 “捕获” 一个 i 的副本。但是个根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i

这样的话,当然所有函数共享一个 i 的引用。循环结构让我们误以为背后还有更复杂的机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环,那它同这段代码时完全等价的。

下面回到正题。缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

IIFE

通过 IIFE 会立即执行一个函数来创建作用域。我们可以利用 IIFE 的特性来创建一个作用域并保存每次循环的 i

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

当然,这些 IIFE 也不过就是函数,因此我们可以将 i 传递进去,如果愿意的话可以将变量名定义为 j,当然也可以还叫做 i。无论如何这段代码现在可以工作了。

在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

let

1
2
3
4
5
for(let i=1; i<= 5; i++) { // 通过let创建闭包的作用域
setTimeout(function() {
console.log(i)
}, i++)
}

let 隐式地创建了一个作用域,起到了闭包的作用。很酷是吧?块作用域和闭包联手便可天下无敌。