第 2 章
事件循环
异步在 JavaScript 里到底是怎么排队的——task、microtask、render
调试 race condition 的时候,事件循环是你唯一的参照系。
JavaScript 是单线程的——同一时刻只跑一段代码。但我们每天都在写异步:
fetch、setTimeout、Promise、事件监听。这些"看起来同时在跑"的事
其实都被排在一个队列里,主线程按规则一件一件做完。这一节讲那个规则。
1. 一句话模型
主线程从一个任务(task)队列里取一项执行,执行完后清空所有 微任务(microtask),然后浏览器有机会走渲染管线,再回来取 下一个任务。
伪代码:
while (true) {
const task = taskQueue.shift(); // 取一个 task
run(task); // 跑完它
while (microtaskQueue.length) {
// 清空所有 microtask
run(microtaskQueue.shift());
}
if (renderNeeded) renderFrame(); // 浏览器决定要不要画一帧
}记住三层节奏:task → 全部 microtask → (可选)渲染 → 下一个 task。
2. 谁是 task、谁是 microtask
| 类型 | 谁产生 |
|---|---|
| task | setTimeout / setInterval / I/O 回调 / <script> 顶层执行 / 用户事件回调(click 等) |
| microtask | Promise.then / queueMicrotask / MutationObserver |
关键差异:
- task 跑完会把所有 microtask 一口气清空。
- 一个 microtask 中产生的新 microtask 也会在同一轮里被处理——
写出
Promise.then嵌套 1000 层会饿死浏览器渲染(常见 bug)。 - task 之间会有渲染机会,microtask 之间没有。
经典输出题:
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");输出:A D C B。
AD:当前 task(脚本本身)顺序执行C:script这个 task 跑完后清空 microtaskB:下一个 task
3. 渲染什么时候发生
requestAnimationFrame(rAF)的回调在渲染前执行,是"我想在画下一帧
之前最后改一下 DOM"的入口。
task → microtask → rAF 回调 → 样式 → 布局 → 绘制 → 合成 → 下一个 task实战意义:
- rAF:DOM 动画的写法(手写动画时用,绝大多数场景用 CSS 动画就够)
requestIdleCallback:浏览器空闲时跑非紧急任务(埋点、预加载); Safari 至今支持有限,跨浏览器场景用 React 的startTransition/ 调度器更稳
AI 协作要点:让 AI 写"等 DOM 更新后再读尺寸"代码时,它常给
setTimeout(..., 0)。这是错的——setTimeout是下一个 task,会 错过这一帧的渲染。正确做法是双层 rAF:requestAnimationFrame(() => requestAnimationFrame(() => { // 此时 DOM 已经渲染过一次 const h = el.offsetHeight; }), );
4. 实战陷阱
4.1 Promise 链里 await 的"陷阱"
async function f() {
console.log("1");
await null; // 等价于 await Promise.resolve(null)
console.log("2");
}
f();
console.log("3");
// 输出:1 3 2await 之后的代码进入 microtask 队列。绝大多数 race 是因为某段代码
误以为 await 之后是同步继续。
4.2 Promise.then 饿死渲染
function loop() {
Promise.resolve().then(loop); // ❌ 永远在 microtask 里递归
}主线程被 microtask 占满,渲染永远轮不到。如果非要持续触发,用
setTimeout 或 requestAnimationFrame 把它降级到 task / 渲染节奏。
4.3 queueMicrotask 的合理使用
需要"在当前任务结束后立刻执行,但比 setTimeout(..., 0) 早"——用
queueMicrotask。典型用例:状态变更后延后通知订阅者,但不希望延后到
下一帧。
4.4 MutationObserver 是 microtask
变更 DOM 后再修改 DOM 用 MutationObserver——它的回调在 microtask
执行,比 setTimeout 早,但不会陷入同步循环。
5. Node.js 的事件循环(一句话差异)
Node 的事件循环阶段比浏览器多(timers / pending callbacks / poll /
check 等),并多出 process.nextTick(比 microtask 还更早)和 setImmediate
(在 poll 阶段后)。
前端工程师只要记:
- 浏览器和 Node 都遵守 "task → microtask" 大节奏
- Node 端排查异步顺序时翻 Node.js: The Node.js Event Loop 一手文档
- 不要在前端代码用 Node 特有 API(
process.nextTick等)——会破坏跨端 代码的可移植性
6. Web Worker:把单线程拆开的唯一办法
JavaScript 的单线程是浏览器主线程。Web Worker 可以开一条独立线程
跑 JS,主线程通过 postMessage 和它通信。
何时用:
- CPU 密集计算(图片处理、加密、大数据 parse)→ 别堵主线程
- Service Worker(PWA 离线缓存的核心):独立线程拦截网络
不能用:
- 直接操作 DOM(Worker 里没有
window/document) - 共享对象(必须
postMessage序列化,或用SharedArrayBuffer)
第 8 章前沿话题会再回来讲 Service Worker。
7. AI 在事件循环这层能帮你做什么
| 任务 | Tier | 备注 |
|---|---|---|
| 解释一段异步代码的输出顺序 | Tier 1 | 但自己也得能讲——这是判断 race 的基本盘 |
| 写 debounce / throttle | Tier 1 | lodash 已有;自己写也是入门必练 |
| 调试 race condition | Tier 4 辅助 | AI 能列假设,最终要你打 log + 走 Performance |
| 决定 microtask vs task vs rAF | Tier 3 慎重 | 错了会出难复现的渲染抖动;选错改起来代价大 |
| 写 Web Worker 通信协议 | Tier 3 review | 序列化边界、错误处理需要人定 |
延伸阅读
- HTML 标准:Event loops (英文) — 浏览器事件循环的根规范;写不出输出题时翻这里
- MDN: 事件循环 — 中文一手,简短版本
- Jake Archibald: In The Loop(演讲) (需代理) — 30 分钟把 task / microtask / rAF / rIC 全讲完,社区公认 最好的入门视频
- Node.js: The Node.js Event Loop — Node 端事件循环官方解释(中文版本可能落后;英文为准)
下一节:§2.5 浏览器存储 —— Cookie、localStorage、IndexedDB 的边界。