带你彻底弄懂 Event Loop
Category(分类): JavaScript Status: 已整理
前言
我在学习浏览器和 Node.js 的 Event Loop 时看了大量文章。那些文章各自都写得很好,但往往每篇文章只讲清几个关键点,综合起来看,才能对这些概念有较为完整的理解。
于是我想保留原来这篇文章的故事、题目和推导方式,再把术语按今天的规范重新整理。Event Loop 不是 ECMAScript 单独规定的一条“宏队列先进先出”规则:ECMAScript 规定 Promise reaction job 等语言层行为,浏览器由 WHATWG HTML 把它们接到 task、microtask checkpoint 和渲染流程上,Node.js 则由 Node 自身、V8 和 libuv 协作完成。下面会明确说明讨论的是浏览器 classic script、浏览器模块、Node CommonJS 还是 Node ESM。
Event Loop 是什么
Event Loop 是宿主提供的一种调度模型,不同宿主有不同实现。
- 浏览器 HTML 标准定义 event loop、task source、microtask checkpoint 和 update-the-rendering 的关系。
- ECMAScript 定义 Promise reaction jobs、
await等语言语义,并通过宿主的HostEnqueuePromiseJob接入微任务机制。Promise 构造器的 executor 本身是同步调用的。 - Node.js 的事件循环建立在 libuv 之上;Node 还维护
process.nextTick()队列,并使用 V8 的 microtask queue 处理 Promise reaction 和queueMicrotask()。
“任务完成了”只表示宿主可以把相应回调排入可运行的任务来源;它不表示回调会立即执行,也不表示所有来源共享一个按注册时间排列的全局队列。
浏览器:task、microtask 和渲染机会

图片来源:原文事件循环配图,已本地化。原图来自第三方图床,授权风险未核实;图中混合了历史性的浏览器/Node 术语,下文按当前 WHATWG 模型校正。
不把浏览器想成一个全局宏任务队列
在浏览器语境里,优先使用 task 这个词。计时器、用户交互、网络/消息、脚本等由不同的 task source 产生任务。HTML 允许用户代理从可运行的 task queue 中选择任务,并不承诺把所有来源合并为一个全局 FIFO 队列。
常见的例子包括:
- classic script 的初始执行、用户交互事件、
postMessage/MessageChannel 消息 setTimeout、setInterval产生的 timer task- 网络、文件选择器等由具体宿主 API 产生的回调任务
setImmediate() 不是浏览器 API。requestAnimationFrame() 也不应简单归类为普通宏任务:它的回调参与下一次合适的 rendering opportunity 中的 update the rendering,其调度与刷新率、页面可见性和浏览器节流有关。
microtask checkpoint
一次 task 的 JavaScript 回调完成后,浏览器会在适当的宿主边界执行 microtask checkpoint。checkpoint 会持续取出 microtask,直到队列为空;在执行某个 microtask 时新加入的 microtask 也会在本次清空过程中继续处理。
浏览器常见的 microtask 来源有:
Promise.prototype.then/catch/finally产生的 Promise reaction jobsqueueMicrotask()MutationObserver的通知
Promise 本身不是“一个微任务队列项”,更准确地说是 Promise 状态改变后触发的 reaction job 被排入宿主的 microtask queue。早期的对象观察 API 已移除,不能作为当前示例。Node 的 process.nextTick() 也不是浏览器 microtask,它属于 Node 自己的 next-tick queue。
渲染不是每个 task 后的必经步骤
microtask checkpoint 结束后,用户代理可能在 rendering opportunity 中执行 update the rendering,其中可以调用合适的 requestAnimationFrame 回调;也可能因为没有渲染机会、页面不可见或正在节流而先选择另一个 task。不能由规范推出“每个微任务后必然绘制”,也不能推出某个 requestAnimationFrame 一定早于某个 setTimeout。
下面的代码可以用来观察几个层次,但只保证同步日志和 microtask 的相对关系;rAF 与 timer 的相对顺序不要写成规范保证:
console.log('sync')
queueMicrotask(() => console.log('microtask'))
Promise.resolve().then(() => console.log('promise reaction'))
setTimeout(() => console.log('timer task'), 0)
requestAnimationFrame(() => console.log('rendering callback'))
浏览器的经典题
下面仍然保留经典题。它假设代码作为浏览器 classic script 执行,且没有其他竞争任务;在当前常见浏览器中通常输出如下:
console.log(1)
setTimeout(() => {
console.log(2)
Promise.resolve().then(() => {
console.log(3)
})
})
new Promise((resolve) => {
console.log(4)
resolve(5)
}).then((data) => {
console.log(data)
})
setTimeout(() => {
console.log(6)
})
console.log(7)
1
4
7
5
2
3
6
解释如下:new Promise 的 executor 在构造时同步打印 4,调用 resolve(5) 只会使 reaction 在之后以 job 执行;全局 script 继续打印 7。script task 结束后执行 microtask checkpoint,先运行打印 5 的 reaction。之后用户代理选择第一个已经达到 timer 条件的 timer task,打印 2,该 task 中新排入的 Promise reaction 会在下一个 task 之前的 checkpoint 打印 3,再处理另一个 timer task。
这里“第一个 timer”是针对同一 timer source 和这个简化示例的教学描述,不代表所有 task source 在浏览器中共享一个可观察的总队列。
Promise 链会在同一个 checkpoint 继续排队
console.log('start')
Promise.resolve().then(() => {
console.log('first reaction')
return Promise.resolve()
}).then(() => {
console.log('second reaction')
})
setTimeout(() => console.log('timer'), 0)
console.log('end')
在浏览器 classic script 中,通常先输出 start、end,随后运行 Promise reaction jobs,最后才有机会运行 timer。第二个 reaction 是第一个 reaction 返回的 Promise 兑现后再排入的 job;microtask checkpoint 会继续清空它,而不是看到队列暂时为空就把它推迟到下一个 timer。
Node.js:nextTick、V8 microtask 和 libuv
浏览器的 HTML 模型不能直接套到 Node。Node 的实现图常被画成六个阶段,这对入门有帮助,但它是 libuv/Node 的近似实现图,不是“浏览器宏任务规范”,也不是作者可以观察到的四个独立宏队列。
libuv 阶段的版本化近似
Node 文档常用下面的阶段帮助理解一次循环:
- timers:处理已达到阈值的
setTimeout、setInterval回调 - pending callbacks:处理部分被延迟到下一轮的系统级回调
- idle、prepare:Node/libuv 内部使用
- poll:获取 I/O 事件并执行大部分 I/O 回调,条件合适时可能等待
- check:处理
setImmediate()回调 - close callbacks:处理例如 socket close 的回调
Node 20 使用的 libuv 1.45.0 改变了正常循环迭代中 timers 的位置:timers 通常在 poll 之后处理,而不是把旧教材中的 timers -> poll -> check 当作永远固定的全序列。libuv 为兼容仍可能在初次进入默认循环前处理一次 timers。timer 的延迟是达到条件的下限,不是精确的执行时刻。
原文中的 libuv 图、六阶段图和 Node 队列图仍有历史教学价值,下面统一作为“历史/教学近似图”保留。它们不应被理解成 ECMAScript 或 WHATWG 的规范数据结构。





