同样使用 Promise、定时器和事件回调,为什么一段代码从浏览器搬到 Node.js 后,有些执行顺序仍然成立,有些却需要重新推导?

《Node.js 事件循环与异步 I/O:异步结果如何回到 JavaScript?》中,我们沿着异步操作的往返路径,讨论了 libuv 阶段、回调边界和微任务。浏览器也要接收外部结果,也要让 JavaScript 继续执行,同时还需要协调用户输入、文档和页面渲染。这些共同需求与不同职责,构成了两套事件循环模型的相同与不同。

本文不再完整复述 Node.js 的 I/O 分支,而是围绕一个问题展开:哪些顺序来自共同的语言规则,哪些顺序取决于宿主在什么位置调度什么工作? 理解这个问题,比把两张循环图逐格配对更有用。

本文使用 Node.js v24.16.0、libuv v1.52.1。Node.js 示例通过独立 .cjs 文件运行,涉及 ESM 时使用 .mjs;浏览器示例使用独立 HTML 页面。详细验证环境见文末。

阅读提示:较早的《JavaScript 事件循环模型》中,“一次迭代先处理一批所有任务,再处理微任务”、单一全局 FIFO 队列,以及把 Node.js 专属 API 混入浏览器模型的表述需要校准。本文会在相应位置解释正确的机制。

1. 相同的 JavaScript,哪些执行规则可以复用

1.1 语言、引擎与宿主分别决定什么

ECMAScript 定义函数调用、执行上下文、Promise 和 async / await 等语言语义;JavaScript 引擎负责实现这些语义;宿主则把语言执行接到文件、网络、用户输入等外部工作上。

Node.js 使用 V8,但浏览器并不都使用 V8。例如,Chromium 使用 V8,Firefox 使用 SpiderMonkey,Safari 使用 JavaScriptCore。即使 Chromium 和 Node.js 使用同一种引擎,也不能据此认为它们采用同一套事件循环:引擎负责执行 JavaScript,宿主仍要决定何时调用它、何时处理微任务,以及何时等待外部事件。

Promise 是语言内置对象,而 setTimeout()queueMicrotask() 是宿主提供的 API。当 Promise reaction(后续反应)需要执行时,语言通过宿主接口安排相应 job,也就是一项后续执行工作;浏览器把这些 Promise jobs 接入自己的微任务机制,Node.js 则接入 V8 微任务队列。这里既有共同的语言约束,也有宿主的集成规则,不能全部归给语言或全部归给引擎。

共同语言规则与两种宿主的调度职责

图中的上下两种宿主是替代关系,不是一条先经过浏览器、再经过 Node.js 的执行链。两边都需要操作系统和其他线程协作,但这些工作不等于用户 JavaScript 正在并行执行。

1.2 先建立一段可以跨环境推导的顺序

下面这段代码可保存为 common.cjs,运行 node common.cjs;也可放进独立 HTML 的普通 <script> 中,然后重新加载页面:

// common.cjs;浏览器中使用普通 <script>
console.log("sync: start");

setTimeout(() => console.log("timer"), 0);

Promise.resolve().then(() => {
  console.log("promise");
  queueMicrotask(() => console.log("nested microtask"));
});

queueMicrotask(() => console.log("microtask"));

console.log("sync: end");

两种环境都得到:

sync: start
sync: end
promise
microtask
nested microtask
timer

先看同步部分。当前 JavaScript 同步执行遵循 run-to-completion:在这里讨论的普通执行路径中,异步 timer 或 Promise 回调不会打断仍在运行的同步调用栈。因此,登记异步工作之后,当前代码仍然先输出 sync: end。这一约束针对当前 JavaScript 执行线程,不表示整个浏览器或 Node.js 进程只有一个线程;Worker 的边界在第 6 节展开。

再看微任务。这里 Promise.resolve() 已经兑现,调用 .then() 时可以安排对应的 Promise Reaction Job,因此它先于显式的 queueMicrotask() 入队。执行 promise 时追加的微任务排到现有微任务之后,最终得到 promise → microtask → nested microtask

最后,宿主必须在相关检查点处理这些微任务,才会让示例中的 timer 回调执行。timer 的延迟写成 0,并不能让它跳过当前同步代码和微任务检查点。

如果把 Promise.resolve() 换成尚未落定的 Promise,提前调用 .then() 只是在登记后续反应,并不表示相应 job 已经进入微任务队列。需要比较的是实际入队时间,不只是 API 在源码中的出现时间。

1.3 异步和并行仍是两个问题

async 函数会返回 Promise,但函数体中第一段同步计算仍在调用它的线程上执行;await 暂停的是当前异步函数的继续执行,不会自动把后续计算迁移到其他线程。

浏览器可以在网络组件中等待响应,Node.js 可以通过操作系统通知或 Worker Pool(为部分原生异步工作服务的线程池)等不同路径等待 I/O。无论哪条路径,都需要区分两个时点:底层操作已经取得结果,不等于应用层的 JavaScript 已经开始处理结果。

例如,发起异步 I/O 后,当前 JavaScript 执行线程又运行了一段耗时的同步计算。计算期间,操作系统或其他线程仍可能推进已经提交的底层工作,甚至使其完成;但相关的异步回调不会因此打断当前同步调用栈,也不会自动改到完成 I/O 的线程上执行。它必须等待当前 JavaScript 让出控制权,再由宿主按照调度规则安排执行。让出控制权只是让宿主有机会继续处理,并不保证该回调立即运行。

