为什么要了解js
中的事件循环
javascript
是一种基于事件的单线程、异步、非阻塞编程语言,我经常在看书或者浏览别人博客的时候看到这种说法,可是之前一直没有深入理解过,只知道javascript
中经常使用各种回调函数,比如浏览器
端的各种事件回调(点击事件、滚动事件等)、ajax
请求回调、setTimeout
回调以及react16
的内核fiber
中用到的requestAnimationFrame
、promise
回调、node
端fs
模块异步读取文件内容回调、process
模块的nextTick
等等。最近有时间浏览了各种资料(后面有各种相关资料的链接),终于明白所有的这些内容其实都离不开javascript事件循环
,而浏览器
端的事件循环又与node
端的事件循环有较大区别,下面分别介绍下。
浏览器 vs node
自从有了node
,javascript
既可以运行在浏览器端又可以运行在服务端,以chrome
浏览器为例,相同点是都基于v8
引擎,不同的是浏览器端实现了页面渲染、而node端则提供了一些服务端会用到的特性,比如fs
、process
等模块,同时node端为了实现跨平台,底层使用libuv
来兼容linux
、windows
、 macOS
三大操作系统。因此虽然都实现了javascript
的异步、非阻塞特性,但是却有有不少不同之处。
浏览器端
无论是在浏览器
端还是node
端,线程入口都是一个javascript
脚本文件,整个脚本文件从第一行开始到最后运行完成可以看作是一个entry task
,即初始化任务,下图task
中第一项即为该过程。初始化过程中肯定会注册不少异步事件
,比如常见的setTimeout
、onClick
、promise
等,这些异步事件执行中又有可能注册更多异步事件。所有的这些异步任务都是在事件循环
一次次的循环中得到执行,而这些异步任务又可以分为两大类,即microtask
和task(或macrotask)
。那么一次事件循环中会执行多少个异步任务?microtask
和task
的执行先后顺序是什么呢?看下图。
先忽略图中的红色部分(渲染过程,后面再介绍),顺时针方向即为事件循环方向,可以看出每次循环会先后执行两类任务,task
和microtask
,每一类任务都由一个队列组成,其中task
主要包括如下几类任务:
1、 index.js(entry)
2、 setTimeout
3、 setInterval
4、 网络I/O
而microtask
主要包括:
1、 promise
2、 MutationObserver
因此microtask
的执行事件结点是在两次task
执行间隙。前面说了,每类任务都由一个队列组成,这其实是一种生产者-消费者
模型,事件的注册过程即为任务生产
过程,任务的执行过程即为事件的消费
过程。那么每次轮到一类任务执行各个队列会出队多少个任务来执行呢?图中我已经标明,task
队列每次出队一项任务来执行,执行完成之后开始执行microtask
;而microtask
则每次都把所有(包括当前microtask
执行过程中新添加的任务)任务执行完成,然后才会继续执行task
。也就是说,即便microtask
是异步任务,也不能无节制的注册,否则会阻塞task
和页面渲染
的执行。比如,下面的这段代码中的setTimeout
回调任务将永远得不到执行(注意,谨慎运行这段代码,浏览器可能卡死):
setTimeout(() => {
console.log('run setTimout callback');
}, 0);
function promiseLoop() {
console.log('run promise callback');
return Promise.resolve().then(() => {
return promiseLoop();
});
}
promiseLoop();
现在回过头来再看上图中的粉红色虚线部分,该过程表示的是浏览器渲染过程,比如dom
元素的style
、layout
以及position
这些渲染,那为什么用虚线表示呢?是因为该部分的调度是由浏览器控制,而且是以60HZ
的频率调度,之所以是60HZ
是为了能满足人眼视觉效果的同时尽量低频的调度,如果浏览器一刻不停的频繁渲染,那么不仅人眼观察不到界面的变化效果(就如同夏天电扇转太快人眼分辨不出来),而且耗费计算资源。因此上图中渲染过程用虚线表示不一定每次事件循环都会执行渲染过程。
仔细看虚线框起来的渲染过程,可以看到在执行渲染之前可以执行一个回调函数requestAnimationFrame
,执行渲染之后可以执行一个回调函数requestIdleCallback
。使用这两个钩子函数注册的回调函数同task
回调和microtask
回调一样,会进入专属的事件队列,但是这两个钩子函数与setTimeout
不一样,不是为了在4ms,16ms或1s
之后再执行,而是在下一次页面渲染
阶段去执行,具体来说是requestAnimationFrame
在style
和layout
计算之前执行,requestIdleCallback
则是在变更真正渲染到页面后执行。
requestAnimationFrame
比setTimeout
更适合做动画,这里有个例子可以参考:jsfiddle.net/H7EEE/245/。效果如下图所示,可以看出requestAnimationFrame
比setTimeout
动画效果更加流畅。
而
requestIdleCallback
则是在每一渲染贞后的空闲时间去完成回调任务,因此一般用于一些低优先级的 任务调度
,比如 react16
则使用了该钩子函数实现异步 reconcilation
算法以保证页面性能,当然由于 requestIdleCallback
是比较新的 API
, react
团队实现了 pollyfill
,注意是目前是使用 requestAnimationFrame
实现的哦。
现在总结一下浏览器端的事件队列,共包括四个事件队列:task
队列、requestAnimationFrame
队列、requestIdleCallback
队列以及microtask
队列,javascript
脚本加载完成后首先执行第一个task
队列任务,即初始化任务
,然后执行所有microtask
队列任务,接着再次执行第二个task
队列任务,以此类推,这其中穿插着60HZ
的渲染过程
。先执行谁后执行谁现在了解清楚了,可是到每个事件队列执行的轮次时,分别会有多少个事件出队执行呢?答案见下图(截图自Jake Archibald
大神的JSConf
演讲视频):
可以看出,在一次事件循环中: 普通
task
每次出队一项回调函数去执行,requestAnimationFrame
每次出队所有当前队列的回调函数去执行(requestIdleCallback
一样),microtask
每次出队所有当前队列的回调函数以及自己轮次执行过程中又新增到队尾的回调函数。这三种不同的调度方式正好覆盖了所有场景。
实践一下
demo1: 对比index.js
、promise
、setTimeout
的执行先后顺序
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
new Promise(function (resolve) {
console.log('promise1.1');
resolve();
}).then(function () {
console.log('promise1.2');
}).then(function () {
console.log('promise1.3');
}).then(function () {
console.log('promise1.4');
});
new Promise(function (resolve) {
console.log('promise2.1');
resolve();
}).then(function () {
console.log('promise2.2');
}).then(function () {
console.log('promise2.3');
}).then(function () {
console.log('promise2.4');
});
console.log('script end');
这段代码的输入如下:
script start
promise1.1
promise2.1
script end
promise1.2
promise2.2
promise1.3
promise2.3
promise1.4
promise2.4
setTimeout
按照前面的事件循环示例图,按照如下顺序执行:
1、 执行task
(index.js); 这里包括四项输出:script start
、promise1.1
、promise2.1
、script end
。其中需要留意promise1.1
和promise2.1
,因为new Promise
中resolve()
调用之前也是同步代码,因此也会同步执行。
2、 执行microtask
; 这里需要留意microtask
会边执行边生成新的添加到事件队列
队尾,因此执行完所有microtask
才重新进入事件循环开始下一项。
3、 执行task
(setTimeout); 根据前面的示例图,这里又轮到了task
的执行,只不过这次是setTimout
。
node端
前面介绍了下浏览器端的事件循环,涉及到task
和microtask
,其实node端的异步任务也包括这些,只不过node
端的task
划分的更细,如下图所示,node
端的task
可以分为4类任务队列:
1、 index.js(entry)、setTimeout
、setInterval
2、 网络I/O、fs(disk)
、child_process
3、 setImmediate
4、 close事件
而microtask
包括:
1、 process.nextTick
2、 promise
开始后会首先执行注册过的所有
microtask
,然后会依次执行该4类task
队列。而每执行完一个task
队列就会接着执行microtask
队列,然后再接着执行下一个task
队列。因此microtask
队列的执行是穿插在各个类形的task
之间的,当然也可以。 node
端与浏览器
端事件循环的一个很重要的不同点是,浏览器
端task
队列每轮事件循环仅出队一个回调函数去执行接着去执行microtask
,而node
端只要轮到执行task
,则会跟执行完队列中的所有当前任务,但是当前轮次新添加到队尾的任务则会等到下一轮次才会执行,该机制与浏览器端的requestAnimationFrame
的调度机制时一样的。 总结一下node
端的事件循环,共包括4类task
事件队列与2类microtask
事件队列,microtask
穿插在task
之间执行。task
每次轮到执行会将当前队列中的所有回调函数出队执行,而microtask
的调度机制则与浏览器
端一样,每次轮到执行都会出队所有当前队列的回调函数以及自己轮次执行过程中又新增到队尾的回调函数去执行。与浏览器
端不一样的是node
端的microtask
包括process.nextTick
和promise
两类。
实践一下
demo1: 对比promise与setTimeout的执行顺序
console.log('main');
setTimeout(function () {
console.log('execute in first timeout');
Promise.resolve(3).then(res => {
console.log('execute in third promise');
});
}, 0);
setTimeout(function () {
console.log('execute in second timeout');
Promise.resolve(4).then(res => {
console.log('execute in fourth promise');
});
}, 0);
Promise.resolve(1).then(res => {
console.log('execute in first promise');
});
Promise.resolve(2).then(res => {
console.log('execute in second promise');
});
前面这段代码的输出结果如下:
main
execute in first promise
execute in second promise
execute in first timeout
execute in second timeout
execute in third promise
execute in fourth promise
执行顺序如下:
1、 index.js(主程序代码main);
2、 microtask(promise1, promise2);
3、 task(setTimeout1, setTimeout2);
4、 microtask(promise3, promise4);
这个执行顺序与之前画的图完全对应。
demo2: 对比index.js
、promise
、async await
、setTimeout
的执行先后顺序
console.log('script start');
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('entry async2');
return Promise.resolve();
}
setTimeout(function () {
console.log('setTimeout');
}, 0);
async1();
new Promise(function (resolve) {
console.log('promise1');
resolve();
}).then(function () {
console.log('promise2');
}).then(function () {
console.log('promise3');
}).then(function () {
console.log('promise4');
}).then(function () {
console.log('promise5');
}).then(function () {
console.log('promise6');
});
console.log('script end');
这段代码在node10
环境的执行结果如下:
script start
async1 start
entry async2
promise1
script end
promise2
promise3
promise4
async1 end
promise5
promise6
setTimeout
注意我这里强调了是node10
环境,是因为node8
和node9
下面async await
有bug
,而node10
中得到了修复,详情可以参考这篇文章:Faster async functions and promises。下面按照前面的事件循环
示例图分析下前面这段代码的执行结果:
1、 执行task
(index.js); 这里包括5项输出:script start
、async1 start
、entry async2
、promise1
、script end
。这里要注意async
函数中第一个await
之前执行的代码也是同步代码,因此会打印出scync1 start
以及entry async2
。
2、 执行microtask
; 这里打印了所有剩下的promise
以及一个位于await
后的语句async1 end
。打印这个集合肯定是没问题的,但是问题是为什么async1 end
会比promise
延迟3个呢? 这个问题是这段代码最难懂的地方,答案在刚刚提到的那篇文章中:每个await
需要至少3个microtask queue ticks
,因此这里async1 end
的打印相对于promise
晚打印了3个tick
。其实通过这里例子我们也应该的出一个结论,就是最要不要把promise
和async await
混用,否则容易时序混乱。
3、 执行task
(setTimeout)。 根据前面的示例图,这里又轮到了task的执行,只不过这次是setTimout。 从demo2
可以看出,虽然async await
本质上也是microtask
,但是每个await
会耗费至少3个microtask queue ticks
,这点需要注意。
引用
本篇总结主要参考了如下资源,强烈推荐浏览阅读:
1、 Jake Archibald: In The Loop – JSConf.Asia 2018
2、 Philip Roberts: What the heck is the event loop anyway? – JSConf.EU 2014
3、 Everything You Need to Know About Node.js Event Loop – Bert Belder, IBM
4、 Event Loop and the Big Picture — NodeJS Event Loop Part 1
5、 Timers, Immediates and Process.nextTick— NodeJS Event Loop Part 2
6、 Promises, Next-Ticks and Immediates— NodeJS Event Loop Part 3
7、 Handling IO — NodeJS Event Loop Part 4
8、 Event Loop Best Practices — NodeJS Event Loop Part 5
9、 Using requestIdleCallback