问题描述

前几天去找前端的实习生工作,被一道 js 的闭包题目给卡住了,题目大致如下:

for (var i = 0; i < 10; i++) {
  setTimeout(function() {
    console.log(i);
    }, 0);
}

稍微了解 js 的异步机制的都知道,输出结果是: 10 10 10 ... 10

但是面试官又问我:其实希望得到的是0 1 2 ... 9,如何能够解决这个问题?我回答不上来。

思考分析

我一直从异步的角度在思考,最终的结论也就是:这里可以不用异步函数……

然而这是个闭包问题,面试官给我的答案是使用立即执行函数(IIFE)
我一下子明白过来,在《JavaScript高级程序设计》里就有收录这个问题。(Update: 第三版 7.2.1)

我发现,其实这个问题除了和闭包有关系之外,也和var变量与其他语言(比如java)不同的作用域有关系。
通过手动的方式写这个循环可以发现这些问题:

{
  var i = 0;
  setTimeout(function() { console.log(i); }, 0);
}
{
  var i = 1;
  setTimeout(function() { console.log(i); }, 0);
}
...
{
  var i = 9;
  setTimeout(function() { console.log(i); }, 0);
}
{
 var i = 10; 
 // Update : i 要到达 10 才会不满足语块的执行条件,之前没有注意,误导了大家,不好意思
}

所以for执行的每一步都是给i重新赋值和往事件队列推个事件。
由于for的语块不是var变量的作用域范围,在事件开始执行时,所有事件的回调函数通过闭包拿到的i是全局作用域下的同一个i

解决方法

1. 使用立即执行函数

for (var i=0; i < 10; i++) {
  (function (temp) {
    setTimeout(function() {
      console.log(temp);
    }, 0);
  })(i);
}

通过立即执行函数,回调函数闭包获得的不是原来的i,而是立即执行函数的参数,这个参数刚好是i的拷贝,闭包获得的拷贝并不指向内存中的同一对象,代码执行起来大概是这个样子:

{
  var i = 0;
  var temp0 = i;
  setTimeout(function() { console.log(temp0); }, 0);
}
{
  var i = 1;
  var temp1 = i;
  setTimeout(function() { console.log(temp1); }, 0);
}
...
{
  var i = 9;
  var temp9 = i;
  setTimeout(function() { console.log(temp9); }, 0);
}

2. 使用 ES6 的 let 标识符

for (let i = 0; i < 10; i++ ) {
  setTimeout(function() {
    console.log(i);
  }, 0);
}

是的,使用let的话就不会有这个闭包问题。
为什么呢?
原来,在ES6中,为了修正var奇怪的函数作用域,添加了let,它的作用域是语块:

{
  let a = true;
}
console.log(a); // undefined

现在再想象一下循环:

{
  let i = 0;
  setTimeout(function() { console.log(i); }, 0);
}
{
  let i = 1;
  setTimeout(function() { console.log(i); }, 0);
}
...
{
  let i = 9;
  setTimeout(function() { console.log(i); }, 0);
}

回调函数闭包获取的i不再是同一个了!