驱动网页的 JavaScript 事件循环与驱动 Node.js 服务器的事件循环行为截然不同,如果不加注意,这种不匹配可能会导致 UI 卡死或 I/O 阻塞。对于编写需要在两种环境中运行的异步代码的人来说,了解两者的分歧点至关重要。

为什么这种区别很重要

事件循环并未在 ECMAScript 规范中定义;它存在于宿主环境中。浏览器必须在渲染帧的同时保持页面响应,而 Node.js 则围绕非阻塞 I/O 构建。将适用于一种宿主的模式混用到另一种宿主中可能会产生难以复现的 bug:长链式的 Promise 可能会延迟浏览器的重绘,而不受控制的 process.nextTick 循环可能会阻止 Node 进入其 I/O 阶段。

浏览器的轮询式循环

在浏览器中,循环运行一个交替执行任务执行、微任务清空和渲染的单次周期:

  1. 执行一个宏任务(点击处理程序、setTimeout 等)。
  2. 清空所有微任务(promises, queueMicrotask)。
  3. 如果到了帧时间,进行绘制和合成,以达到目标 60 fps。
  4. 重复。

两个 API 为开发者提供了进入此周期的显式钩子:

  • requestAnimationFrame – 在浏览器绘制之前调用。它是进行动画工作的理想场所,因为回调在当前微任务之后但在下一帧之前运行。
  • requestIdleCallback – 在浏览器没有高优先级工作时调用。它适用于低影响任务,如分析数据或预加载数据。

陷阱:微任务饥饿 (microtask starvation)

由于浏览器在渲染之前清空微任务队列,长链式的 Promise 可能会导致 UI 永远无法进行绘制。调用栈并没有被阻塞;页面只是根本无法到达渲染步骤,这在用户看来就像是冻结了一样。

Node 基于 libuv 驱动的循环

Node.js 将其循环委托给 libuv,这是一个将工作划分为不同阶段的 C 库,每个阶段都有自己的队列:

  1. Timers – 来自 setTimeoutsetInterval 的回调。
  2. Pending callbacks – 已在 OS 层面完成的延迟 I/O 回调。
  3. Poll – 获取新的 I/O 事件(文件读取、网络数据)。
  4. Check – 运行 setImmediate 回调。
  5. Close callbacks – 当 socket 或 handle 关闭时触发。

有两个构造函数位于此阶段顺序之外:

  • process.nextTick – 在微任务队列之前运行,紧接在当前操作完成后。

陷阱:I/O 饥饿 (I/O starvation)

如果一个函数在不让出控制权的情况下重复调度 process.nextTick,Node 将永远无法推进到“next-tick”步骤之外。网络请求、文件读取和定时器都会处于闲置状态,导致服务器端延迟激增或直接挂起。

setImmediatesetTimeout 的实际应用

两者都会为下一次迭代调度回调,但它们的相对顺序取决于它们在哪里被调用:

  • 顶层代码 – 顺序无法保证;这取决于进程启动的速度。
  • 在 I/O 回调内部 – 顺序是确定性的:setImmediatesetTimeout(fn, 0) 之前运行。在 Poll 阶段结束后,libuv 会在重新进入 Timers 阶段处理零延迟超时之前,先移动到 Check 阶段(setImmediate 所在的阶段)。

当你依赖精确的执行顺序时(例如在读取完成后立即清理资源),这种细微差别非常重要。

核心差异一览

  • 目标:浏览器优先考虑视觉更新;Node 优先考虑 I/O 就绪状态。
  • 渲染钩子requestAnimationFrame(仅限浏览器)。
  • 特定阶段钩子setImmediate(仅限 Node,在 Check 阶段触发)。
  • 高优先级队列process.nextTick(仅限 Node,在微任务之前运行)。
  • 饥饿风险:浏览器中的长 Promise 链;Node 中的无限制 process.nextTick

后续注意事项

如果你维护的代码库需要在两种环境中运行(例如同构库),请审计以下任何地方:

  • 链式调用了许多 Promise 而没有向事件循环让出控制权。插入 await new Promise(r => setTimeout(r, 0)) 或在浏览器中使用 requestIdleCallback 来给渲染器一个机会。
  • process.nextTick 用于可以延迟执行的工作。当你不需要“next-tick”级别的紧迫性时,优先使用 setImmediate 或普通的 Promise。
  • 假设 setTimeout(fn, 0)setImmediate 是可以互换的。如果执行顺序很重要,请测试 I/O 回调内部的顺序。

核心要点

事件循环是特定于宿主环境的调度器,而非通用的 JavaScript 特性。浏览器将渲染编织进循环中;而 Node 则将 I/O 隔离在 libuv 的各个阶段中。误用优先级机制——如浏览器中的 microtasks 或 Node 中的 process.nextTick——可能会导致每个环境旨在服务的系统部分出现“饥饿”现象。使你的异步模式与宿主的循环模型保持一致,你就能同时避免页面冻结和服务器阻塞。