对于返回 Promise 的 API,还需要再区分一步:底层操作完成,并不必然意味着对应的 Promise 已经落定。运行时可能还需要处理完成通知,才能兑现或拒绝 Promise,并安排已登记的 Promise 反应或 await 后续执行。因此,从发起 I/O 到后续 JavaScript 开始执行所经历的时间,不能全部归为底层 I/O 的耗时,其中还可能包含等待完成通知被处理、等待 JavaScript 获得执行机会的时间。

共同规则告诉我们哪些工作不能插队,但还没有解释:当前工作结束后,宿主究竟怎样选出下一项工作?

2. 下一项工作怎样被选中:任务源与事件循环阶段

2.1 浏览器的 task、task source 与 task queue

在 HTML 的事件循环模型中,task(任务)可以理解为交给事件循环调度的一项工作。规范通过一系列处理步骤(steps)描述这项工作要做什么,例如调用定时器回调、派发用户输入事件,或者处理网络活动引起的后续工作。这里的“步骤”就是规范所说的算法描述,不一定是一段开发者编写的 JavaScript 代码。因此,一个 task 不一定对应一个 JavaScript 函数,也不一定从头到尾都在执行 JavaScript。

task source(任务源) 表明任务属于哪类来源,例如 timer、用户交互、网络或渲染。task queue(任务队列) 则是事件循环用来组织、选择这些 task 的集合。一个事件循环可以有一个或多个任务队列;对同一个事件循环,同一任务源关联到特定队列,不同任务源也可以共用队列。

选择下一项工作时,浏览器先选择一个含有可运行任务的队列,再取其中最早的可运行任务执行。不同队列之间保留调度空间,因此“先登记的鼠标事件、网络事件和 timer 一定按全局时间先后执行”不是通用规则。HTML 甚至明确指出,task queue 在规范数据结构上是集合,而不是只能机械弹出队首的 FIFO queue,因为队列中可能存在暂时不可运行的任务。HTML 任务定义与处理模型给出了这些约束。

这不意味着浏览器可以任意打乱一切。相同任务源的排序约束、各 API 的排队规则,以及任务之间的因果依赖仍然有效。准确的边界是:存在局部顺序,但不存在一条可以解释所有来源的全局 FIFO 队列。

“宏任务”是社区常用说法,本文统一使用 task 或任务。它不等于“微任务之外所有东西”的无差别容器,更不能拿它给 Node.js 的每个阶段重新命名。

2.2 Node.js 的 phase 是处理位置,不是一项 task

Node.js 的底层事件循环由 libuv 提供。libuv 是跨平台的异步 I/O 基础库,负责事件循环、I/O 通知以及部分线程池工作,但不负责解释 JavaScript。Node.js 在这个基础上连接原生资源、JavaScript 回调与 V8。

我们经常看到的 phase(阶段),是循环处理某类工作的位置:poll 接收和处理许多 I/O 事件;check 运行 setImmediate() 回调;timers 处理达到时间条件的 timer;pending callbacks 处理一些延后到下一轮的 I/O 回调;close callbacks 处理部分关闭回调。idle、prepare 主要供内部使用。

这些阶段描述的是循环的组织方式,而不是“一个阶段只执行一个函数”。同一阶段可能处理多个回调;某个回调返回 Node.js 运行时边界后,还可能先运行 next tick 与微任务,再回到当前阶段处理后续回调。阶段也不都是简单、完全相同的 FIFO 数组,timer 还涉及到期时间组织。

本文采用的现代 Node.js / libuv 版本在常规迭代中于 poll 之后处理 timers;UV_RUN_DEFAULT 为兼容行为保留的入口 timer 检查,不是每轮都在 poll 前再执行一次 timers。完整阶段及版本边界已在上一篇第 4 节讨论,比较时只需要保留“阶段中可以有多个回调”这个关键粒度。

2.3 两套事件循环的整体调度路径

把任务与阶段放回各自的循环,才能看清下一项工作怎样被选中、当前工作结束后又回到哪里。下面分别沿两侧的箭头阅读:左侧是浏览器的窗口事件循环,右侧是本文版本范围内 Node.js 的常规迭代,两侧方框的横向位置不表示一一对应。

浏览器与 Node.js 事件循环的整体调度路径对比

浏览器这一侧,输入、网络和 timer 等来源按各自规则安排 task;渲染机会也有独立的检测路径,通过渲染任务源安排更新工作,而不是由“微任务清空”直接触发绘制。事件循环选择可运行的 task,执行它的处理步骤及结束后的微任务检查点,再继续调度。没有可运行 task 时,还会涉及空闲期等处理与等待,队列暂空不表示页面结束。这里先保留整体关系,渲染的具体条件在第 5 节展开,Worker 的循环边界在第 6 节讨论。

Node.js 这一侧,阶段箭头表示底层循环推进,不表示上一个 JavaScript 回调结束后就直接跳到下一阶段。右侧用虚线连接的说明框放大了普通回调返回运行时后的队列处理,它不属于新阶段,也不限于 check。图中省略了入口 timer 兼容检查,以及 poll 之后额外处理 pending 回调等底层步骤;这些细节可对照 libuv v1.52.1 的 Unix 循环实现。循环不再满足存活条件时会返回 Node.js 运行时;只有没有新增工作等条件成立,进程才可能自然退出,不能把“循环返回”直接等同于“立即退出进程”。

