在 3.3.1 小节中我们已经梳理了事件循环的六大阶段,但仅仅了解阶段划分还不够。每一个阶段内部以及阶段之间,代码执行的先后顺序还受到宏任务和微任务分类的严格控制。这也是 Node.js 中异步回调顺序最容易让人困惑的地方——表面上同样都是异步操作,为什么 Promise 回调有时会比 setTimeout 更早执行?为什么 process.nextTick 优先级似乎“无上限”?本节将把这些执行规则彻底理清。
宏任务(MacroTask)与微任务(MicroTask)的定义
这两个概念并非 Node.js 独创,浏览器中也存在相同的区分。简单来说:
- 宏任务:由宿主环境(浏览器或 Node.js)发起的较大粒度的异步任务。在 Node.js 事件循环中,每一个阶段的回调执行都可以看作宏任务。例如
setTimeout、setInterval、setImmediate、I/O回调、新连接回调等,都属于宏任务。
- 微任务:由 JavaScript 自身发起的更细粒度、更紧急的异步任务。主要包括
Promise.then/catch/finally、async/await(本质是 Promise)、以及 Node.js 专属的process.nextTick。微任务不会在事件循环的某个单独阶段处理,而是在每一个宏任务执行结束后,下一个宏任务开始前,一次性清空所有已注册的微任务队列。
这也意味着,微任务队列的清理是“插队”进行的,它不遵守事件循环的六个阶段轮转,而是在两个宏任务之间的间隙立刻执行。
Node.js 中的微任务细分:nextTick 队列与 Promise 队列
Node.js 将微任务又细分为两个队列,并且给定了内部优先级:
- nextTick 队列:由
process.nextTick()添加的回调。 - Promise 队列:由
Promise.then()等添加的回调(通常也称为 MicroTask 队列,但在 Node.js 源码实现中两者分开)。
关键规则是:nextTick 队列的优先级高于 Promise 队列。 也就是说,每当一个宏任务执行完毕后,事件循环会先清空 nextTick 队列中的所有回调,然后清空 Promise 队列中的所有回调,之后再进入下一个宏任务。这一规则在 Node.js 文档中明确记载,也是导致 nextTick “优先级极高”的根源。
执行顺序实例拆解
下面的例子可以直观地展示执行顺序:
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
Promise.resolve().then(() => {
console.log('promise');
});
process.nextTick(() => {
console.log('nextTick');
});
console.log('sync');
在 Node.js 中运行这段代码,输出为:
sync
nextTick
promise
timeout
immediate
(注:timeout 和 immediate 的顺序可能会因为事件循环启动时机略有波动,但微任务 nextTick 和 promise 一定在它们之前)
我们逐步分析:
- 脚本作为宏任务整体执行,逐行同步代码
console.log('sync')先输出。 - 执行过程中分别注册了
setTimeout、setImmediate、Promise.then、nextTick,异步回调都被挂起。 - 脚本宏任务执行结束。此时 nextTick 队列中有一个回调,Promise 队列中也有一个回调。
- 事件循环清空微任务:先输出
nextTick,然后输出promise。 - 微任务清空后,事件循环继续,检查定时器阶段。
setTimeout(fn, 0)的定时器可能已到期,输出timeout(如果事件循环启动较快,可能未到期而先进入 Poll 阶段,但本例输出timeout后immediate)。 - 继续循环,最终在处理 Check 阶段时输出
immediate。
如果是这样:
setTimeout(() => {
console.log('timeout');
process.nextTick(() => console.log('timeout.nextTick'));
Promise.resolve().then(() => console.log('timeout.promise'));
}, 0);
在 timeout 宏任务执行时,它内部又注册了微任务。由于微任务必须在当前宏任务结束之后立即清空,因此输出顺序是:
timeout
timeout.nextTick
timeout.promise
每个宏任务执行完毕都会触发微任务检查,而不是等到整个事件循环轮询一圈。这一点在复杂逻辑中至关重要。
process.nextTick 的“饥饿”风险
由于 nextTick 队列拥有最高优先级,并且会在每次清空时一次性全部执行,在 nextTick 中递归调用 process.nextTick 会导致事件循环永远无法进入下一个阶段,从而造成 I/O 和定时器回调无法执行,这种现象称为“饿死事件循环”。
// 危险示例:永远阻塞事件循环
function recurse() {
process.nextTick(recurse);
}
recurse();
// 后续任何 setTimeout、I/O 都不会被执行
setImmediate 则不会导致此问题,因为它在 Check 阶段执行,只会在下一个循环轮次中触发。因此除非明确需要最高优先级的微任务插入,推荐优先使用 setImmediate 或 Promise,避免滥用 nextTick。
与浏览器事件循环的核心差异
浏览器的事件循环中,每个 <script> 标签执行、setTimeout、UI 渲染等为宏任务,Promise、MutationObserver 为微任务。但浏览器有额外的渲染步骤,微任务通常在执行后紧跟着渲染机会。而 Node.js 没有 UI 渲染,因此微任务纯粹为了协调异步代码优先级。
最显著的区别在于 setImmediate 是 Node.js 独有,它被定义为在当前事件循环的 Check 阶段执行,与 setTimeout(fn,0) 的调用时机非常接近,但执行阶段不同。另一个差异是 process.nextTick 的存在,让 Node.js 的微任务调度比浏览器更灵活,也更易踩坑。
常见面试题的逻辑拆解
经典问题:
async function foo() {
console.log('1');
await bar();
console.log('2');
}
async function bar() {
console.log('3');
}
foo();
console.log('4');
输出为 1 3 4 2。因为 await bar() 相当于 Promise.resolve(bar()).then(() => console.log('2')),所以 console.log('2') 作为微任务,在当前宏任务(脚本)结束后清空微任务时执行,先于后续宏任务但晚于同步的 console.log('4')。
另一个经典:
setTimeout(() => console.log(1), 0);
setImmediate(() => console.log(2));
由于 setTimeout(fn,0) 和 setImmediate 的执行顺序在不同 Node.js 版本和运行环境下可能不稳定。当主模块直接运行时,事件循环在定时器阶段可能还未到 1ms 的阈值,导致 setTimeout 回调被跳过而进入 Poll 阶段再执行 setImmediate;但如果在 I/O 回调中调用两者,则 setImmediate 总是在 setTimeout(fn,0) 之前执行(因为 Check 阶段紧跟在 Poll 之后),这属于事件循环内部顺序的细节。
实际开发中的应用策略
- 善用微任务完成“异步收尾”:在某个异步操作后需要更新状态或触发后续逻辑时,使用
Promise.resolve().then()可以将处理延迟到当前宏任务结束后、下一个宏任务之前,避免阻塞或时序错乱。 - 用
setImmediate拆分 CPU 密集循环:当需要在循环中释放事件循环时,在每次迭代中通过setImmediate推迟下一次循环,可以保持对其他请求的响应能力。 - 避免在 nextTick 中执行重逻辑:因为 nextTick 的高优先级和执行时机,回调中的代码会延迟所有 I/O 和定时器。除非确切知道其必要性,否则用
setImmediate更安全。 - 理解 Promise 的微任务特性来优化性能:例如批量状态更新可以借助
Promise微任务将多次更新合并到一次事件循环尾端,减少不必要的中间状态渲染(在 Node.js 服务端中减少不必要的响应写操作)。
总结一张图
宏任务执行
↓
当前宏任务结束
↓
清空 nextTick 队列(直到队列空)
↓
清空 Promise 微任务队列(直到队列空)
↓
是否还有微任务? ——> 是 ——> 继续清空
↓ 否
进入下一个宏任务(事件循环下一阶段或同阶段的下一回调)
这一套执行模型是 Node.js 异步编程的心智模型核心。掌握了微任务与宏任务的执行顺序和优先级,就拥有了预测任意异步代码执行时序的能力,也是编写高性能、无阻塞 Node.js 应用的前提之一。