深挖【let, for与定时器】引发的疑惑


建议您在阅览此文之前学完W3school - JS Tutorial章节所有内容

经典的问题

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

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

新手第一次看到这个问题由于没有深入了解setTimeout方法的执行机制就会得到错误的结果。

/*output
0
1
2
3
4
'hello world'
*/

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

认识单线程、任务队列和事件循环

单线程

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

知识延申:操作系统的进程和线程:

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

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

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

任务队列

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

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

所以,设计者把计算机的程序任务可以分为两种,同步任务和异步任务。同步任务:直接进入主线程执行的任务。前面的任务执行完,后面的才能执行,按顺序一个接一个的执行;异步任务:不会直接进入主线程,而是通过“任务队列”(task queue)通知“主线程队列”准备就绪才会进入主线程执行。

具体来说整个机制如下:

  1. 所有同步任务都在主线程上执行,生成一个执行栈(execution context stack)。
  2. 主线程外单独划分出一个任务队列(task queue)。异步任务在同步任务执行时也不会“偷懒”,同时运行得到结果。然后异步任务会生成一个对应的通知事件放置于“任务队列”。
  3. 执行栈中的同步任务执行完毕,系统就会读取任务队列中的通知事件,通知事件所对应的异步任务就会结束等待状态进入执行栈开始执行。并且通知事件遵循先进先出原则。
  4. 主线程会不断重复以上三步。
    机制流程示意图:
    执行栈一空就会读取任务队列,如此往复,这就是JS的运行机制。
    image

事件和回调函数的关系
任务队列中的通知事件包括了I/O设备事、用户点击、页面滚动等等。只要指定了回调函数(callback)这些事件就会进入任务队列,等待主线程读取。

回调函数(callback)的代码会被任务队列挂起。所以需要异步执行某个程序时就请使用回调函数,主线程读取任务队列时会先检查通知事件是否包含【定时器】确认执行时间之类的。

事件循环

主线程读取任务队列事件是往复循环的,整个机制被称之为事件循环(event loop)。
接下来参考Philip Roberts的演讲《Help, I'm stuck in an event-loop》深挖事件循环
image
从上面的图示我们能够看到,主线程执行时产生两个事物,分别是(heap)和(stack),栈会调用各种外部的WebAPI,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数。

定时器[setTimeout]

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

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

从前面【事件循环】小节我们知道了,setTimeout属于异步任务,它会生成一个事件(对应指定的回调函数)放进任务队列挂起,直到中的同步任务都执行完毕后,系统读取任务队列拿到通知事件对应的回调函数再放进执行并返回结果。

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

// 同步执行
for(var 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();

回过头来看文章开头那段代码,我们就可以利用闭包的原理让定时器打印出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循环变量泄露为全局变量的问题和变量覆盖的问题。

回到上面的代码,着重说下let是如何做到每次循环能够记忆当前i的值并传给下次循环的:

  1. 首先,在for循环中,设置循环变量的括号实质上是一个父作用域,而循环体是子作用域。
  2. let声明了该父作用域是块级作用域而不是全局作用域,每次循环i的值只对当前循环的块级作用域有效,就像是块级作用域是一支捕虫网,捕获循环更新的i值。循环一次就会更新块级作用域以及变量i,好比拿新的捕虫网来捕获新i
  3. 说白了,每次循环变量i会重新声明初始化i。实质上是JS引擎内部会记忆上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。
  4. 循环体内部函数会先访问本身的块级作用域,没有i就继续向上查询循环体作用域,没有i向上查询父作用域拿到当前循环记忆(捕获)的i值最后打印出来。
  5. 细心的朋友其实已经发现了,循环体内部函数 + 往上查询的块级作用域语境刚好组成了类似闭包的组合。

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

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

参考引用:
JavaScript 运行机制详解:再谈Event Loop
阮一峰ES6文档-let 和 const 命令