再把整体图中的一小段放大,就能看清两侧处理工作的粒度:浏览器选择一项 task,而 Node.js 的同一个阶段可以依次处理多个回调。

浏览器选择任务与 Node.js 阶段处理回调的粒度对比

图中只放大一段局部路径。浏览器的一个 task 不能直接等同于 Node.js 的 poll 或 check;Node.js 的一个 callback 也不必然对应浏览器的一个完整 task。二者更适合按“外部工作如何组织”“何时进入 JavaScript”“返回后处理什么”这些问题比较。

2.4 循环图没有画出的地方,可能决定最终顺序

如果阶段图显示 poll → check,并不能推出 poll 中的一个 readFile 回调一返回,就立刻执行 setImmediate()。中间还要考虑当前回调产生的后续队列,以及 poll 是否还有其他要处理的回调。

类似地,浏览器任务图中相邻的两个方框,也不能理解成“两个任务之间除了切换,没有其他工作”。循环图描述宏观调度,检查点描述执行边界;两个层次必须同时成立。

3. 微任务什么时候执行:从任务结束到回调边界

3.1 “任务结束后处理微任务”是入口,不是完整规则

微任务检查点(microtask checkpoint) 是宿主按规则处理当前微任务队列的过程。它不是一个独立的浏览器 task,也不是 Node.js 的新阶段。

浏览器事件循环在处理完所选 task 后会执行微任务检查点,但规范还在其他位置要求检查。例如,HTML 的“运行脚本后的清理”会在移除相关执行上下文后检查栈是否为空;如果为空,就执行微任务检查点。Web IDL 对宿主调用用户回调的定义会衔接这些脚本运行与清理步骤,因此 task 内部也可能出现检查点。HTML 脚本清理规则Web IDL 回调调用规则需要结合起来读。

这与普通函数返回不同。假设 outer() 同步调用 inner()inner() 安排了一个微任务。inner() 返回后,调用栈上仍有 outer(),代码会继续执行 outer() 剩余的同步语句,而不是立刻插入微任务。不能把“函数结束”“栈变空”和“宿主要求执行检查点”混成同一件事。

Node.js 则在它调度的 JavaScript 操作返回相应运行时边界后,处理 next tick queue 与 V8 微任务队列。这里说的回调边界检查点是帮助理解 Node.js 行为的描述,不是 HTML 的术语,也不是“每个 JavaScript 函数调用都有一份检查点”。

3.2 同一个阶段中的两个回调,不必连续执行

在浏览器中,可以用两个 timer 观察任务之间的检查点;在 Node.js 中,则用两个 setImmediate() 观察同一个 check 阶段中的回调边界。下面代码保存为 boundaries.cjs,或放在独立页面的普通 <script> 中:

// boundaries.cjs;浏览器中使用普通 <script>
const schedule = typeof window === "undefined"
  ? setImmediate
  : (callback) => setTimeout(callback, 0);

schedule(() => {
  console.log("A");
  queueMicrotask(() => console.log("microtask from A"));
});

schedule(() => console.log("B"));

本例两种环境都输出:

A
microtask from A
B

相同输出背后的粒度却不同。浏览器的两个 timer 回调来自各自的 timer task;Node.js 的两个 immediate 回调在本例中由同一次 check 处理,它们之间也会处理微任务。因而,Node.js 并不是“把整个 check 阶段全部执行完,再统一清空微任务”。这里的环境判断只是让两个独立实验共用一段代码,不是推荐用 window 检测实现通用运行时识别。

把最后两次 schedule(...) 换成普通函数直接调用,则得到 A → B → microtask from A。改变的是调度边界,而不是微任务被赋予了另一种优先级。

3.3 两个事件监听器之间,会不会运行微任务

“都是事件监听器”仍不足以回答这个问题。把下面的完整页面保存为 listeners.html,加载后先直接点击“真实点击”,再点击“同步派发”,观察页面输出:

<!doctype html>
<meta charset="utf-8">
<title>事件派发与微任务边界</title>
<button id="target">真实点击</button>
<button id="dispatch">同步派发</button>
<pre id="output"></pre>
<script>
  const target = document.querySelector("#target");
  const dispatch = document.querySelector("#dispatch");
  const output = document.querySelector("#output");
  const log = (text) => { output.textContent += text + "\n"; };

  target.addEventListener("click", () => {
    log("listener A");
    queueMicrotask(() => log("microtask from A"));
  });
  target.addEventListener("click", () => log("listener B"));

  dispatch.addEventListener("click", () => {
    log("dispatch: start");
    target.dispatchEvent(new Event("click"));
    log("dispatch: end");
  });
</script>

直接点击第一个按钮时,输出:

listener A
microtask from A
listener B

这里用户输入引起事件派发,浏览器依次调用监听器。在这个没有额外外层用户脚本的例子中,A 返回后的脚本清理使 JavaScript 执行上下文栈为空,于是微任务检查点可以发生在 B 之前。A、B 属于同一次事件派发,并不因此成为两个独立 task。

点击第二个按钮时,新增的输出是:

