深挖【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
)通知“主线程队列”准备就绪才会进入主线程执行。
具体来说整个机制如下:
- 所有同步任务都在主线程上执行,生成一个
执行栈
(execution context stack)。 - 主线程外单独划分出一个
任务队列
(task queue)。异步任务在同步任务执行时也不会“偷懒”,同时运行得到结果。然后异步任务会生成一个对应的通知事件
放置于“任务队列”。 - 待
执行栈
中的同步任务执行完毕,系统就会读取任务队列
中的通知事件,通知事件所对应的异步任务就会结束等待状态
进入执行栈
开始执行。并且通知事件
遵循先进先出原则。 - 主线程会不断重复以上三步。
机制流程示意图:
执行栈
一空就会读取任务队列
,如此往复,这就是JS的运行机制。
事件和回调函数的关系
任务队列
中的通知事件
包括了I/O设备事、用户点击、页面滚动等等。只要指定了回调函数
(callback)这些事件就会进入任务队列
,等待主线程读取。
回调函数
(callback)的代码会被任务队列
挂起。所以需要异步执行某个程序时就请使用回调函数
,主线程读取任务队列
时会先检查通知事件
是否包含【定时器】确认执行时间之类的。
事件循环
主线程读取任务队列
事件是往复循环的,整个机制被称之为事件循环
(event loop)。
接下来参考Philip Roberts的演讲《Help, I'm stuck in an event-loop》深挖事件循环
从上面的图示我们能够看到,主线程执行时产生两个事物,分别是堆
(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
的值并传给下次循环的:
- 首先,在
for
循环中,设置循环变量的括号实质上是一个父作用域,而循环体是子作用域。 let
声明了该父作用域是块级作用域
而不是全局作用域
,每次循环i
的值只对当前循环的块级作用域
有效,就像是块级作用域
是一支捕虫网,捕获循环更新的i
值。循环一次就会更新块级作用域
以及变量i
,好比拿新的捕虫网来捕获新i
。- 说白了,每次循环变量
i
会重新声明初始化i
。实质上是JS引擎内部会记忆上一轮循环的值,初始化本轮的变量i
时,就在上一轮循环的基础上进行计算。 - 循环体内部函数会先访问本身的
块级作用域
,没有i
就继续向上查询循环体作用域,没有i
向上查询父作用域拿到当前循环记忆(捕获)的i
值最后打印出来。 - 细心的朋友其实已经发现了,循环体内部函数 + 往上查询的
块级作用域
语境刚好组成了类似闭包的组合。
对于不能兼容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 命令