在一些文章中或者工作面试问题上,会遇见这种看似简单的经典问题。

for(var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i);
  });
}
console.log('hello word');
/*output
'hello word'
5
5
5
5
5
*/

对于老鸟来说这种问题不足挂齿,但是如果你是新手正在学习 js 的路上如火如荼或是刚好遇到了此类问题一知半解,那么这篇文章将给你带来原理和解答。 小小问题背后别有洞天。

单线程

JS 是典型的单线程语言,所谓单线程就是只能同时执行一个任务。
之所以是单线程而不是多线程,是为了避免多线程对同一 DOM 对象操作的冲突。比如 A 线程创造一 <div> 元素而 B 线程同时想要删除这个 <div> 元素那么就会出现矛盾。所以单线程是 JS 的核心特征。

操作系统的进程和线程
对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。

有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)。

一个进程至少有一个线程,复杂的进程有多个线程。操作系统通过多核cpu快速交替执行这些线程就给人一种同时执行的感觉。

同步和异步

单线程就意味着,所有任务需要排队,前一个任务结束,后一个任务才会执行。前面的任务耗时过长,后面的任务也得硬着头皮等待。而任务执行慢通常不是 CPU 性能不行,而是 I/O 设备操作耗时长,比如Ajax操作从网络获取数据。

JS 设计者意识到,遇到这种情况主线程可以完全不管 I/O 设备的结果,先挂起 I/O 耗时的任务,然后执行排在后面的任务。直到 I/O 设备返回了结果,并发来了通知,再回过来执行先前挂起的任务。

所以,设计者把浏览器的程序任务可以分为两种,同步任务异步任务

  • 同步任务:直接进入主线程执行的任务。前面的任务执行完,后面的才能执行,按顺序一个接一个的执行。
  • 异步任务:不会直接进入主线程,而是通过一个通知(条件触发)被添加进任务队列。然后主线程空闲的时候任务队列被通知可以推入一个任务进主线程执行。

例子中的代码运行机制看这里:
一文说清 JS 运行时环境(Event Loop)

定时器[setTimeout]

回过头来看文章开头那段代码

for(var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i);
  });
}
console.log('hello word');
  1. 首先setTimeout()方法中作为 Timers 属于异步任务,每次循环就会被分发到 WEB API's 的容器中,并且作为参数的匿名函数也会被存储到内存堆中,也就是说这种操作 JS 运行时 会重复 5 次。
  2. 由于设置的时间是 0 秒,所以 5 个匿名函数会紧接着按顺序被添加到任务队列中。
  3. 方法console.log('hello word')在循环完成后被推入执行栈执行,打印字符串。
  4. 此时执行栈为空,便通知任务队列推入待执行的回调匿名函数,执行完一个就推出清空执行栈,然后就推入下一个回调匿名函数。然后打印i,重复 5 次结束。

所以实质上可以看作(取巧方便理解,非实质):

// 同步执行
var i;
for(i = 0; i < 5; i++) {
}
// 同步执行
console.log('hello word');

// 异步执行
console.log(i);
console.log(i);
console.log(i);
console.log(i);
console.log(i);

作用域 + 闭包

作用域简单的说就是 JS 函数当前执行的上下文语境。函数在这个上下文语境中才能访问和引用这个语境中的其他变量。子作用域可以访问和引用父作用域中的变量,反之不行。

一个函数对象在JS中被创建的时候同时创建了闭包闭包是由该函数对象和它所在的语境而构成的一个组合。通常返回一个函数的引用。

// 一个典型的闭包
function makeFunc() {
  var text = "hello world";
  function displayName() {
      console.log(text);
  }
  return displayName;
}
var myFunc = makeFunc();
myFunc();// hello world

利用闭包

我们可以利用闭包的原理让定时器打印出 0, 1, 2, 3, 4。

for(var i = 0; i < 5; i++) {
  ((i) => {
    setTimeout(function () {
    console.log(i);
    });
  })(i);
}
console.log('hello word');

在上面的代码中,使用了一个技巧 立即函数 给计时器单独提供了一个新的作用域(上下文语境),加上里面的计时器就刚好组成了一个异步的闭包组合,而且是立刻调用的。

通过上面的手段就可以很好的避免var声明的循环变量暴露在全局作用域带来的问题。从而打印出 0, 1, 2, 3, 4。

利用块级作用域

通过let声明循环变量也是很好的解决手段,let允许你声明一个被限制在块作用域中的变量,这个就是块级作用域

for (let i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i);
  });
}
console.log('hello word');

let是ES6语法,而块级作用域的出现解决了var循环变量泄露为全局变量的问题和变量覆盖的问题。

利用 try...catch 语句

对于不能兼容ES6的浏览器,我们也可以使用ES5try...catch语句,形成类似闭包的效果。

for(var i = 0; i < 5; i++) {
  try {
    throw(i)
  } catch(j) {
    setTimeout(function () {
    console.log(j);
	});
  }
}
console.log('hello word');

总结

回到上面的代码,着重说下let是如何做到每次循环变量i能够保存当前的上下文语境,并传值传给下次循环的:

  • let关键字声明的变量i至始至终都是属于for循环块级作用域内的局部变量。
  • for循环每迭代一次,局部变量i就将当前的状态单独保存在内存中。
  • 而匿名函数的作用域链是:自身的活动对象 => for循环的块级作用域对应的变量对象 => 全局变量对象,所以匿名函数和for循环的块级作用域(上下文语境)形成了闭包这样的关系。
  • 所以匿名函数每次打印的i值都是局部变量i单独保存在内存中值。