dispatch: start
listener A
listener B
dispatch: end
microtask from A

dispatchEvent() 在当前同步调用链上派发事件。A 返回时,外层“同步派发”监听器仍在执行栈上,因此微任务不能插进 A、B 之间;必须先完成事件派发,再输出 dispatch: end,等外层脚本退出相关边界后处理微任务。这个区别来自 DOM 的监听器调用流程如何接入脚本清理规则,不是因为人为派发的事件具有另一套微任务优先级。通过脚本调用 target.click() 也不能代替这里的真实输入实验。

Node.js 的 EventEmitter 则是按事件名登记、同步调用监听器的机制。下面是一个独立的 emitter.cjs

// emitter.cjs
const { EventEmitter } = require("node:events");
const emitter = new EventEmitter();

emitter.on("ready", () => {
  console.log("listener A");
  queueMicrotask(() => console.log("microtask from A"));
});
emitter.on("ready", () => console.log("listener B"));

emitter.emit("ready");
console.log("emit: end");

输出为 listener A → listener B → emit: end → microtask from Aemit() 本身不会为每个监听器另排一项异步工作。即使它是在 I/O 回调中被调用,监听器仍然属于这条同步调用链;EventEmitter 的同步调用契约不因外围异步来源而改变。

3.4 检查点处理共享队列,不按回调分组

微任务可以来自当前回调、同步调用的库函数、事件监听器,或这些代码使之落定的 Promise。只要它们进入当前事件循环使用的微任务队列,检查点就按队列规则处理,不会先筛选“是否属于刚刚返回的那个回调”。

浏览器的微任务队列关联到事件循环,而不是给每个 DOM 元素、事件监听器或 Promise 单独分配一条。Node.js 中也应以当前 JavaScript 运行环境理解 next tick 与微任务队列,而不是每个请求各有一组调度队列。不同 Worker 的环境则独立,不能把“共享”扩大成整个进程、全部页面共用一条队列。

浏览器还会用微任务通知 MutationObserver,也就是观察 DOM 变化的接口。每个 observer 可以保存自己的变化记录,但通知过程通过微任务安排;“对象有自己的记录队列”不等于“对象拥有私有微任务队列”。它观察的是 DOM 变化,不是像素已经呈现,这一点在第 5 节讨论渲染时同样适用。DOM 的通知算法区分了这两种队列。

第一节中由 Promise 回调追加的 nested microtask 还说明:检查点不是只处理进入时的固定快照。执行微任务时新加入的微任务会继续被处理,直到队列清空。正在执行检查点时,回调清理不会无限递归开启另一层检查点;新增工作由当前处理过程继续消费。

因此,一个库即使每个微任务都很短,只要不断追加微任务,也可能拖延同一执行环境中其他代码等待的 timer、输入或 I/O。用请求标识追踪来源有助于诊断,但这些因果信息不会自动变成公平调度或队列隔离。

3.5 Node.js 多出的 next tick,为什么不能写成全局优先级

process.nextTick() 将回调放入 Node.js 专有的 next tick queue。它不属于 HTML 的微任务清单,不是 libuv 阶段,也不是“下一轮才运行”的意思。

在普通 Node.js 回调返回运行时边界时,可以先按“处理 next tick,再处理 V8 微任务”推导。但如果微任务运行期间又加入 next tick,Node.js 不会立刻打断当前微任务检查点。运行下面的 nested-queues.cjs

// nested-queues.cjs
setImmediate(() => {
  console.log("callback");

  process.nextTick(() => {
    console.log("tick A");
    queueMicrotask(() => console.log("microtask from tick"));
  });

  queueMicrotask(() => {
    console.log("microtask A");
    process.nextTick(() => console.log("tick from microtask"));
    queueMicrotask(() => console.log("microtask B"));
  });
});

输出为:

callback
tick A
microtask A
microtask from tick
microtask B
tick from microtask

回调返回时,next tick queue 中有 tick A,微任务队列中已有 microtask A。先执行 tick A,它追加的 microtask from tick 排在 microtask A 后面。随后开始微任务检查点:microtask A 虽然登记了一个 next tick,但其余微任务仍继续执行。当前微任务队列清空后,Node.js 才回查 next tick queue,执行 tick from microtask

宿主回调、同步嵌套与 Node.js 队列回查的边界时间线

图中的 Node.js 路径讨论普通回调返回后的队列处理,不包含拒绝通知等其他运行时细节。关键是:清空队列期间可能追加工作,Node.js 需要处理完相关队列才能继续阶段流程;不能把它解释成每执行一个微任务,就重新按“next tick 最高”抢占一次。

3.6 CJS 与 ESM 的差异发生在什么上下文

把下面代码分别保存为 top-level.cjstop-level.mjs,单独运行:

// 分别保存为 top-level.cjs 与 top-level.mjs
console.log("sync");
process.nextTick(() => console.log("tick"));
Promise.resolve().then(() => console.log("promise"));
queueMicrotask(() => console.log("microtask"));
运行入口 输出
node top-level.cjs sync → tick → promise → microtask
node top-level.mjs sync → promise → microtask → tick

这里 Node.js 的 ESM 顶层求值已经处于微任务相关的执行上下文中,新增微任务会在返回处理 next tick 的边界之前运行。它不是 ESM 全面改写了 next tick 的优先级。Node.js 的说明也特别区分了这个场景。

