侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 81 篇文章
  • 累计收到 7 条评论

主模块顶层,nextTick 排到 Promise 后面

2026-9-21 / 0 评论 / 6 阅读

主模块顶层,nextTick 排到 Promise 后面

把一段 nextTickPromise.resolve().then 混排的代码从 .cjs 文件挪到 .mjs 文件,打印顺序会变。这类顺序问题在日志打点与初始化钩子里都会踩到,退出清理时也一样:两边都以为自己在对方之前,于是初始化先跑了一半。Node 官方文档对这种差异有明确的说明。

ESM 模块体本身就在微任务队列里

Node 文档 process 页的 When to use queueMicrotask() vs. process.nextTick() 一节原文如下:

The queueMicrotask() API is an alternative to process.nextTick() that instead of using the "next tick queue" defers execution of a function using the same microtask queue used to execute the then, catch, and finally handlers of resolved promises.

Within Node.js, every time the "next tick queue" is drained, the microtask queue is drained immediately after.

So in CJS modules process.nextTick() callbacks are always run before queueMicrotask() ones. However since ESM modules are processed already as part of the microtask queue, there queueMicrotask() callbacks are always executed before process.nextTick() ones since Node.js is already in the process of draining the microtask queue.

这段把机制讲清楚了:next tick 队列排空之后,微任务队列紧接着排空;而 ESM 的模块体本身就是在微任务队列里执行的,所以在模块顶层排一个 nextTick,它只能等到当前这一轮微任务全部结束。文档还给了判断倾向,除非确实需要 process.nextTick 的附加能力(比如递参数),否则优先用 queueMicrotask

离开顶层之后还有没有差别

脚本 tools/verify-microtask-order.mjs 与配套的 .cjs,Node v26.8.1,跑法是把每种排队方式在同一个场景里按顺序调用,然后在最后用一个 40 毫秒的定时器收尾,保证队列全部排空后才打印。箭头左边是先执行的。

场景 顺序
A 主模块顶层,ESM 同步 → queueMicrotask → then → nextTick → setImmediate → setTimeout(0)
B 定时器回调内 同步 → nextTick → queueMicrotask → then → setImmediate → 嵌套 setTimeout(0)
C I/O 回调内(fs.readFile) 同步 → nextTick → queueMicrotask → then → setImmediate → setTimeout(0)
D nextTick 里再排一队 同步 → queueMicrotask → then → nextTick → 再排的 nextTick → 在 nextTick 里排的微任务
E 微任务里排 nextTick then → queueMicrotask → 在 then 里排的 queueMicrotask → 在 then 里排的 nextTick
F async 函数内 同步 → inner 同步段 → 同步 → await 之后 → queueMicrotask → then → nextTick
G 递归五层 nextTick 同步 → 第五次 nextTick → setImmediate → setTimeout(0)
CJS 主模块顶层 同步 → nextTick → queueMicrotask → then → setImmediate → setTimeout(0)

A 与 CJS 那一行是这次排查的关键:同样的调用顺序,.mjs 顶层给出的第一项是 queueMicrotask.cjs 顶层给出的第一项是 nextTick。B 与 C 两个场景里两种模块类型没有差别,nextTick 仍然在前,因为它们已经在回调里,不在模块体的微任务上下文内。也就是说这条差异只出现在主模块顶层,离开顶层就回到文档描述的常规顺序。

D 与 E 两组值得单独看。在顶层排 nextTick 之后,它执行时再排的 nextTick 与微任务都排在这一轮的队尾,而先前排好的微任务先跑完;在 then 里排 nextTick,它要等到整轮微任务清空。F 显示 await 之后的续体也是微任务,排在前面的先跑,与 queueMicrotask 同一条队列。G 里五层递归 nextTick 全部跑完才轮到 setImmediate,递归排 nextTick 不会把自己让位给定时器。

node v26.8.1 / darwin arm64 / 模块类型 ESM
[A 主模块顶层] 同步 1 -> 同步 2 -> queueMicrotask -> then -> nextTick -> setImmediate -> setTimeout(0)
[B 定时器回调内] 定时器内 同步 -> nextTick -> queueMicrotask -> then -> setImmediate -> 嵌套 setTimeout(0)
[C I/O 回调内] readFile 回调 同步 -> nextTick -> queueMicrotask -> then -> setImmediate -> setTimeout(0)
[D nextTick 里嵌套] 同步 -> queueMicrotask#1 -> then#1 -> nextTick#1 -> nextTick#2 在 nextTick 内排 -> queueMicrotask 在 nextTick 内排 -> then 在 nextTick 内排
[E 微任务里排 nextTick] then#1 -> queueMicrotask#1 -> queueMicrotask 在 then 里排 -> nextTick 在 then 里排
[F async/await] 同步 1 -> inner 同步段 -> 同步 2 -> await 之后 -> queueMicrotask -> then -> nextTick
[G 递归 nextTick] 同步 -> 第 5 次 nextTick 结束 -> setImmediate -> setTimeout(0)
node v26.8.1 / 模块类型 CJS
[CJS 主模块顶层] 同步 1 -> 同步 2 -> nextTick -> queueMicrotask -> then -> setImmediate -> setTimeout(0)
[CJS 定时器回调内] 定时器内 同步 -> nextTick -> queueMicrotask -> then -> setImmediate

两个细节留在最后。setImmediatesetTimeout(0) 的先后在 I/O 回调里按文档应当是 setImmediate 先,本次七个场景都给出这个结果,但顶层那个 setTimeout 与 setImmediate 的顺序在别的机器上有过反复,我不敢把它当成可依赖的结论,这一点没有换机器复现。另一个是排查手法上的取舍:这类顺序问题用打印法就够了,--trace-warnings 之类看不到队列信息,真要看清顺序,把每次排队的位置写上标签比上调试器快,代价是改动代码,本机用的就是这个笨办法。

    🤞 分享