1. 微任务与宏任务:process.nextTick、Promise、async/await 在事件循环中的优先级与执行时机
说到 Node.js 的事件循环,很多人第一反应就是「回调地狱」。但真正让新手头疼的,其实是微任务和宏任务的执行顺序。我在项目中见过不少同事,写异步代码时被执行顺序坑得怀疑人生。今天咱们就把这块彻底掰扯清楚。
1.1 事件循环的基本阶段
Node.js 的事件循环,说白了就是一个不停转的轮子。它按照固定的顺序,轮询各个阶段的任务队列。我习惯把事件循环想象成一个流水线,每个阶段就是一个工位。
事件循环的主要阶段包括:
- timers:执行 setTimeout、setInterval 的回调
- pending callbacks:执行延迟到下一轮循环的 I/O 回调
- idle, prepare:内部使用,咱们不用管
- poll:轮询,获取新的 I/O 事件
- check:执行 setImmediate 的回调
- close callbacks:执行关闭事件的回调
每个阶段之间,都会清空微任务队列。嗯,这里要注意,微任务的执行时机比宏任务更「着急」。
1.2 微任务与宏任务的定义
先给个简单的定义:
- 宏任务(MacroTask):setTimeout、setInterval、setImmediate、I/O 回调
- 微任务(MicroTask):process.nextTick、Promise.then/catch/finally、async/await 的后续代码
你可能会问:process.nextTick 和 Promise 谁更快?我直接告诉你答案——process.nextTick 永远比 Promise 先执行。这是 Node.js 设计上的一个「特例」。
核心规则:process.nextTick 的优先级高于 Promise,Promise 的优先级高于宏任务。
1.3 执行顺序的实战验证
光说不练假把式。咱们直接上代码:
console.log('1 - 同步代码');
setTimeout(() => {
console.log('2 - setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('3 - Promise');
});
process.nextTick(() => {
console.log('4 - nextTick');
});
console.log('5 - 同步代码');
猜猜输出顺序?我当初第一次跑这段代码时,也猜错了。实际输出是:
1 - 同步代码
5 - 同步代码
4 - nextTick
3 - Promise
2 - setTimeout
为什么会这样?因为事件循环的执行逻辑是:
- 先执行当前栈的同步代码
- 执行完同步代码后,清空微任务队列(nextTick 优先于 Promise)
- 进入事件循环的下一个阶段,执行宏任务
个人经验:我在项目中调试过一个线上 bug,就是因为 process.nextTick 和 Promise 的执行顺序没搞清楚,导致状态更新时序错乱。后来我养成了一个习惯——能用 Promise 就别用 nextTick,除非你真的需要「插队」执行。
1.4 async/await 的本质
async/await 其实是 Promise 的语法糖。很多人以为 await 会「阻塞」代码,其实它只是让出执行权。
async function test() {
console.log('A');
await Promise.resolve();
console.log('B');
}
test();
console.log('C');
输出顺序是:
A
C
B
await 后面的代码,相当于被放到了 Promise.then 的回调里。所以 B 会在微任务阶段执行,而不是立即执行。
避坑指南:我曾经在 Express 中间件里用 async/await 处理请求,结果因为 await 后面的代码被延迟执行,导致响应已经返回了,日志还没写完。后来我改用同步写法,或者把日志操作放到 finally 块里。
1.5 一个复杂的综合示例
咱们来个稍微复杂点的,看看你能不能完全搞懂:
setTimeout(() => {
console.log('1');
Promise.resolve().then(() => {
console.log('2');
});
process.nextTick(() => {
console.log('3');
});
}, 0);
setTimeout(() => {
console.log('4');
Promise.resolve().then(() => {
console.log('5');
});
process.nextTick(() => {
console.log('6');
});
}, 0);
console.log('7');
输出顺序是:
7
1
3
2
4
6
5
解释一下:
- 先执行同步代码,输出 7
- 进入 timers 阶段,执行第一个 setTimeout,输出 1
- 清空微任务队列:先执行 nextTick 输出 3,再执行 Promise 输出 2
- 进入下一轮 timers 阶段,执行第二个 setTimeout,输出 4
- 再次清空微任务队列:先 nextTick 输出 6,再 Promise 输出 5
关键点:每个宏任务执行完后,都会清空微任务队列。而不是等所有宏任务执行完再清空。
1.6 实际项目中的应用建议
说了这么多理论,咱们聊聊实际怎么用:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 需要立即执行的回调 | process.nextTick | 优先级最高,但慎用 |
| 异步操作的结果处理 | Promise / async/await | 代码可读性好,执行顺序可控 |
| 延迟执行但不需要精确时间 | setImmediate | 比 setTimeout(fn, 0) 更高效 |
| 需要精确延迟 | setTimeout | 时间可控,但注意最小延迟 1ms |
我的习惯:在写 Node.js 服务端代码时,我几乎只用 async/await。只有在需要「在当前 I/O 回调之后、下一个 I/O 回调之前」执行某些操作时,才会考虑 setImmediate。process.nextTick 我基本不用,除非是写底层库或者需要修复某些时序问题。
1.7 总结一下
微任务和宏任务的优先级,说白了就是一句话:同步代码 > nextTick > Promise > 宏任务。但要注意,每个宏任务执行完后都会清空微任务队列,而不是等所有宏任务执行完。
你想想看,事件循环其实没那么神秘。只要记住「先同步、再微任务、再宏任务」这个顺序,大部分场景都能搞定。如果遇到复杂的嵌套场景,别靠脑子猜,直接跑一遍代码看输出,比什么都管用。
下一章咱们聊聊 setTimeout(fn, 0) 和 setImmediate 的区别,这两个东西在面试里经常被问到,实际项目中也有不少坑。到时候我会分享一个我在生产环境踩过的坑,保证让你印象深刻。