只改变一个条件:把这四条语句一起移进 setImmediate(() => { /* 四条语句 */ })。两种模块格式此时都输出 sync → tick → promise → microtask,因为现在比较的是 immediate 回调边界,不再是模块顶层求值。

因此,判断执行顺序时,要同时说明宿主、版本、入口格式和当前位置。浏览器没有原生 process.nextTick(),不能把这张 CJS / ESM 对照表继续推广为浏览器普通脚本与模块脚本的对照表。

理解了检查点,才有足够的粒度比较那些名字相同或功能相似的宿主 API。

4. 同名定时器与异步 API,为什么不能直接互换推理

4.1 setTimeout(0) 在两边都不表示立即执行

timer 的延迟表达时间条件,不是 JavaScript 回调的精确预约。等时间条件满足后,回调还需要等待宿主安排执行;当前同步代码、检查点队列或其他工作都可能继续推迟它。

不过,两边对延迟参数的处理不完全相同。HTML 的 timer 初始化算法规定:继承的 timer nesting level 大于 5,且请求延迟小于 4 毫秒时,将延迟设为 4 毫秒。这个嵌套层级来自 timer 初始化链路,也适用于 setInterval(),不是 JavaScript 函数调用栈深度,更不是“页面里登记第六个 timer 就自动变成 4 毫秒”。浏览器还可能因页面可见性、节能与实现策略延后后台 timer,不能把某款浏览器的后台阈值写成所有环境的常量。HTML timers区分了时间条件与调度保证。

Node.js 24 的 setTimeout() 则把小于 1 毫秒、超过 2147483647 毫秒或为 NaN 的延迟设为 1 毫秒,小数部分会截断。这是 Node.js API 的参数规则,不是 HTML 的嵌套限制。它也不保证精确触发时间。Node.js timers 文档应与浏览器规则分别阅读。

还有一个可观察差异:浏览器 timer 返回数字标识,Node.js 返回 Timeout 对象。取消时分别交回各自宿主,不能把一个环境中的 timer 标识拿到另一个环境继续使用。Node.js 的对象还提供 ref()unref() 等生命周期能力,第 6 节会说明它们改变的不是回调优先级。

4.2 与另一种调度来源比较时,只推导有依据的局部顺序

浏览器可以用 MessageChannel 安排消息通知。它提供相连的两个端口,向一个端口发送消息,会在另一个端口触发消息处理。下面代码放进独立页面的普通 <script> 中:

// 浏览器:message-vs-timer.html 的普通 <script>
const channel = new MessageChannel();

channel.port1.onmessage = () => {
  console.log("message");
  channel.port1.close();
  channel.port2.close();
};

setTimeout(() => console.log("timer"), 0);
channel.port2.postMessage("ready");
queueMicrotask(() => console.log("microtask"));

本例中,宿主会先处理当前检查点中的微任务,所以 microtask 先于另外两个回调。messagetimer 来自不同任务源,它们的相对顺序取决于各自的准备情况与宿主调度;重复实测中的稳定顺序,也不构成跨环境保证。

MessageChannel 和 Node.js 的 setImmediate() 都能安排稍后执行的工作,但各自的调度来源、条件和生命周期不同。比较它们时,需要回到各自的宿主模型。

Node.js 中,对应的经典比较是 setTimeout(0)setImmediate()。在普通进程顶层一起登记时,不应承诺固定顺序;若在 poll 阶段的 I/O 回调里登记,则可以根据阶段关系得到局部顺序:

// io-order.cjs
const { readFile } = require("node:fs");

readFile(__filename, (error) => {
  if (error) throw error;
  console.log("I/O");

  setTimeout(() => console.log("timer"), 0);
  setImmediate(() => console.log("immediate"));
  queueMicrotask(() => console.log("microtask"));
});
I/O
microtask
immediate
timer

readFile 回调不是微任务。它运行期间安排了一个微任务,返回 Node.js 边界后先处理微任务,再回到 poll 的处理流程;poll 完成后,check 中的 immediate 先于本例新建的 timer。期间可能还有未列出的 I/O 回调,以上只约束这四条日志。Node.js 的事件循环说明为这个上下文提供了依据。

如果需要稳定的 timer → immediate,可以让 timer 回调负责登记 immediate。这样的顺序来自显式因果依赖,不依赖它们谁“通常更快”。浏览器跨来源的先后要求也应优先通过明确的调用、Promise 链或协议确认表达,而不是用一个猜测的延迟数值充当依赖。

4.3 返回 Promise,不表示底层操作变成微任务

浏览器和 Node.js 都有 fetch(),但相似的 API 表面不意味着底层使用相同线程、相同 I/O 组件或相同队列。网络操作需要先在宿主中进行;当对应 Promise 可以落定时,它的后续反应才通过微任务继续执行。

例如,浏览器 fetch() 的后续 .then() 是 Promise reaction,网络收包本身却不是微任务。Node.js 的 Promise 风格文件读取同样不是把读盘动作放进 V8 微任务队列。await 只描述应用怎样接续结果,不决定磁盘或网络怎样完成工作。

这里还要修正一个 API 范围误区:setImmediate() 不是标准浏览器调度 API;process.nextTick() 也不是 HTML 微任务类型。把它们与浏览器的 timer、消息事件放在一张未标注宿主的“任务清单”中,会让后面的顺序推导失去成立条件。

