用来展示闭包最常见最权威的例子是老实巴交的 for 循环。

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

注意: 当你将函数放在循环内部时 Linter 经常会抱怨,因为不理解闭包的错误 在开发者中太常见了。我们在这里讲解如何正确地利用闭包的全部力量。但是 Linter 通常不理解这样的微妙之处,所以它们不管怎样都将抱怨,认为你 实际上 不知道你在做什么。

这段代码的精神是,我们一般将 期待 它的行为是分别打印数字“1”,“2”,……“5”,一次一个,一秒一个。

实际上,如果你运行这段代码,你会得到“6”被打印5次,一秒一个。

啊?

首先,让我们解释一下“6”是从哪儿来的。循环的终结条件是 i <=5。第一次满足这个条件时 i 是6。所以,输出的结果反映的是 i 在循环终结后的最终值。

如果多看两眼的话这其实很明显。超时的回调函数都将在循环的完成之后立即运行。实际上,就计时器而言,即便在每次迭代中它是 setTimeout(.., 0),所有这些回调函数也都仍然是严格地在循环之后运行的,因此每次都打印 6

但是这里有个更深刻的问题。要是想让它实际上如我们在语义上暗示的那样动作,我们的代码缺少了什么?

缺少的东西是,我们试图 暗示 在迭代期间,循环的每次迭代都“捕捉”一份对 i 的拷贝。但是,虽然所有这5个函数在每次循环迭代中分离地定义,由于作用域的工作方式,它们 都闭包在同一个共享的全局作用域上,而它事实上只有一个 i

这么说来,所有函数共享一个指向相同的 i 的引用是 理所当然 的。循环结构的某些东西往往迷惑我们,使我们认为这里有其他更精巧的东西在工作。但是这里没有。这与根本没有循环,5个超时回调仅仅一个接一个地被声明没有区别。

好了,那么,回到我们火烧眉毛的问题。缺少了什么?我们需要更多 铃声 被闭包的作用域。明确地说,我们需要为循环的每次迭代都准备一个新的被闭包的作用域。

我们在第三章中学到,IIFE 通过声明并立即执行一个函数来创建作用域。

让我们试试:

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

这好用吗?试试。我还会等你。

我来为你终结悬念。不好用。 但是为什么?很明显我们现在有了更多的词法作用域。每个超时回调函数确实闭包在每次迭代时分别被每个 IIFE 创建的作用域中。

拥有一个被闭包的 空的作用域 是不够的。仔细观察。我们的 IIFE 只是一个空的什么也不做的作用域。它内部需要 一些东西 才能变得对我们有用。

它需要它自己的变量,在每次迭代时持有值 i 的一个拷贝。

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

万岁!它好用了!

有些人偏好一种稍稍变形的形式:

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

当然,因为这些 IIFE 只是函数,我们可以传入 i,如果我们乐意的话可以称它为 j,或者我们甚至可以再次称它为 i。不管哪种方式,这段代码都能工作。

在每次迭代内部使用的 IIFE 为每次迭代创建了新的作用域,这给了我们的超时回调函数一个机会,在每次迭代时闭包一个新的作用域,这些作用域中的每一个都拥有一个持有正确的迭代值的变量给我们访问。

问题解决了!

重温块儿作用域

仔细观察我们前一个解决方案的分析。我们使用了一个 IIFE 来在每一次迭代中创建新的作用域。换句话说,我们实际上每次迭代都 需要 一个 块儿作用域。我们在第三章展示了 let 声明,它劫持一个块儿并且就在这个块儿中声明一个变量。

这实质上将块儿变成了一个我们可以闭包的作用域。所以接下来的牛逼代码“就是好用”:

  1. for (var i=1; i<=5; i++) {
  2. let j = i; // 呀,给闭包的块儿作用域!
  3. setTimeout( function timer(){
  4. console.log( j );
  5. }, j*1000 );
  6. }

但是,这还不是全部!(用我最棒的 Bob Barker 嗓音)在用于 for 循环头部的 let 声明被定义了一种特殊行为。这种行为说,这个变量将不是只为循环声明一次,而是为每次迭代声明一次。并且,它将在每次后续的迭代中被上一次迭代末尾的值初始化。

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

这有多酷?块儿作用域和闭包携手工作,解决世界上所有的问题。我不知道你怎么样,但这使我成了一个快乐的 JavaScript 开发者。