JavaScript 事件循环:宏任务与微任务执行顺序一图搞懂
面试高频考点:当面试官问"这段代码输出什么?"时,他们考察的不仅是语法,更是你对浏览器底层机制(Event Loop)的理解。本文通过一道综合例题,彻底拆解宏任务、微任务的执行顺序,并厘清它们与页面渲染、掉帧的关系。
一、为什么需要事件循环(Event Loop)?
浏览器的渲染进程主线程是一个"大忙人",它需要同时处理:
- 执行 JavaScript 代码
- 解析 DOM 树
- 计算样式(Style)
- 布局(Layout/Reflow)
- 绘制(Paint)
- 处理用户交互(点击、滚动等)
如果所有任务都同步排队执行,一旦遇到耗时操作(如复杂计算或网络请求),整个页面就会卡死(无响应)。
为了解决这个问题,浏览器采用了 "消息队列 + 事件循环" 机制:
- 同步代码:尽快在当前调用栈中执行完毕。
- 异步任务:交给宿主环境(浏览器内核)处理,完成后将回调放入队列等待执行。
- 事件循环:不断检查调用栈是否为空,若为空则从队列中取出任务执行。
🔄 一次完整的"工作循环"流程

| 环节 | 说明 |
|---|---|
| 1. 执行宏任务 | 整段 <script> 代码本身就是一个宏任务。JS 引擎从当前宏任务开始执行同步代码。 |
| 2. 遇异步任务 | 遇到 setTimeout、Promise 等,将其回调注册到对应的队列(宏任务队列或微任务队列),不阻塞主线程。 |
| 3. 清空微任务 | 关键点 :当前宏任务中的同步代码执行完毕后,立即清空微任务队列中的所有任务(先进先出)。 |
| 4. 尝试渲染 | 微任务清空后,浏览器可能会进行一次 UI 渲染(重排/重绘)。注:渲染时机受限于浏览器策略和帧率。 |
| 5. 下一个宏任务 | 从宏任务队列中取出下一个任务,重复上述过程。 |
核心口诀 :执行一个宏任务 → 清空所有微任务 → (可能渲染) → 执行下一个宏任务。
二、宏任务 vs 微任务:阵营划分
搞清楚"谁进哪个队"是解题的关键。
2.1 宏任务(Macro Task / Task)
宏任务代表了较大的、独立的逻辑片段。
- ✅ 整体脚本代码 (
<script>) - ✅
setTimeout()/setInterval() - ✅ I/O 操作
- ✅ UI 事件回调 (click, scroll, keydown 等)
- ✅
setImmediate(Node.js 环境)
2.2 微任务(Micro Task / Job)
微任务优先级更高,必须在当前宏任务结束前全部执行完。
- ✅
Promise.then()/.catch()/.finally() - ✅
async/await(本质是 Promise,await后的代码相当于.then回调) - ✅
queueMicrotask() - ✅
MutationObserver(DOM 变化监听)
三、实战演练:一道综合例题
下面这段代码覆盖了同步代码、定时器、Promise、async/await、原生微任务 API 以及 DOM 监听。
📝 代码挑战
请先遮住答案,自己在脑海中或控制台推演一下输出顺序。
javascript
console.log('1. 同步代码 start');
// 1. 宏任务:setTimeout
setTimeout(() => {
console.log('2. setTimeout 回调');
// 内部产生一个微任务
Promise.resolve().then(() => {
console.log('3. setTimeout 内部的微任务');
});
}, 0);
// 2. 同步代码:Promise 构造函数(立即执行)
const promise1 = new Promise((resolve) => {
console.log('4. Promise 构造函数执行');
resolve();
console.log('5. Promise resolve 之后');
});
// 3. 微任务:Promise.then
promise1.then(() => {
console.log('6. Promise.then 回调');
// 内部产生一个宏任务
setTimeout(() => {
console.log('7. Promise.then 内部的 setTimeout');
}, 0);
});
// 4. async/await 测试
async function asyncFn() {
console.log('8. async 函数同步部分');
await Promise.resolve(); // 相当于一个微任务断点
console.log('9. await 之后的代码');
}
asyncFn();
console.log('10. 同步代码 end');
// 5. 原生微任务 API
queueMicrotask(() => {
console.log('11. queueMicrotask 回调');
});
// 6. MutationObserver (DOM 变更触发微任务)
const observer = new MutationObserver(() => {
console.log('12. MutationObserver 回调');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1'); // 触发观察器
console.log('13. 同步代码 truly end');
🧠 执行步骤深度拆解
我们将时间轴划分为 宏观阶段:
第一阶段:执行初始宏任务(整体 Script)
主线程自上而下执行同步代码:
- 打印
'1. 同步代码 start' - 遇到
setTimeout:将回调放入宏任务队列 A。 - 执行
new Promise:构造函数是同步的。- 打印
'4. Promise 构造函数执行' - 调用
resolve() - 打印
'5. Promise resolve 之后'
- 打印
- 遇到
promise1.then:将回调放入微任务队列 B。 - 调用
asyncFn():- 执行同步部分,打印
'8. async 函数同步部分' - 遇到
await:将await后的代码放入微任务队列 C。
- 执行同步部分,打印
- 打印
'10. 同步代码 end' - 遇到
queueMicrotask:将回调放入微任务队列 D。 - 执行
MutationObserver逻辑:div.setAttribute触发变更。- 浏览器将 Observer 回调放入微任务队列 E。
- 打印
'13. 同步代码 truly end'
此时同步代码执行完毕,调用栈清空。开始检查微任务队列。
第二阶段:清空微任务队列
按照入队顺序依次执行(B -> C -> D -> E):
- 执行 B (
promise1.then):- 打印
'6. Promise.then 回调' - 遇到内部
setTimeout:将回调放入宏任务队列 F(注意:这是新的宏任务,要等下一轮)。
- 打印
- 执行 C (
await后):- 打印
'9. await 之后的代码'
- 打印
- 执行 D (
queueMicrotask):- 打印
'11. queueMicrotask 回调'
- 打印
- 执行 E (
MutationObserver):- 打印
'12. MutationObserver 回调'
- 打印
微任务队列已清空。 (此处浏览器可能会尝试渲染,但本例无视觉变化)
第三阶段:执行下一个宏任务
从宏任务队列中取出最早进入的任务(队列 A:最初的 setTimeout):
- 执行 A :
- 打印
'2. setTimeout 回调' - 遇到内部
Promise.resolve().then:将回调放入新的微任务队列 G 。 当前宏任务结束,再次清空微任务: - 执行 G :打印
'3. setTimeout 内部的微任务'
- 打印
第四阶段:执行再下一个宏任务
从宏任务队列中取出下一个任务(队列 F:promise.then 内部的 setTimeout):
- 执行 F :
- 打印
'7. Promise.then 内部的 setTimeout'
- 打印
✅ 最终输出结果
text
1. 同步代码 start
4. Promise 构造函数执行
5. Promise resolve 之后
8. async 函数同步部分
10. 同步代码 end
13. 同步代码 truly end
6. Promise.then 回调
9. await 之后的代码
11. queueMicrotask 回调
12. MutationObserver 回调
2. setTimeout 回调
3. setTimeout 内部的微任务
7. Promise.then 内部的 setTimeout
(注:如果你运行的结果顺序略有不同,请检查是否混淆了 async/await 的断点位置或 MutationObserver 的触发时机)
四、进阶思考:与渲染和掉帧的关系
理解事件循环不仅是为了做题,更是为了优化性能。
-
渲染时机 : 浏览器通常在 微任务清空后 且 下一个宏任务开始前 尝试进行渲染(RequestAnimationFrame 也在此阶段附近)。
-
掉帧(Jank)的成因:
- 如果微任务过多 或微任务执行耗时过长,会推迟渲染时机,导致用户看到页面卡顿。
- 如果宏任务执行时间超过 16ms(假设 60fps),也会造成掉帧。
-
避坑指南:
- ❌ 避免在微任务中死循环或进行大量计算(因为它会阻塞渲染)。
- ✅ 拆分大任务 :将一个巨大的同步计算拆分成多个
setTimeout宏任务,给浏览器留出渲染和响应用户输入的时间片。 - ✅ 合理使用
requestAnimationFrame:对于动画相关逻辑,不要依赖setTimeout或微任务,应使用 rAF 以确保与屏幕刷新率同步。
五、总结
掌握事件循环的核心在于记住这个流程图:
一句话总结 :宏任务是"大板块",微任务是"插队王"。一个大板块做完,必须把所有插队的微任务处理完,才能做下一个大板块,中间顺便画个图(渲染)。
希望这篇文章能帮你彻底搞定 Event Loop,面试 confidently 输出正确答案!🚀