5. 浏览器还要安排什么:渲染机会与动画回调

5.1 DOM 已修改,为什么用户还看不到

执行 element.textContent = "处理中" 时,DOM 对象的状态已经改变,后续 JavaScript 可以读到新值。但 DOM 状态改变、浏览器更新渲染,以及新画面最终呈现到屏幕,是不同层次的事情。

浏览器需要在合适时机协调样式、布局、动画、绘制与合成等工作。具体流水线可以跨多个线程,不能简化成“JavaScript 每结束一次,就同步把新像素画出来”。主线程上的长计算会阻塞依赖它的更新;某些已有的合成器动画或滚动仍可能继续,也不代表主线程保持响应。

rendering opportunity(渲染机会) 表示宿主根据显示刷新、页面状态等条件判断适合更新渲染的机会。机会与事件循环迭代不是一一对应:一段时间内可以执行多个 task 而不更新一次可见画面,某次机会也可能因文档不可渲染或没有必要更新而被跳过。

这里需要把旧式循环图再校准一步。当前 HTML 标准会在渲染机会相关的并行步骤中,通过 rendering task source(渲染任务源) 排入更新渲染的任务。因此,“渲染绝不属于 task”过于绝对;但把 UI render 当成每轮必执行的普通队尾回调,同样不准确。应同时保留它的任务调度形式,以及文档筛选、渲染机会与跳过更新等专门规则。HTML 更新渲染流程明确描述了这两层约束。

DOM 修改、渲染机会、动画回调与画面呈现的关系

图中的时间线是一次可能发生的更新,不是每个 task 结束后的必经流水线。尤其不要把图末的“呈现”当成 JavaScript 可以凭一条日志确认的事件。

5.2 requestAnimationFrame() 不是“绘制完成后”回调

requestAnimationFrame(),通常简称 rAF,用于登记在后续适当的更新渲染过程中运行的动画帧回调。它适合在这里计算和写入动画状态,却不保证“刚刚已经完成一次画面呈现”。rAF 回调里执行长时间计算,仍然会推迟后续依赖主线程的渲染工作。

同一批动画帧回调是由浏览器的动画帧算法组织调用的,不是把每个 rAF 都当成独立 timer task。它们调用用户 JavaScript 时也要遵守回调与脚本清理规则,不能假设多个 rAF 回调之间绝无微任务。回调中再次登记 rAF,会留给后续的动画帧回调批次,而不是追加进当前已经取出的那一批。动画帧回调算法规定了这层批次边界。

所以,await new Promise(requestAnimationFrame) 只表明相应 rAF 已经使 Promise 兑现。await 后的继续执行仍是微任务,可能在本次更新渲染的后续步骤之前运行,不能把它当作“用户一定看到了上一帧”的屏障。连续等待两次 rAF 也不应被包装成无条件的像素呈现确认。

Node.js 没有页面渲染生命周期,不能把 check 阶段视为 rAF 对应阶段。两者都能安排稍后运行代码,但一个服务于 Node.js 循环的回调组织,一个服务于浏览器的更新渲染过程。

5.3 await Promise.resolve() 为什么不能解决页面卡顿

下面的完整页面让两种做法执行相同的有限工作:8 个约 30 毫秒的计算片段。保存为 render-yield.html,在前台页面分别点击两个按钮;每次完成后再点击另一个按钮:

<!doctype html>
<meta charset="utf-8">
<title>让出函数与让出任务</title>
<button id="micro">只等待微任务</button>
<button id="task">在任务间分片</button>
<p id="status">等待开始</p>
<pre id="output"></pre>
<script>
  const status = document.querySelector("#status");
  const output = document.querySelector("#output");
  const buttons = [...document.querySelectorAll("button")];

  function workFor(ms) {
    const end = performance.now() + ms;
    while (performance.now() < end) { /* 有限的同步计算 */ }
  }

  async function run(mode) {
    buttons.forEach((button) => { button.disabled = true; });
    output.textContent = "";
    status.textContent = "处理中";
    const startedAt = performance.now();
    const log = (text) => {
      const elapsed = Math.round(performance.now() - startedAt);
      output.textContent += `${text}: ${elapsed}ms\n`;
    };

    setTimeout(() => log(`timer 读到 ${status.textContent}`), 0);
    requestAnimationFrame(() => log(`rAF 读到 ${status.textContent}`));

    for (let i = 0; i < 8; i += 1) {
      workFor(30);
      status.textContent = `处理中 ${i + 1}/8`;
      if (mode === "micro") {
        await Promise.resolve();
      } else {
        await new Promise((resolve) => setTimeout(resolve, 0));
      }
    }

    status.textContent = "完成";
    log("work done");
    buttons.forEach((button) => { button.disabled = false; });
  }

  document.querySelector("#micro").onclick = () => run("micro");
  document.querySelector("#task").onclick = () => run("task");
</script>

先预测“只等待微任务”的情况。第一次 await 确实让 run() 暂停并让同步调用链退出,但继续执行已经进入微任务队列。后续每个片段又通过 await Promise.resolve() 追加下一次继续执行,于是工作一直续在当前检查点中。

