文章

宏任务与微任务执行机制深度解析

搞懂事件循环的铁律:一个宏任务 → 清空所有微任务 → 渲染 → 下一个宏任务。附带经典面试题全解。

宏任务与微任务执行机制深度解析

一句话概括

事件循环只有一条铁律:每轮取一个宏任务执行,执行完立刻清空所有微任务,然后才考虑渲染或下一个宏任务。微任务永远抢在下一个宏任务前”插队”。


核心知识点

1. 两类任务,一张对照表

 宏任务 (Macrotask)微任务 (Microtask)
典型 APIsetTimeoutsetInterval、I/OPromise.thenMutationObserverqueueMicrotask
每轮取几个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. 同步代码 15(当前宏任务)→ 结束
  2. 引擎检查微任务队列:34 → 依次清空
  3. 微任务队列空 → 取下一个宏任务 → 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. 同步代码:14(executor)、9
  2. 微任务队列:5(第一个 then)→ 输出 5,注册了 setTimeout(‘6’);同时产生了微任务 7 + 8(queueMicrotask 先还是 then 先?取决于注册顺序)
  3. 清空微任务:8(queueMicrotask 在 then 之前注册?不是——注意 queueMicrotask 在 Promise 链之后调用,但都在同一个宏任务中注册。实际顺序:then#1then#2 + queueMicrotask,因为 queueMicrotask 是同步注册的,在 then#1 的微任务执行之后排在队列中。最终:5 → 8 → 7)
  4. 下一宏任务:2 → 输出 2,注册微任务 3
  5. 清空微任务:3
  6. 再下一宏任务: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 → 渲染 → 宏任务。它自成一体。


一句话总结

记死这一行:同步跑完 → 微任务全部清空 → 可能渲染 → 取一个宏任务 → 重复。所有”输出顺序题”都是这句话的排列组合。

本文由作者按照 CC BY 4.0 进行授权