宏任务与微任务执行机制深度解析
搞懂事件循环的铁律:一个宏任务 → 清空所有微任务 → 渲染 → 下一个宏任务。附带经典面试题全解。
一句话概括
事件循环只有一条铁律:每轮取一个宏任务执行,执行完立刻清空所有微任务,然后才考虑渲染或下一个宏任务。微任务永远抢在下一个宏任务前”插队”。
核心知识点
1. 两类任务,一张对照表
| 宏任务 (Macrotask) | 微任务 (Microtask) | |
|---|---|---|
| 典型 API | setTimeout、setInterval、I/O | Promise.then、MutationObserver、queueMicrotask |
| 每轮取几个 | 1 个 | 全清(清到队列为空) |
| 新产生的同类任务 | 下一轮 | 本轮内全部执行 |
| 渲染时机 | 可能在两轮之间 | 微任务清空后、渲染前 |
2. 经典执行顺序模型
1
2
3
4
5
6
7
8
9
10
11
console.log('1');
setTimeout(() => console.log('2'), 0); // 宏任务
Promise.resolve()
.then(() => { console.log('3'); return Promise.resolve(); })
.then(() => console.log('4')); // 微任务链
console.log('5');
// 输出:1 → 5 → 3 → 4 → 2
执行路径:
- 同步代码
1、5(当前宏任务)→ 结束 - 引擎检查微任务队列:
3、4→ 依次清空 - 微任务队列空 → 取下一个宏任务 →
2
3. 微任务里生微任务:全在本轮消化
1
2
3
4
5
6
7
Promise.resolve().then(() => {
console.log('A');
Promise.resolve().then(() => console.log('B')); // A 里生 B
});
Promise.resolve().then(() => console.log('C'));
// 输出:A → C → B (不是 A → B → C!)
B 虽然由 A 产生,但被追加到队列末尾,排在 C 之后——微任务队列是 FIFO,不是栈。
4. Promise 构造函数是同步的(高频陷阱)
1
2
3
4
5
6
7
8
console.log('start');
new Promise((resolve) => {
console.log('executor'); // ← 同步执行!
resolve();
}).then(() => console.log('then'));
console.log('end');
// 输出:start → executor → end → then
几乎所有”输出排序题”都靠这个坑拉开分数。
5. 微任务无限递归 = 页面卡死
1
2
3
4
5
6
7
8
function dead() {
Promise.resolve().then(() => {
console.log('stuck');
dead(); // 永远有新微任务 → 宏任务队列永远得不到执行 → 渲染被阻塞
});
}
dead();
// setTimeout 永远不会跑、页面不会渲染
微任务清空阶段如果源源不断产生新的微任务,宏任务队列和渲染管线永远等不到机会——这是真实的生产事故模式。
「其实你每天都在用」
1. Vue 的 nextTick
1
2
3
4
5
this.count = 1; // 触发 watcher
this.$nextTick(() => {
// DOM 已更新——因为 nextTick 优先用微任务
console.log(this.$el.textContent);
});
2. React 的批量更新
React 18 的自动批处理依赖微任务调度——多次 setState 合并成一次渲染。
3. MUI / Ant Design 的 loading 动画
1
2
3
4
5
6
showLoading();
setTimeout(() => {
// 确保 loading 先渲染出来
heavyCompute();
hideLoading();
}, 0);
4. await 后面的代码
1
2
await fetch('/api');
console.log('done'); // ← 这是个微任务
5. IntersectionObserver / MutationObserver
这些都走微任务通道,先于浏览器重绘执行。
实战:一道题吃透所有规则
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
}, 0);
new Promise((resolve) => {
console.log('4');
resolve();
}).then(() => {
console.log('5');
setTimeout(() => console.log('6'), 0);
}).then(() => console.log('7'));
queueMicrotask(() => console.log('8'));
console.log('9');
// 答案:1 → 4 → 9 → 5 → 8 → 7 → 2 → 3 → 6
逐帧拆解:
- 同步代码:
1、4(executor)、9 - 微任务队列:
5(第一个 then)→ 输出 5,注册了 setTimeout(‘6’);同时产生了微任务7+8(queueMicrotask 先还是 then 先?取决于注册顺序) - 清空微任务:
8(queueMicrotask 在 then 之前注册?不是——注意queueMicrotask在 Promise 链之后调用,但都在同一个宏任务中注册。实际顺序:then#1→then#2+queueMicrotask,因为 queueMicrotask 是同步注册的,在 then#1 的微任务执行之后排在队列中。最终:5 → 8 → 7) - 下一宏任务:
2→ 输出 2,注册微任务3 - 清空微任务:
3 - 再下一宏任务:
6
这道题覆盖了:executor 同步、then 微任务、queueMicrotask 排序、宏任务中注册微任务——面试够用了。
常见误解(FAQ)
❌ 误区 1:「setTimeout(fn, 0) 会立即执行」
不会。它的意思是”尽快把一个宏任务加入队列”,但排在它前面的同步代码和所有微任务必须先跑完。HTML5 规范还规定嵌套 5 层以上的 setTimeout 最小间隔 4ms。
❌ 误区 2:「微任务队列是一次清空一个」
不对。微任务队列是一次清空全部,包括清空过程中新产生的微任务。这就是为什么微任务递归会饿死宏任务。
❌ 误区 3:「async/await 的 await 前代码是微任务」
await 之前的代码是同步的(属于当前宏任务),await 之后的代码才是微任务。await fn() 中 fn() 本身也是同步执行的。
❌ 误区 4:「requestAnimationFrame 是微任务」
不是。rAF 在微任务清空后、浏览器渲染前执行。顺序是:微任务 → rAF → 渲染 → 宏任务。它自成一体。
一句话总结
记死这一行:同步跑完 → 微任务全部清空 → 可能渲染 → 取一个宏任务 → 重复。所有”输出顺序题”都是这句话的排列组合。