Node 的两条回调队列
在当前 Node 中,至少要区分:
- next-tick queue:
process.nextTick()使用的 Node 专用队列。它不是 libuv 的一个阶段,也不是 V8 Promise microtask。 - V8 microtask queue:Promise reaction 和
queueMicrotask()使用的队列。
Node 在进入或返回 JavaScript 回调的边界会处理这些队列,通常先清空 next-tick queue,再清空 V8 microtask queue。具体可观察顺序还受代码是 CommonJS 还是 ESM、回调从哪个阶段进入以及 Node 版本影响。不要把它简化为“每个阶段结束才统一清空全部微任务”,也不要称 Node 有四个规范意义上的宏队列。
下面这个较小的示例适合观察当前 CommonJS 文件中 callback 之间的处理:
setImmediate(() => {
console.log('A')
process.nextTick(() => console.log('nextTick after A'))
})
setImmediate(() => console.log('B'))
在当前常见 Node 版本中,典型输出是:
A
nextTick after A
B
这是 Node 在一个 setImmediate 回调返回 JavaScript 边界时处理 next-tick queue 的结果,不应反推浏览器存在同样的队列。
CommonJS 和 ESM 的差异
Node 顶层 CommonJS 代码与 ESM 代码的执行入口不同。下面的三句分别放在 order.cjs 和 order.mjs 中运行:
Promise.resolve().then(() => console.log('promise'))
queueMicrotask(() => console.log('microtask'))
process.nextTick(() => console.log('nextTick'))
在当前 Node 中常见的结果是:
- CommonJS:
nextTick、promise、microtask - ESM:
promise、microtask、nextTick
ESM 顶层评估本身处于 Promise/微任务相关的执行路径中,因此不能把 CommonJS 顶层观察到的 nextTick 优先级无条件套到 ESM。需要跨浏览器和 Node 编写代码时,普通的“尽快异步执行”优先考虑 queueMicrotask();只有确实需要 Node 特有优先级时才使用 process.nextTick()。
setTimeout 对比 setImmediate
在主模块中同时注册下面两者,先后顺序不保证:
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
不要使用忙等循环来“证明”某个顺序。忙等会阻塞当前线程,结果还会依赖机器速度、timer threshold、操作系统和 Node/libuv 版本。
在同一个 I/O 回调中注册时,Node 官方教程给出的实用规则是 setImmediate() 通常先于同处注册的 setTimeout(..., 0),因为 I/O 回调在 poll,随后会进入 check;timer 仍受阈值和循环状态影响。下面是 CommonJS 示例,__filename 是文件路径,不是目录:
const fs = require('node:fs')
fs.readFile(__filename, (error) => {
if (error) throw error
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
})
旧 Node 示例:保留题目,但不再给唯一答案
下面这道题和原文中的 Node 六阶段图一样,是很有代表性的历史面试材料:
console.log('start')
setTimeout(() => {
console.log(111)
setTimeout(() => console.log(222), 0)
setImmediate(() => console.log(333))
process.nextTick(() => console.log(444))
}, 0)
setImmediate(() => {
console.log(555)
process.nextTick(() => console.log(666))
})
setTimeout(() => {
console.log(777)
process.nextTick(() => console.log(888))
}, 0)
process.nextTick(() => console.log(999))
console.log('end')
start、end 和顶层的 999 的关系较稳定;顶层 timer 与 immediate 的起始顺序可能不同,且不同 Node 版本、入口类型和平台会影响后续完整序列。原文曾以旧 Node 的阶段 drain 方式给出一个“正确答案”,这里保留代码作为历史练习,但不再宣称一个跨版本唯一全序列。若要研究当前行为,应在目标 Node 版本下分别运行 .cjs 和 .mjs,并记录版本。
递归 process.nextTick() 没有可以依赖的固定次数上限。旧版 Node 的深度限制信息不能代表当前 Node。无限递归 nextTick 可能持续占用 CPU、阻塞 I/O 和 timer;需要让出事件循环时,应分批处理并在批次之间使用 setImmediate() 或其他合适的调度方式。

