梳理一下浏览器与Node中的事件循环,它们的一些特征:
- 浏览器: 不同的实现, browser context
- Node: 多个阶段, process.nextTick()
关于这个问题,实际场景中很少遇到需要深究的地步,不过还是需要了解下,不仅是为了看到下面这种求打印顺序的题不会懵逼:
console.log('script start'); setTimeout(() => { console.log('setTimeout'); }, 0); new Promise((resolve) => { console.log('promise'); resolve() }).then(() => { console.log('promise - then'); }).then(() => { console.log('promise - then - then'); }) console.log('script end');
对比
在比较浏览器与Node的事件循环有何不同时,可以从下表中的几个方面来看:
浏览器
浏览器的事件循环中将任务分为两类:microtask和macrotask(也称为task)
流程
浏览器中的事件循环流程如下
- 读取函数执行栈中的任务,执行
- 读取所有microtask queue中的任务,执行
- 读取一个macrotask queue中的任务,执行
- 循环2,3步,直到无任务
任务队列
不同队列中的任务类型:
- macrotasks: setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI rendering
- microtasks: process.nextTick, Promises, Object.observe, MutationObserver
这个任务类型在HTML规范中并没有明确说明。
队列中的任务是存在优先级的,在不同环境下有不同的优先级。
Promise比较特殊,它是在ECMAScript中定义的而不是在HTML规范中,ECMAScript中的Jobs类似于microtask,有的浏览器将其作为microtask
,有的作为macrotask
。一个普遍的共识是Promise属于microtask
。
其他
- 可以在这个网站中查看浏览器执行代码时的实时事件循环和任务队列情况。
- web worker工作在单独的线程,有自己的event loop,不共用browser context。
Node
在浏览器和Node中,JS都是以单线程在运行,当前执行栈必须执行完(为空)才会进入下个event loop。
Node中使用libuv中默认的事件循环对象uv_default_loop
。
流程
Node(libuv)中每次事件循环的流程如下:
- timer: 执行
setTimeout()
和setInterval()
定时任务的回调 - pending callbacks: 执行上一次循环未执行的回调
- idle,prepare: 内部执行
- poll: 轮询I/O任务
- check: 执行
setImmediate()
回调 - close callbacks: 一些
close
事件的回调,比如socket.on('close', ...)
可以看出Node中event loop分为不同的阶段,每个阶段有自己的任务。
当调用setTimeout()
和setImmediate()
时,会将它们调度的回调函数在下一次事件循环中执行,但nextTick()
不会这么做,它会在本次事件循环结束前被调用。可以想象的到如果递归调用nextTick()
那么延迟任务将没有任何机会去执行。除此之外,Promise和nextTick均属于microtask,会在一次event loop结束前被执行。
若有如下代码:
setImmediate(() => { console.log('immediate'); }); process.nextTick(() => { console.log('nextTick'); }); // nextTick // immediate
结果会总是符合预期,nextTick
总会先打印出来。但如果改成:
setImmediate(() => { console.log('immediate'); }); setTimeout(() => { console.log('timeout'); }, 0); // ? // ?
会发现它们输出顺序会发生变化且不稳定,而在一个I/O循环中调用的话:
const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); }); // immediate // timeout
会看到输出结果总会符合预期,immediate
总会优先打印。
这是因为在Node中定时器执行的顺序跟它们的执行上下文有关。
若在主模块
中调度,则调度时间可能会受到进程性能的约束(受到在机器上运行的其他应用影响),若在I/O循环
中调度,那么setImmediate()
总会在其他定时器的回调前执行。
对于受到性能约束的情况可以举个栗子:虽然setTimeout()的回调是在第一个阶段的timer queue中执行,但它需要访问timer、计算与等待timeout的时间与等待队列中的所有函数执行完成,因此回调的执行可能会比在第四个阶段的setImmediate()回调还要晚。
实例
在浏览器和Node中分别执行如下代码:
setTimeout(() => { console.log('setTimeout - 1') setTimeout(() => { console.log('setTimeout - 1 - 1') }) new Promise(resolve => resolve()).then(() => { console.log('setTimeout - 1 - then') new Promise(resolve => resolve()).then(() => { console.log('setTimeout - 1 - then - then') }) }) }) setTimeout(() => { console.log('setTimeout - 2') setTimeout(() => { console.log('setTimeout - 2 - 1') }) new Promise(resolve => resolve()).then(() => { console.log('setTimeout - 2 - then') new Promise(resolve => resolve()).then(() => { console.log('setTimeout - 2 - then - then') }) }) })
结果如下:
浏览器(Chrome) | Node |
---|---|
setTimeout – 1 | setTimeout – 1 |
setTimeout – 1 – then | setTimeout – 2 |
setTimeout – 1 – then – then | setTimeout – 1 – then |
setTimeout – 2 | setTimeout – 2 – then |
setTimeout – 2 – then | setTimeout – 1 – then – then |
setTimeout – 2 – then – then | setTimeout – 2 – then – then |
setTimeout – 1 – 1 | setTimeout – 1 – 1 |
setTimeout – 2 – 1 | setTimeout – 2 – 1 |
可以明显的看出浏览器和Node在处理延迟函数时的区别。
简要说明浏览器中的过程:
- 遇到两个setTimeout,注册到macrotask queue中,执行第一个任务。(
setTimeout - 1
) - 遇到setTimeout,注册到macrotask queue中。遇到Promise,注册microtask queue中,并且注册它嵌套的microtask,执行microtask queue中的所有任务。(
setTimeout - 1 - then
和setTimeout - 1 - then - then
) - 执行macro queue中的下一个任务(第二个外层的setTimeout),与上一步类似,输出(
setTimeout - 2
,setTimeout - 1 - then
和setTimeout - 1 - then - then
) - 执行macro queue中剩余的两个任务。(
setTimeout - 1 - 1
和setTimeout - 2 - 1
)
现在在看最开头的题,应该一眼就能看出顺序了:
script start promise script end promise - then promise - then - then setTimeout
并且在两种环境中的顺序是一致的。
深入阅读
浏览器
Node
其他