因此,示例中的 timer 与 rAF 都不能插进这串微任务计算:work done 在它们之前输出,它们运行时读到的是“完成”。约 240 毫秒只是实验设置的计算总量,实际耗时还包含调度与系统负载;timer 与 rAF 谁先输出不作保证。

“在任务间分片”则不同。每次继续执行都必须先等一个未来 timer 回调,使运行时有机会离开当前检查点,处理其他 task。开始时安排的观察 timer 可以在整个计算结束前执行;前台页面的 rAF 和中间状态也可能在分片间得到处理。但某一次分片之间是否真的呈现了新画面,仍由渲染机会与浏览器决定,不能据此承诺“每个片段渲染一帧”。

这个实验刻意使用 30 毫秒片段放大现象,并不是推荐的性能预算。实际分片还要考虑目标刷新率、输入响应、单片工作量和设备能力。分片归还了调度机会,没有把 CPU 计算变成并行;对较重的纯计算,还需要评估 Worker。

页面日志证明的是回调执行顺序和它们读取到的 DOM 状态,不是屏幕呈现时间。若要判断用户看到的过程,应结合 Performance 录制中的主线程工作、渲染记录、帧与截图,而不是只看控制台有没有输出。

6. 一个循环属于谁,又为什么继续运行

6.1 页面、Worker 与线程,不是同一个边界

浏览器不是一个只有一条事件循环的大盒子。规范通过 agent(执行代理) 描述可以在同一执行线程上同步协作的 JavaScript 执行单元,并为其关联事件循环。一个 realm 表示一套全局环境及内置对象,它也不等于一条独立线程;多个可以同步协作的窗口环境可能共享同一个 agent 与事件循环,不能直接按标签页或 iframe 数量计算循环数量。

Web Worker 使用独立的执行环境和事件循环,能够与页面上的 JavaScript 并行运行,通过消息等机制通信。页面的检查点不会替 Worker 清空微任务,页面也不能直接调用 Worker 内部的普通函数。常规 Worker 没有页面 DOM;支持相应能力的 Dedicated Worker 可以使用 OffscreenCanvas 和 rAF 等机制,因此“所有 Worker 都与任何渲染能力无关”也过于绝对。

Node.js 的 Worker Threads 同样可以并行执行 JavaScript,每个 Worker 拥有独立的 JavaScript 环境与事件循环。它不是 libuv Worker Pool:后者为部分原生异步工作提供线程池,不是自动接收我们任意 JavaScript 函数的执行器。Node.js Worker Threads 文档强调了它在 CPU 密集型 JavaScript 工作中的用途。

两边把计算搬到 Worker 都有成本:启动和调度、消息传输、结构化克隆或转移数据,以及结果回到接收方后仍要排队处理。主线程长期阻塞时,Worker 即使已完成计算,主线程也不一定立即执行结果回调。共享内存又会引入额外的同步约束,不属于本文的单执行代理顺序实验。

6.2 队列暂时为空,不等于运行环境应该结束

网页暂时没有 task 可运行时,浏览器仍要等待后续输入、网络活动和渲染机会。文档是否继续存在、何时被冻结或丢弃,是浏览器生命周期问题,不是“清空 Promise 队列就关闭页面”。Worker 也有自己的终止与关闭规则,不能统一按页面或 Node.js 进程处理。

Node.js 的命令行程序则需要回答“还有没有让进程继续运行的工作”。活动且被引用的资源、尚未完成的请求等会参与存活判断。一个处于 pending 状态的普通 Promise 对象,本身并不是让进程等待的活动 I/O 资源:

// pending.cjs
new Promise(() => {
  // 不登记 timer、I/O 或任何其他后续工作
}).then(() => console.log("不会执行"));

console.log("script end");

独立运行时,输出 script end 后进程就会自然结束。这里既没有工作将 Promise 兑现,也没有应继续等待的活动资源。不要把这个普通 CJS 示例推广到所有模块生命周期:未完成的顶层 await 还有专门的退出处理规则。

timer 默认会保持 Node.js 事件循环活动,而 timer.unref() 只是表示“不要仅因为这个 timer 保持进程存活”,并不取消它,也不降低它的调度优先级。如果其他资源让进程继续运行足够久,它仍可触发;若没有其他维持存活的工作,进程可以先退出。这与浏览器后台 timer 的节流是两个问题:前者改变进程存活条件,后者改变某些工作的调度时机。

因此,“程序不退出”和“回调执行得慢”应沿不同方向检查:前者先找未释放的资源,后者先定位等待或占用发生在哪里。

7. 用比较模型解释卡顿、延迟与跨环境代码

7.1 同一种调度问题,为什么表现成不同故障

浏览器与 Node.js 都可能因长时间同步计算或持续追加微任务而失去及时处理其他工作的机会。浏览器中常表现为点击无响应、画面迟迟不更新;Node.js 中则可能是 timer、I/O 回调和多个请求一起延迟。这是共同的调度压力作用在不同宿主职责上的结果。

但“页面卡顿”不一定全是 JavaScript,样式与布局等渲染工作也可能占据主线程;“服务响应慢”也不一定是事件循环,磁盘、线程池、连接池或下游系统都可能在等待。事件循环模型用于区分候选路径,不是把所有延迟都归给一个名字。

