这篇文章主要来聊一下事件循环;什么是事件循环?通常情况下,js是单线程处理主要任务,而除了同步逻辑之外,还有大部分异步逻辑;事件循环的规则就用于协调同步与异步任务的调用。js的事件循环在浏览器与nodejs不太一样,后面会展开说一下。
异步任务分类比较多,在浏览器端,有DOM事件,也有定时器,Promise
等。在nodejs,也有process.nextTick
与setImmediate
等。下面会逐步介绍一下
浏览器
先从一个比较简单的例子入手,该例子运行于浏览器端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
setTimeout(() => {
console.log('timeout')
}, 0)
new Promise((resolve) => {
console.log('before resolve')
resolve()
})
.then(() => {
console.log('after resolve')
})
const now = Date.now()
// 避免setTimeout受最小延迟4ms影响
while (Date.now() - now < 10) {}
console.log('end') |
输出结果是什么?
1 2 3 4 |
before resolve end after resolve timeout |
为什么是这样子的输出?这个例子大概是:先执行一个0ms
延迟执行的定时器,紧接定义一个马上resolve
的promise
,然后就是一个10ms
的循环block掉主线程;这个block是为了避免受定时器最小延迟因素影响。
首先输出的值是:before resolve
,这是因为传入Promise
的构造函数会被同步执行,至于为什么,可以查找一下Promise
的实现原理。接下来输出的值是end
,这也是同步的一部分,问题不大。
这里主要的疑惑是在定时器与Promise
,按照最直观的感受是,定时器到时间而且先注册,Promise
后面才注册,应该是先输出timeout
,再输出after resolve
。
对于这个处理,需要引入一个macro task(宏任务)
与micro task(微任务)
的概念;现在知道有这回事,js把异步任务分成两种;而这两种任务的优先级不同;事件循环的时候,可以想象:线程快速轮询,判断是否有这两种任务,有则执行;那么执行的时候,总是优先执行微任务,用代码简单描述:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
let macroTaskQueue = []
let microTaskQueue = []
while (macroTaskQueue.length) {
// 执行宏任务,按照FIFO顺序执行
execute(macroTaskQueue[0])
// 执行完毕检查是否有微任务,也是按照FIFO顺序执行
while (microTaskQueue.length) {
execute(microTaskQueue[0])
microTaskQueue.shift()
}
// 执行完该宏任务,则从队列删除
macroTaskQueue.shift()
} |
宏任务会首先执行,因为主线程开始是属于宏任务;执行完同步任务后,检查是否有微任务,有则一直执行,直到把微任务的队列清空,然后再检查是否有宏任务,有则执行...一直轮询这个过程。
而promise
属于微任务,setTimeout
属于宏任务;回去刚才的例子,当执行完输出end
之后,相当于第一个宏任务已经完成;而这个时候setTimeout
也到了设定时间,需要加入到macroTaskQueue
的队列中;promise
也从pending
变成resolved
状态了,也需要加入到microTaskQueue
队列中;因为microTaskQueue
有任务,所以需要先执行,输出after resolve
之后;microTaskQueue
队列就为空了;然后就检查macroTaskQueue
队列,发现有任务,这个时候就输出timeout
。
浏览器中的微任务通常有:
- promise
- mutation observer 说明链接
宏任务通常除了以上的,基本都是,例如:
- setTimeout
- ajax
- requestAnimationFrame 与 requestIdleCallback (这两个后面会说到)
- dom事件回调等
requestAnimationFrame 与 requestIdleCallback
requestAnimationFrame
简称为rAF
,表示:
告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画
具体的api说明可以看mdn说明。
requestIdleCallback
简称rIC
,表示浏览器渲染一帧的空闲时间进行调用;若执行没有超过给定的时间,则不会影响浏览器关键事件,例如动画和输入响应的。具体的api说明可以看mdn说明。
我们来看一个稍微复杂一点的例子:
1 2 |
<!-- dom 结构-->
<div id="outer"></div> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
document.getElementById('outer').addEventListener('click', () => {
console.log('start')
requestIdleCallback(deadline => {
console.log('rIC', deadline.didTimeout)
})
requestAnimationFrame(() => {
console.log('rAF')
})
setTimeout(() => {
console.log('setTimeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise')
})
console.log('end')
} |
大概的逻辑就是,对#outer
的元素进行一个点击绑定,在点击的回调函数里面有各种异步操作,求输出的顺序。
按照我们之前的微/宏任务的划分,可以大概知道顺序是:
1 2 3 4 |
1. start 2. end 3. promise ?. rIC/rAF/setTimeout |
输出顺序的1,2,3很好理解,主线的宏任务与微任务优先级比较高;但是rAF
, rIC
, setTimeout
的输出是怎样的呢?按照之前我们的定义,这三个都属于宏任务,如果这三个按顺序进入macroTaskQueue
的队列的话,也就是逐个输出;那么这个时候输出是:rIC
=> rAF
=> setTimeout
吗?当多次点击触发的时候会发现,顺序并不固定。
这个时候就需要了解简单的原理:
图片来源
上面这张图会简单描述了,浏览器中刷新每一帧所做的事情;每一帧中,都会按顺序处理js事件,timer,rAF,渲染等。从这里我们可以看到,rAF是在Paint
与Layout
阶段之前处理的;
再看下一张图:
图片来源
在一帧当中,如果动画渲染完毕,就会进入这一帧的Idle
阶段,这个时候就会回调我们的rIC
函数,这个函数接收的参数就可以知道当前帧是否有空闲时间、剩余多少空闲时间。
从上面的图大概可以知道每一帧所处理的主要事情;这个时候我们回到输出代码的例子:
1
|
?. rIC/rAF/setTimeout |
这个顺序的不确定性,与浏览器中每一帧的处理有关系;如果在下一帧开始之前,setTimeout
已经加入到macroTaskQueue
队列中,则这个时候,rAF
与rIC
也已经在这之后进入队列,这种情况所输出的顺序是:setTimeout
=> rAF
=> rIC
;
如果在下一帧开始之前,setTimeout
的回调还没有被加入队列中,但是rAF
与rIC
也已经进队了;这个时候就会先输出rAF
=> rIC
;而setTimeout
则在这一帧完成之后,才被加入到队列,所以会下一帧处理timer
的过程输出值;这种情况最后的输出值为:rAF
=> rIC
=> setTimeout
;
还有一种情况是,这一帧处理已经开始了,setTimeout
的回调还没有加入到队列中,当执行完rAF
之后,setTimeout
才加入到队列,那么这个时候是会执行timer
还是rIC
;根据测试发现(chrome 77.0),这个时候会去执行timer
,再执行rIC
;这种情况最后的输出值为:rAF
=> setTimeout
=> rIC
.
小结:
rAF
与setTimeout
的执行顺序取决于进入macroTaskQueue
的顺序,而加入macroTaskQueue
的顺序可能取决于这一帧的运行情况与setTimeout
的时机;- 在执行
rIC
之前,总是把队列中的任务都执行完,包括microTaskQueue
与macroTaskQueue
dom事件顺序与队列关系
通常情况下,触发dom的回调事件除了在人工触发之外,还可以通过代码触发。例如点击事件,可以在元素上点击与主动触发$element.click()
;我们看下面的例子:
1 2 3 4 |
<!-- dom 结构 -->
<div id="outer">
<div id="inner"></div>
</div> |
1 2 3 4 5 6 7 8 9 |
function click (ev) {
console.log('start', ev.currentTarget.id)
Promise.resolve().then(() => {
console.log('promise', ev.currentTarget.id)
})
console.log('end', ev.currentTarget.id)
}
document.getElementById('outer').addEventListener('click', click)
document.getElementById('inner').addEventListener('click', click) |
当在界面点击#inner
的时候,输出的顺序为:
1 2 3 4 5 6 |
1. start inner 2. end inner 3. promise inner 4. start outer 5. end outer 6. promise outer |
上面输出问题不大,因为冒泡的事件触发也属于宏任务,Promise
的触发属于微任务,队列变化的简单过程为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// 0. 点击inner
macroTaskQueue = ['click(inner)', 'click(outer)']
microTaskQueue = []
// 1. inner的回调函数处理完毕,并出队
macroTaskQueue = ['click(outer)']
microTaskQueue = ['promise(inner)']
// 2. 微任务先执行,并出队
macroTaskQueue = ['click(outer)']
microTaskQueue = []
// 3. 再执行剩下outer,并出队
macroTaskQueue = []
microTaskQueue = ['promise(outer)']
// 4. 执行微任务
macroTaskQueue = []
micoTaskQueue = [] |
如果使用代码主动触发:$inner.click()
,输出的顺序为:
1 2 3 4 5 6 |
1. start inner 2. end inner 3. start outer 4. end outer 5. promise inner 6. promise outer |
这个时候任务队列的初始情况就变成了:
1 2 |
macroTaskQueue = ['click(inner);click(outer);']
microTaskQueue = [] |
冒泡的事件都变成到一个宏任务,具体为什么,就后续再做研究了...浏览器的事件循环就暂时到这里。
小结:
- 通过点击处理的dom回调函数,事件冒泡属于多个宏任务
- 通过主动代码触发的dom回调函数,事件冒泡则单个宏任务
nodejs
nodejs端的事件循环比浏览器会稍微复杂一点点,除了微/宏任务之外,还有不同的事件阶段,下面我们来看这张图
图片来源
nodejs中的事件分为6个阶段:
- timers
- pending callbacks
- idle, prepare
- poll
- check
- close callbacks
每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,等等。
简单的说是,执行到每个阶段,都需要把该阶段的队列回调执行完或者达到最大的执行数量,所以nodejs的队列至少有6个。为什么说说至少呢,因为这个截图中没有把procese.nextTick()
与Promise
相关列出来;这两种情况都会在进行下一阶段执行之前执行,而nextTick
的优先级要比Promise
要高,因此这个图就变成了:
上图没有把所有都用箭头标出来,只是列举了前三个,实际上所有阶段都会有这个检查
每个阶段对应的回调类型
nodejs把“宏任务”分成了6种情况,这6种分别对应:
timer
专门执行定时器相关的回调函数:setTimeout
,setInterval
pending callbacks
: 此阶段通常对系统操作执行回调(例如 TCP错误),例如,如果 TCP 套接字在尝试连接时接收到ECONNREFUSED
,则某些 *nix 的系统希望等待报告错误。这将被排队到这个阶段执行idle, prepare
: 系统相关执行,不了解,忽略poll
: 执行大部分I/O callback
,除了process.nextTick
,microtask
,timer
,pending callbacks
,close event
之外的所有回调都是在这里,例如,读取文件的回调等check
: 专门执行setImmediate
的回调close callback
: 例如网络的socket close
的回调事件在这里处理
我们先看一个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Promise.resolve().then(() => {
console.log('promise')
})
process.nextTick(() => {
console.log('nexttick')
})
setTimeout(() => {
console.log('timeout')
})
setImmediate(() => {
console.log('immediate')
})
console.log('finish') |
- 输出
finish
,这个问题不大,因为刚开始的同步执行 - 输出
nexttick
,在进入timers
阶段之前执行,可以认为是微任务 - 输出
promise
,与nexttick
类似,也属于微任务,但是优先级相对低一点 - 输出
timeout
或immediate
,这个输出不稳定,两个先后顺序不确定
在上面的输出结果可以看出,前3个输出都每啥问题;而对于timeout
与immediate
,为什么会不稳定呢?
是因为js在准备进入检查队列的时候,timeout
的回调函数不确定是否进入了timers
阶段的队列;尽管设置了setTimeout
的时间为0,实际上只至少1ms,因此有可能在1ms之后,js进程已经运行过了timers
阶段,那么这个时候的输出结果就是immediate
优先;若进入timers
阶段已经耗费超过1ms,那么就会出现timeout
的输出。
再看下一个例子:
1 2 3 4 5 6 7 8 9 10 11 |
const fs = require('fs');
fs.readFile('./file.js', () => {
console.log('file callback')
setImmediate(() => {
console.log('immediate')
})
setTiemout(() => {
console.log('timeout')
})
}) |
输出顺序为:
- file callback
- immediate
- timeout
因为fs.readFile
的回调属于poll
阶段,而在这个阶段中,把timeout
与immediate
都加入不同的队列中;当poll
阶段完成之后,进入check
阶段,这个时候刚才添加的immediate
已经进入队列,所以输出是immediate
;当事件循环下次经过timers
的时候,再把timeout
输出。
小结:
- nodejs把“宏任务”分为6个不同阶段,每个阶段都有对应类型的队列
process.nextTick
与Promise
属于微任务,优先级相对较高,其中process.nextTick
对于Promise
优先级更高;在进入每个事件阶段之前,都先执行微任务
参考文章
- https://developers.google.com/web/updates/2015/08/using-requestidlecallback
- https://zhuanlan.zhihu.com/p/64917985
- https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
- https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
- https://developers.google.com/web/fundamentals/performance/rendering/optimize-javascript-execution?hl=zh-cn
- https://developers.google.com/web/fundamentals/performance/rendering
- https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/