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

为什么会这样?因为事件循环的执行逻辑是:

  1. 先执行当前栈的同步代码
  2. 执行完同步代码后,清空微任务队列(nextTick 优先于 Promise)
  3. 进入事件循环的下一个阶段,执行宏任务

个人经验:我在项目中调试过一个线上 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 的区别,这两个东西在面试里经常被问到,实际项目中也有不少坑。到时候我会分享一个我在生产环境踩过的坑,保证让你印象深刻。