要区分的问题 浏览器中的证据 Node.js 中的证据
同步计算占用了执行线程吗 Performance 主线程记录、长函数与输入处理延迟 CPU profile、event loop delay、同步函数耗时
微任务不断补充,检查点无法结束吗 Promise / 微任务密集连续,timer 与 rAF 延后 next tick / 微任务重复调度,timer 与 I/O 同时延后
JavaScript 可调度,但外部工作很慢吗 网络时间线与回调开始时间分开观察 I/O 路径、Worker Pool、连接池和下游分阶段耗时
状态已更新,但画面还没呈现吗 DOM、渲染记录、帧与截图交叉验证 无对应页面渲染生命周期,不能寻找“服务端 rAF”
工作似乎结束,环境却仍存活吗 先判断文档 / Worker 生命周期,不能按队列空判断结束 活动资源、未关闭的 server / timer / watcher

CPU profile 用采样定位计算热点,event loop delay 反映循环推进的延迟;两者都不是单独的根因证明。浏览器可以从 Chrome DevTools Performance开始录制;Node.js 的指标示例、线程池实验和存活诊断已放在上一篇第 8 节,这里把它们放回跨宿主的问题分类,而不重复整套工具代码。

7.2 把浏览器中的“让出”实验搬到 Node.js

Node.js 不需要刷新页面,但同样需要让其他回调得到机会。保存下面的 yield.cjs,分别运行 node yield.cjs micronode yield.cjs immediate

// yield.cjs
const { setImmediate: yieldToLoop } = require("node:timers/promises");
const mode = process.argv[2] ?? "micro";
const startedAt = performance.now();

const log = (text) => {
  console.log(text, Math.round(performance.now() - startedAt), "ms");
};

function workFor(ms) {
  const end = performance.now() + ms;
  while (performance.now() < end) { /* 有限的同步计算 */ }
}

setTimeout(() => log("timer"), 0);

(async () => {
  for (let i = 0; i < 8; i += 1) {
    workFor(30);
    if (mode === "immediate") {
      await yieldToLoop();
    } else {
      await Promise.resolve();
    }
  }
  log("work done");
})();

micro 模式中,work done 先于 timer;immediate 模式中,timer 能在整个分片计算完成前得到执行。两种模式的总耗时不必相同,也不应固定期待 timer 在第几个片段之间触发。

两边实验共同说明:暂停当前异步函数,不等于让事件循环继续处理其他来源。 关键在于等待的 Promise 怎样兑现:已经兑现的 Promise 把继续执行接回微任务;等待未来 timer 或 immediate 则需要宿主先走到相应调度位置。

对生产代码,先测量再决定:适量分片可以改善响应,但增加调度次数;把 CPU 工作移入 Worker 可以并行计算,但有传输和管理成本;换成原生异步 I/O 可以避免同步等待,却不能消除下游延迟。只在重计算前加上 async,不解决这些问题。

7.3 跨环境代码应该依赖什么契约

如果只是希望回调不要同步发生,且要在相关检查点运行,可以考虑两边都有的 queueMicrotask()。如果已经在表达一个异步结果的成功或失败,Promise 通常更合适。但 queueMicrotask() 中抛出的异常与 Promise reaction 抛出后形成的拒绝,并不是同一条错误传播路径,不能为追求相似顺序随意互换。

如果目标是让出调度机会,就应把这个意图作为单独的宿主能力处理:浏览器可用任务分片策略,Node.js 可等待 immediate;不要把它命名成“跨平台完全相同的下一轮”,再承诺相对于输入、网络和渲染的一致顺序。大多数新代码也不需要为了“更快”采用 Node.js 已标记为 Legacy 的 process.nextTick()

小结:从共同语义到宿主调度

遇到新的顺序问题,可以沿四个问题推导:

  1. 当前代码在哪个宿主、哪个执行环境和什么上下文中?
  2. 每项工作是同步调用、微任务、next tick,还是未来由宿主调度的工作?
  3. 控制权返回时是否到达检查点,队列处理中是否又追加了工作?
  4. 剩下的先后来自 API 契约和因果依赖,还是仅来自一次观察?

至此,开头的问题就有了答案。相同的 JavaScript 提供了同步执行和 Promise 接续的共同基础;不同的宿主决定外部工作如何排队、检查点在哪里出现,以及渲染或进程生命周期如何推进。可复用的是明确的语义与因果关系,需要重新判断的是宿主调度边界。理解两套模型,不是把它们合成一张万能优先级表,而是知道每个结论为什么成立、又从哪里开始不再适用。

实验与参考资料

本文示例不访问外部服务。Node.js 示例在 macOS 的 Node.js v24.16.0 中以独立进程运行,避免测试框架、REPL 或模块包装改变顶层上下文;浏览器示例在 macOS 的 Chrome 152.0.7977.77 前台页面中验证,以独立页面加载,并区分真实输入与同步派发。耗时实验只核验相对顺序和响应机会,不把单次毫秒数当作性能基准。尚未逐一在 Firefox、Safari 与后台节流场景复测,浏览器实测不等于所有实现的兼容性证明,规范结论与实现观察应分别判断。

这些实验不依赖框架、打包工具或浏览器控制台顶层求值。浏览器规范以 2026 年 9 月核对的 HTML、DOM 与 Web IDL Living Standard 为准。文中的确定输出只约束列出的日志,不表示期间没有浏览器或运行时的其他工作。