一个更准确的浏览器流程图
可以把浏览器的常见流程记成下面的抽象,而不是记成一个宏队列数组:
- 用户代理从某个 task source 选择一个 runnable task
- 执行该 task 内的 JavaScript,执行上下文按 run-to-completion 运行
- 在合适的宿主边界执行 microtask checkpoint,并清空期间新增的 microtask
- 用户代理根据 rendering opportunity 决定是否执行 update-the-rendering 和合适的
requestAnimationFrame回调 - 再选择下一个 runnable task
渲染步骤是独立的宿主调度机会,不是“微任务队列中的另一个任务”,更不能拿它推出 rAF 一定先于 timer。
总结
- 浏览器应使用
task、task source和microtask checkpoint描述调度,不要假设只有一个全局宏任务队列。 - Promise executor 同步执行;
.then()等注册的是 Promise reaction job,浏览器通常在 microtask checkpoint 中运行它。 - checkpoint 会清空 microtask 队列,并处理执行期间新加入的 microtask;这不等于每个 checkpoint 后必然绘制。
requestAnimationFrame属于渲染机会附近的回调,和 timer task 是不同层次,不能写成一定早于或晚于 timer。- Node 需要区分 next-tick queue、V8 microtask queue 和 libuv phases;CommonJS 与 ESM 顶层顺序也可能不同。
- Node 20/libuv 1.45.0 后 timers 的正常循环位置发生变化;
setTimeout(0)是阈值,不是精确的零延迟。 - 递归
process.nextTick()没有可依赖的 1000 次自动让出;它可能造成 event-loop starvation。 - 浏览器 Worker 和 Node
worker_threads各自拥有独立 agent/event loop,可以并行运行 JavaScript;“一个 agent 内按顺序执行”不等于“整个 JavaScript 世界只有一条线程”。

图片来源:原文末尾宣传配图,已本地化;授权风险:宣传图和商标的再发布许可未核实,实际发布前可按站点需求移除。
图片来源与授权风险
本文原文的示意图来自第三方文章/图床,已使用仓库中对应的本地 WebP 作为正文资源,不保留远程热链。图片内容和转载授权无法仅凭下载确认,发布前仍应向原作者或图床内容权利人确认许可:
images/97-image-01.webp:事件循环与任务队列历史示意图。来源:原文配图(原 SegmentFault 图床),授权风险:第三方转载许可未核实。images/97-image-02.webp:libuv 结构历史示意图。来源:原文配图,授权风险:第三方转载许可未核实。images/97-image-03.webp:Node.js/libuv 阶段示意图。来源:原文配图,授权风险:第三方转载许可未核实。images/97-image-04.webp:Node 队列关系历史示意图。来源:原文配图,授权风险:第三方转载许可未核实。images/97-image-05.webp:Node 事件循环历史流程图。来源:原文配图,授权风险:第三方转载许可未核实。images/97-image-06.webp:Node 宏/微队列历史示意图。来源:原文配图,授权风险:第三方转载许可未核实。images/97-image-07.webp:原文更新说明配图。来源:原文配图,授权风险:第三方转载许可未核实。images/97-image-08.webp:原作者来源宣传配图。来源:原文配图,授权风险:宣传图和商标的再发布许可未核实。
原文归属
作者:leocoder。历史来源:SegmentFault《带你彻底弄懂 Event Loop》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。
参考链接
- WHATWG HTML:event loop processing model
- WHATWG HTML:perform a microtask checkpoint
- WHATWG HTML:task source 与 timer task
- WHATWG HTML:rendering opportunity 与 update the rendering
- WHATWG HTML:requestAnimationFrame
- ECMAScript:HostEnqueuePromiseJob
- ECMAScript:Promise reaction job
- ECMAScript:await
- Node.js:event loop、timers 和 nextTick
- Node.js:queueMicrotask 与 process.nextTick
- Node.js:setImmediate
- Node.js:worker_threads
- libuv:event loop design
- libuv:timer handle