我不知道的 V8(14)— 微任务:执行时机、优先级与底层实现
"Promise 的 then 回调比 setTimeout 先执行"——大多数 JavaScript 开发者知道这个结论,但很少有人能说清楚为什么。这不是约定俗成的规则,而是 V8 内部有具体的执行机制在支撑:MicrotaskQueue 数据结构 + PerformMicr…
“Promise 的 then 回调比 setTimeout 先执行”——大多数 JavaScript 开发者知道这个结论,但很少有人能说清楚为什么。这不是约定俗成的规则,而是 V8 内部有具体的执行机制在支撑:MicrotaskQueue 数据结构 + PerformMicrotaskCheckpoint 触发点。理解这套机制,才能解释微任务的所有行为,包括一些反直觉的边界情况。
一、微任务包括哪些
V8 中的微任务(Microtasks)包含三类:
- Promise 回调:
Promise.then()、Promise.catch()、Promise.finally() queueMicrotask():直接向微任务队列添加任务的 API- MutationObserver 回调:DOM 变化后的通知回调
这三类任务有一个共同点:它们的执行时机是”当前宏任务结束后,立即执行,优先于下一个宏任务”。
二、为什么微任务优先于宏任务
先看经典的执行顺序例子:
setTimeout(() => console.log('Macro'), 0);
Promise.resolve().then(() => console.log('Micro 1'));
Promise.resolve().then(() => console.log('Micro 2'));
console.log('Sync');
// 输出顺序:
// Sync ← 同步代码最先
// Micro 1 ← 微任务,宏任务前执行
// Micro 2 ← 微任务,在同一轮清空
// Macro ← 宏任务,所有微任务之后
这个顺序背后是 V8 的事件循环规则:每个宏任务执行完毕后,在进入下一个宏任务之前,清空整个微任务队列。
微任务的优先级不是”比宏任务快一点”,而是”本轮宏任务结束前必须全部完成”。
三、V8 的底层实现:MicrotaskQueue
微任务队列在 V8 的 C++ 层是一个叫 MicrotaskQueue 的结构,存储在 V8 的 Isolate(运行时实例)中。
创建过程:
Promise.resolve().then(() => console.log('Micro'));
调用 then 时,V8 创建一个 PromiseReaction 对象,通过 EnqueueMicrotask 函数将其推入 MicrotaskQueue。queueMicrotask 是对这个接口的直接暴露:
queueMicrotask(() => console.log('Direct micro'));
// 等价于内部的 EnqueueMicrotask 调用
清空过程:
V8 的核心函数是 PerformMicrotaskCheckpoint。它在以下时机被调用:
- 当前宏任务(或脚本)执行完毕,调用栈清空时
- 显式调用
queueMicrotask后的检查点 - 某些 Promise 操作后
PerformMicrotaskCheckpoint 的逻辑:检查 MicrotaskQueue 是否为空,不为空则从头部依次取出并执行,直到队列清空。
关键细节:执行微任务时产生的新微任务,也会在本轮清空。
Promise.resolve()
.then(() => {
console.log('Micro 1');
// 在微任务里再加一个微任务
return Promise.resolve('Micro 2');
})
.then((v) => console.log(v));
// 输出:
// Micro 1
// Micro 2 ← 在同一轮微任务清空中执行,不会推到下个宏任务
四、微任务过多时的陷阱
“微任务在宏任务后全部清空”意味着一个风险:如果微任务不断产生新的微任务,事件循环会被卡住,下一个宏任务永远无法开始。
setTimeout(() => {
console.log('这行永远不会执行');
}, 0);
// 无限递归的微任务
function infiniteMicrotask() {
queueMicrotask(infiniteMicrotask);
}
infiniteMicrotask(); // 页面卡死
更常见的场景是微任务数量过多导致延迟:
// Bad Case:在一个宏任务中产生大量微任务
setTimeout(() => {
for (let i = 0; i < 10000; i++) {
Promise.resolve().then(() => processItem(i));
}
}, 0);
// 下一个宏任务(如 setTimeout、UI 渲染)要等这 10000 个微任务全部执行完
// Better:分批推入宏任务队列
function processBatch(start, end) {
for (let i = start; i < end; i++) {
processItem(i);
}
if (end < 10000) {
setTimeout(() => processBatch(end, Math.min(end + 100, 10000)), 0);
}
}
processBatch(0, 100);
五、queueMicrotask 的实际用途
queueMicrotask 是 ES2020 引入的标准 API,直接向微任务队列添加任务,不需要通过 Promise 包装。
适合替代 setTimeout(fn, 0) 的场景:
// 旧写法:用 setTimeout 推迟到"下一帧"
setTimeout(() => updateUI(), 0);
// 问题:setTimeout 是宏任务,会等到下一个事件循环才执行
// 如果当前宏任务后还有其他宏任务(如 setInterval),UI 更新会被推得更远
// 新写法:queueMicrotask 在当前宏任务后立即执行
queueMicrotask(() => updateUI());
// 无论有多少宏任务在排队,这次 UI 更新都会在当前宏任务结束后立即发生
批量异步操作的微任务优化:
// 场景:多次 DOM 操作后,一次性做检查
let pendingUpdate = false;
function scheduleUpdate() {
if (!pendingUpdate) {
pendingUpdate = true;
queueMicrotask(() => {
// 统一在微任务里处理所有 DOM 变化后的检查
performUpdate();
pendingUpdate = false;
});
}
}
// 即使连续调用多次,实际的 performUpdate 只会执行一次
scheduleUpdate();
scheduleUpdate();
scheduleUpdate();
这个模式在 Vue、React 的调度器里随处可见——把多次状态更新”合批”到一次微任务中处理,避免重复渲染。
表单验证的即时响应:
// 动态添加输入框后,立即验证表单状态
document.body.appendChild(inputElement);
queueMicrotask(() => {
// 在 DOM 添加完成后、渲染前执行验证
validateForm();
});
六、微任务 vs 宏任务 vs 同步代码:选哪个
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 必须立即执行 | 同步代码 | 无延迟 |
| 当前宏任务结束后立即执行 | queueMicrotask / Promise.then | 在渲染前完成,响应最快 |
| 延迟到下一轮事件循环 | setTimeout(fn, 0) | 给渲染和其他宏任务腾出空间 |
| 固定间隔重复执行 | setInterval / requestAnimationFrame | 周期性任务 |
| DOM 变化后响应 | MutationObserver | 批量变化,微任务级别的即时性 |
七、总结
微任务的核心机制:调用 then 或 queueMicrotask 时将回调放入 MicrotaskQueue,当前宏任务(或脚本)执行完毕后,V8 调用 PerformMicrotaskCheckpoint 清空整个队列。
这套机制的设计目标:让”需要紧跟当前操作的回调”(如 Promise 结果处理、DOM 更新通知)不用等待下一个宏任务轮次,在当前轮次内就完成。
queueMicrotask 的直接暴露让开发者可以不依赖 Promise,直接向微任务队列添加任务——在实现框架级别的调度器、批量更新、延迟验证等场景时,这是比 setTimeout 更精确的工具。
本系列其他文章:
- 上一篇:宏任务的调度逻辑
- 下一篇:MutationObserver 的 DOM 监听机制
- 延伸阅读:async/await 的实现原理