Appearance
事件循环、微任务、宏任务
事件循环在规范中的定义
事件循环(Event Loop)的定义不在 ECMAScript 规范中,而在 HTML 标准 §8.1.6 的 Event Loop Processing Model 中。ECMAScript 只定义了 Promise、async/await 等语言特性,它们通过宿主环境(浏览器、Node.js)提供的 Job Queue 接口与事件循环交互。
一个完整的事件循环迭代(agent event loop turn)包含以下阶段:
text
1. 从 task queue 中取出最老的 task(task selection)
2. 设置当前 running task
3. 执行 task(同步代码段)
4. 清空 microtask queue(Perform a microtask checkpoint)
├── 执行每个 microtask,包括 microtask 执行过程中新产生的 microtask
└── microtask queue 完全为空后才结束
5. 检查是否需要渲染(Update the rendering)
├── requestAnimationFrame 回调在此阶段执行
├── 样式计算 → 布局 → 绘制 → 合成
└── requestIdleCallback 在渲染后执行(空闲时段)
6. 循环回到步骤 1关键点:microtask queue 的清空发生在每个 macrotask 执行完毕后、渲染之前。这是区分宏任务和微任务的核心——微任务在当前 task 内同步清空,宏任务各自占据独立的 task turn。
V8 中的实现路径
V8 本身不实现事件循环——事件循环由嵌入器(embedder)提供,即浏览器(Blink)或 Node.js(libuv)。V8 暴露了以下接口供嵌入器调用:
cpp
// 简化的 V8 嵌入器 API
v8::Isolate::PerformMicrotaskCheckpoint() // 清空微任务队列
v8::MicrotasksScope // 微任务作用域控制
v8::platform::PumpMessageLoop() // 驱动消息循环Microtask 入队机制
Promise.then() 的微任务入队不是由 Promise 本身完成的,而是通过 V8 的 EnqueueMicrotask 回调:
text
Promise.then(onFulfilled)
→ NewPromiseCapability()
→ PerformPromiseThen()
→ EnqueueJob("PromiseJobs", PromiseReactionJob) // 规范抽象
→ V8::EnqueueMicrotask() // 引擎层实现
→ 微任务队列追加在 V8 源码中,MicrotaskQueue 底层是一个单向链表(std::unique_ptr<Microtask> head_),每个 Microtask 节点持有指向下一个 microtask 的指针。每次 EnqueueMicrotask 向链表尾部追加节点,PerformMicrotaskCheckpoint 从链表头部逐个取出执行,直到链表为空。
Blink 侧的调度
在 Chromium 中,事件循环的具体实现位于 scheduler/ 目录下的 TaskQueueManager。渲染进程维护多个 task queue:
| Queue | 优先级 | 典型内容 |
|---|---|---|
| Input Queue | 最高 | 鼠标/键盘/触摸事件 |
| Compositor Queue | 高 | requestAnimationFrame |
| Default Queue | 中 | setTimeout、网络回调、IO |
| Idle Queue | 低 | requestIdleCallback |
同一 task turn 中,高优先级队列被优先消费。这是为什么 setTimeout 0 的实际延迟通常 >0ms——回调不仅排队,还要等更高优先级的队列先被 drain。
async/await 的 Promise 展开
await 的语义是 Promise.then() 的语法糖,但展开方式决定了执行顺序。规范(ECMAScript §14.7.5.14)定义了 await 的求值算法,简化后的关键步骤:
text
await expr 的执行:
1. 对 expr 求值
2. 调用 Promise.resolve(expr)
3. 如果 expr 不是 Promise,则包一层 Promise(立即 resolve)
4. 调用 .then(resume) — resume 是 await 后续代码
这一步将 await 后面的代码作为 .then 的回调入队微任务队列以原始代码为例来分析实际执行顺序:
js
function log(key, ms) {
return setTimeout(() => {
console.log(key);
}, ms);
}
(async function () {
await log(1, 300); // log 返回 setTimeout ID(数字),非 Promise
await log(2, 200);
})();
(async function () {
let a = log(3, 200);
let b = log(4, 300);
await a;
await b;
})();拆解过程:
- 第一个 IIFE 开始执行,调用
log(1, 300)——setTimeout被调用,回调被安排到 300ms 后执行,返回值是 timer ID(一个数字) await log(1, 300)→Promise.resolve(timerId).then(resume)——log(1, 300)返回数字,await把它包成Promise,.then作为微任务入队- 微任务队列中有第一个
.then,此时同步代码还未执行完(第二个 IIFE 尚未执行),微任务未执行 - 第二个 IIFE 开始执行:
log(3, 200)和log(4, 300)分别安排在 200ms 和 300ms 后 await a(a 是 timer ID 数字),Promise.resolve(timerId).then(resume)入队微任务- 同步代码执行完毕,开始清空微任务队列
- 第一个微任务执行:恢复第一个 IIFE,调用
log(2, 200),安排 200ms 后执行,await log(2, 200)再次入队微任务 - 第二个微任务执行:恢复第二个 IIFE,
await b(b 是 timer ID),Promise.resolve(timerId).then(resume)入队微任务 - 微任务继续执行,以此类推
最终输出:3 → 2 → 1 → 4(受 setTimeout 延迟控制,200ms 的 3 和 2 先到达,300ms 的 1 和 4 后到达)
要点:await 后面的代码总是作为微任务执行,不论 await 后面是否跟 Promise。区分点在于:若后面不是 Promise,只产生一层 .then;若是 Promise,.then 的注册时机取决于 Promise 的状态。
渲染检查点与事件循环的关系
浏览器的渲染(Update the Rendering)不是每个 task 之间都执行,而是受 vsync 信号 驱动(通常 60Hz,约 16.6ms 间隔)。在一个 vsync 周期内:
text
vsync → input events (macrotasks) → microtask checkpoint → rAF → layout → paint → composite → vsync关键结论:
requestAnimationFrame在渲染阶段执行,不在 macrotask/microtask 队列中。它的回调在当前帧的 layout 之前执行requestIdleCallback在帧末尾空闲时执行,deadline 到下一帧 vsync 为止- 在一个 macrotask 内产生的所有 microtask 都在同一个 task turn 中清空,渲染要等 microtask queue 完全为空后才可能发生
这意味着:如果 microtask queue 被无休止地追加新任务(microtask starvation),渲染永远无法执行——页面卡死。
js
// microtask 饥饿示例 — 页面永不渲染
function recursiveMicrotask() {
Promise.resolve().then(() => {
recursiveMicrotask() // 每次清空 microtask 时又追加新的
})
}
recursiveMicrotask()
// 之后的 macrotask(包括用户交互、渲染)永远不会执行处理方案:常见异步时序问题
方案一:拆分长任务,防止 microtask 饥饿
js
// ❌ 微任务递归 — 阻塞渲染
function processBatch(items) {
if (items.length === 0) return
doWork(items.shift())
Promise.resolve().then(() => processBatch(items)) // 永远在 microtask
}
// ✅ 交替调度 — macrotask 间允许渲染
function processBatch(items) {
if (items.length === 0) return
doWork(items.shift())
setTimeout(() => processBatch(items), 0) // 切到下一个 macrotask turn
}setTimeout 0 将下一次处理推入新的 macrotask,为渲染检查点留出间隙。对于 >50ms 的长任务,拆分为多个 <50ms 的任务块可保持页面 60fps 响应。
方案二:利用 queueMicrotask 显式控制优先级
js
// 需要在当前 task 内、渲染前完成的高优操作
queueMicrotask(() => {
updateCriticalState() // 微任务 — 当前 task 结束前执行
})
// 可推迟到下一帧的操作
requestAnimationFrame(() => {
updateVisualState() // 渲染前执行 — 避免 layout 抖动
})
// 完全不急的操作
requestIdleCallback(() => {
sendAnalytics() // 空闲时段执行
})queueMicrotask(ES2020)是 Promise.resolve().then(fn) 的语义等价物,但更直接、无 Promise 包装开销。
方案三:async/await 的错误处理
js
// ❌ await 后面的代码抛错,作为微任务的未捕获异常
async function fetchData() {
await fetch('/api/data') // 若 fetch 失败 reject,错误在这里抛出
processData() // 这行不会执行
}
// ✅ 明确 try/catch 或 .catch
async function fetchData() {
try {
const res = await fetch('/api/data')
processData(res)
} catch (e) {
handleError(e)
}
}await 将 Promise reject 转换为同步 throw(在微任务执行过程中),未被 catch 的 reject 会成为未处理的 Promise rejection。
方案四:setTimeout 延迟的 4ms 保护
js
// 嵌套的 setTimeout 有最小延迟
setTimeout(() => {
setTimeout(() => {
// 嵌套 5 层后,最小延迟从 0 变为 4ms(浏览器实现)
}, 0)
}, 0)HTML 规范规定:setTimeout 嵌套层级 > 5 时,最小延迟为 4ms。这是为了防止 setTimeout 的紧密循环阻塞事件循环。现代浏览器扩展了此保护——Chromium 中 setTimeout(fn, 0) 的实际最小延迟通常为 1ms(非 0ms),通过 DOMTimer::CalculateDelay 强制调整。
对于动画或高精度定时,用 requestAnimationFrame 替代 setTimeout。
方案五:Promise.all 与并发微任务
js
// ✅ Promise.all — 所有 Promise 的 .then 在同一次 microtask 清空中执行
await Promise.all([
fetch('/api/a'),
fetch('/api/b'),
fetch('/api/c'),
])
// 三个请求并发,但它们的 resolve 回调在同一个 microtask checkpoint 中依次执行
// 顺序:按 Promise.all 内部记录的 completion index注意 Promise.all 的并发限制——三个 fetch 的 .then 在同一个 microtask checkpoint 内依次入队、依次执行,三个微任务之间不会插入 macrotask 或渲染。
方案六:Node.js 侧的 process.nextTick
js
// Node.js 专有:nextTick 优先级高于所有微任务
Promise.resolve().then(() => console.log('microtask'))
process.nextTick(() => console.log('nextTick'))
// 输出:nextTick → microtaskprocess.nextTick 的回调在 Node.js 的 nextTickQueue 中,它在每个 C/C++ → JS 边界转换时被 drain——在 microtask 之前。浏览器中无对应机制。这常用于 Node.js 的 error-first callback 模式中确保异步错误处理。
事件循环与 async/await 的时序陷阱
陷阱 1:await 与循环
js
// ❌ 串行执行(每个 await 等待上一个 Promise resolve)
async function sequential(urls) {
for (const url of urls) {
const data = await fetch(url) // 一个接一个
results.push(data)
}
}
// ✅ 并发执行(Promise.all)
async function concurrent(urls) {
const promises = urls.map(url => fetch(url))
const results = await Promise.all(promises)
}for...of + await 是串行的——每次循环迭代的 await 等待上一个 resolve。用 map + Promise.all 将所有 Promise 并发启动,再统一等待结果。
陷阱 2:async 函数内部 await 前的同步代码
js
async function demo() {
console.log(1) // 同步
await Promise.resolve()
console.log(2) // 微任务
}
demo()
console.log(3)
// 输出:1 → 3 → 2await 之前的代码同步执行(1),await 之后的代码作为微任务入队(2),在同步代码(3)之后执行。
陷阱 3:多个 await 产生的微任务链
js
async function chain() {
console.log('A')
await Promise.resolve()
console.log('B')
await Promise.resolve()
console.log('C')
}
chain()
Promise.resolve().then(() => console.log('D'))
// 输出:A → B → D → C
// 不是 A → B → C → D每次 await 产生一个 .then,后续 await 的 .then 链在微任务队列中交替穿插。第二个 await 需要等 console.log('B') 执行完后才注册下一个 .then,此时 'D' 的微任务已在队列中。
生产环境中的调试
Chrome DevTools Performance 面板
- Record → 分析 Main Thread:灰色块 = Task,Task 内部嵌套的块标有
Run Microtasks的就是微任务执行段 - Call Tree 面板:搜索
FunctionCall→Promise.then调用链,确认微任务来源 - Bottom-Up 面板:按 Self Time 排序,找出耗时最长的微任务
诊断 microtask 饥饿
- 症状:页面无响应、点击无反馈、但 CPU 使用率高
- Performance 面板:Main Thread 一直显示 Task,Task 之间没有 Paint 事件
- 火焰图:看到连续的
Run Microtasks块,没有Update Layer Tree/Paint/Composite
诊断长任务
Performance 面板中标记为红色的 Task(>50ms)即为长任务。长任务导致用户交互延迟、渲染卡顿。修复方向:将长任务拆分为 setTimeout 或 requestIdleCallback 调度的小任务块。
requestAnimationFrame 的正确使用
js
// ✅ rAF 用于视觉更新,时序精确到帧
function animate() {
updatePosition()
requestAnimationFrame(animate) // 每帧执行一次
}
// ❌ setTimeout 用于动画 — 频率不可控
setInterval(updatePosition, 16) // 可能丢帧或重复帧rAF 在渲染前执行,浏览器可将其与布局/绘制合并到同一帧内,避免 layout thrashing。setInterval 与 vsync 不同步,回调可能在帧中间执行导致额外 layout。
rAF + microtask 交互
js
requestAnimationFrame(() => {
console.log('rAF')
Promise.resolve().then(() => console.log('microtask in rAF'))
})rAF 回调内的 Promise.then 微任务在 rAF 回调执行完毕后、实际 layout 之前被清空——因为 microtask 清空发生在每个 JS 执行上下文退出时,rAF 回调退出触发 microtask checkpoint。
