宏任务与微任务执行机制深度解析
深入剖析 JavaScript 事件循环中的宏任务与微任务机制,理解 setTimeout/setInterval 与 Promise.then 的执行顺序规则,掌握 Event Loop 面试核心考题。
一句话概括
JavaScript 事件循环(Event Loop)通过宏任务队列与微任务队列的双队列设计,实现了”执行一个宏任务 → 清空所有微任务 → 渲染 → 取下一个宏任务”的循环模型,微任务总是优先于下一个宏任务执行。
背景
JavaScript 是单线程语言,所有代码在同一条调用栈上执行。为了处理异步操作(网络请求、定时器、用户交互),浏览器引入了事件循环机制。早期只有简单的任务队列概念,随着 Promise 的引入,ES 规范正式将任务分为宏任务(macrotask/task)和微任务(microtask/job)两种优先级,彻底改变了异步代码的执行时序。
理解宏任务与微任务的执行顺序,是解答所有”输出顺序”类面试题的基础,也是理解 Vue 的 nextTick、React 的批量更新等框架机制的底层前提。
概念与定义
宏任务(Macrotask / Task)
| 来源 | 示例 |
|---|---|
| 定时器 | setTimeout、setInterval |
| I/O 操作 | 网络请求完成、文件读取完成 |
| UI 交互 | 点击、滚动等用户事件 |
| 浏览器 API | requestAnimationFrame(部分实现归为宏任务) |
| DOM 变更 | MessageChannel |
微任务(Microtask / Job)
| 来源 | 示例 |
|---|---|
| Promise | .then()、.catch()、.finally() |
| MutationObserver | DOM 变更监听回调 |
| queueMicrotask | 显式微任务调度 |
| async/await | await 之后的代码等价于 .then() |
| Object.observe | 已废弃,但属于微任务 |
核心规则
- 每轮循环只取一个宏任务执行
- 宏任务执行完后,清空整个微任务队列(逐个执行至队列为空)
- 微任务执行过程中新产生的微任务,在本轮清空阶段一并执行
- 微任务全部清空后,才可能进行 UI 渲染
- 渲染完成后,取下一个宏任务,开始下一轮循环
最小示例
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')) // 微任务1
.then(() => console.log('4')); // 微任务2(由微任务1产生)
console.log('5'); // 同步代码(当前宏任务)
// 输出顺序:1 → 5 → 3 → 4 → 2
解析:
1、5:同步代码,属于当前宏任务,立即执行3、4:Promise.then 产生微任务,当前宏任务结束后清空微任务队列2:setTimeout 产生宏任务,下一轮事件循环才执行
核心知识点拆解
1. 事件循环完整流程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌───────────────────────────────────┐
│ 事件循环模型 │
│ │
│ ┌─────────┐ ┌─────────────┐ │
│ │ 宏任务 │ │ 微任务队列 │ │
│ │ 队列 │ │ (FIFO, 全清) │ │
│ └────┬────┘ └──────┬──────┘ │
│ │ │ │
│ ▼ │ │
│ 取一个宏任务 ──────► 执行同步代码 │
│ │ │
│ ▼ │
│ 清空微任务队列 │
│ │ │
│ ▼ │
│ 是否需要渲染? │
│ │ │ │
│ 是 否 │
│ ▼ │ │
│ UI 渲染 │ │
│ │ │ │
│ └───┬────┘ │
│ ▼ │
│ 下一轮循环 │
└───────────────────────────────────┘
2. 微任务的”插队”特性
微任务在当前宏任务结束后、下一个宏任务开始前全部执行完毕,相当于插队到下一个宏任务前面:
1
2
3
4
5
6
7
8
9
10
setTimeout(() => console.log('timeout'), 0); // 宏任务A
Promise.resolve().then(() => {
console.log('microtask1');
Promise.resolve().then(() => {
console.log('microtask2'); // 微任务中产生的微任务,本轮清空
});
});
// 输出:microtask1 → microtask2 → timeout
关键点:微任务中产生的微任务不会延迟到下一轮,而是在当前微任务清空阶段一并执行。
3. async/await 的微任务本质
async/await 是 Promise 的语法糖,await 之后的代码等价于 .then():
1
2
3
4
5
6
7
8
9
10
11
async function foo() {
console.log('1'); // 同步
await Promise.resolve();
console.log('2'); // 等价于 .then(() => console.log('2')),微任务
}
console.log('3'); // 同步
foo();
console.log('4'); // 同步
// 输出:3 → 1 → 4 → 2
等价转换:
1
2
3
4
5
6
function foo() {
console.log('1');
Promise.resolve().then(() => {
console.log('2'); // 微任务
});
}
4. setTimeout(fn, 0) 不是真零延迟
1
2
3
4
5
6
7
// HTML5 规范:setTimeout 最小延迟为 4ms(嵌套超过5层后)
setTimeout(() => console.log('timeout'), 0);
// 在同一调用栈中,多次 setTimeout(0) 的执行顺序
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
// A 一定在 B 前面(同优先级 FIFO)
5. 微任务队列的无限扩展陷阱
1
2
3
4
5
6
7
8
9
// ⚠️ 危险:微任务队列无限扩展,导致宏任务永远无法执行
function infiniteMicrotasks() {
Promise.resolve().then(() => {
console.log('microtask');
infiniteMicrotasks(); // 递归产生微任务,页面卡死!
});
}
infiniteMicrotasks();
// setTimeout 永远不会执行,UI 无法渲染
这是一个常见的性能陷阱:微任务队列必须清空后才能执行下一个宏任务和渲染,如果在微任务中不断产生新微任务,页面会完全卡死。
实战案例
案例一:经典面试输出顺序题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
new Promise((resolve) => {
console.log('promise1'); // Promise 构造函数是同步执行的!
resolve();
}).then(() => {
console.log('promise2');
}).then(() => {
console.log('promise3');
});
console.log('script end');
// 输出顺序:
// script start ← 同步
// promise1 ← 同步(Promise 构造函数同步执行)
// script end ← 同步
// promise2 ← 微任务(第1个then)
// promise3 ← 微任务(第2个then,由第1个then产生)
// setTimeout ← 宏任务
核心陷阱:Promise 的 executor(构造函数参数)是同步执行的,只有 .then() 中的回调才是微任务。
案例二:async/await 与 Promise 混合
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
async function async1() {
console.log('async1 start');
await async2(); // await 右侧同步执行,之后进入微任务
console.log('async1 end'); // 微任务
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2');
});
console.log('script end');
// 输出顺序:
// script start
// async1 start
// async2 ← await async2() 同步执行
// promise1 ← Promise executor 同步执行
// script end
// async1 end ← 微任务(await 之后的代码)
// promise2 ← 微任务
// setTimeout ← 宏任务
案例三:微任务中创建宏任务
1
2
3
4
5
6
7
8
9
10
11
12
Promise.resolve().then(() => {
console.log('microtask1');
setTimeout(() => console.log('macro from micro'), 0);
}).then(() => {
console.log('microtask2');
});
setTimeout(() => console.log('macro1'), 0);
setTimeout(() => console.log('macro2'), 0);
// 输出顺序:
// microtask1 → microtask2 → macro1 → macro2 → macro from micro
在微任务中注册的宏任务会排到宏任务队列末尾,不会提前执行。
底层原理
V8 引擎中的微任务实现
V8 使用微任务检查点(Microtask Checkpoint)机制:
- 每个宏任务执行后,V8 会调用
PerformMicrotaskCheckpoint() - 该函数循环取出微任务队列中的任务执行,直到队列为空
- 微任务执行过程中新增的微任务会在同一轮 Checkpoint 中执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
调用栈 微任务队列 宏任务队列
│ │ │
│ 同步代码 ──► │ │
│ │ │
│ await/then ──► [cb1, cb2] │
│ │ │
│ 同步代码结束 │ │
│ │ │
▼ Checkpoint │ │
│ ◄── cb1 ──────┤ │
│ (执行) │ │
│ ◄── cb2 ──────┤ │
│ (执行) │ │
│ │ │
│ 渲染? │ │
│ │ │
▼ 取宏任务 │ ◄── task1
│ ◄─────────────────────────────────┤
浏览器中的任务调度
Chrome 的任务调度器实现:
- 主线程:执行 JS、DOM、样式计算、布局、绘制
- 定时器线程:管理 setTimeout/setInterval 计时(非主线程)
- 事件触发线程:管理用户交互事件(非主线程)
定时器到期后,回调被推入主线程的宏任务队列,等待主线程取出执行。这就是为什么 setTimeout(fn, 0) 不是零延迟——主线程可能正在执行其他任务。
Node.js 事件循环的差异
Node.js 的事件循环基于 libuv,分为 6 个阶段:
1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────────────┐
│ timers │ ← setTimeout/setInterval
├──────────────────────────┤
│ pending callbacks │ ← 系统级回调
├──────────────────────────┤
│ idle, prepare │ ← 内部使用
├──────────────────────────┤
│ poll │ ← I/O 回调
├──────────────────────────┤
│ check │ ← setImmediate
├──────────────────────────┤
│ close callbacks │ ← close 事件
└──────────────────────────┘
Node.js 中 process.nextTick 优先级高于 Promise.then(微任务内部再分级)。
高频面试题解析
Q1:宏任务和微任务的区别是什么?
答:
- 执行时机:微任务在当前宏任务结束后立即执行,宏任务在下一轮事件循环执行
- 队列策略:每轮循环只取一个宏任务,但清空全部微任务
- 典型代表:宏任务——setTimeout/setInterval/I/O;微任务——Promise.then/MutationObserver
- 优先级:微任务 > 宏任务(微任务总是先于下一个宏任务执行)
Q2:为什么 Promise.then 是微任务而不是宏任务?
答:设计为微任务是为了最小化延迟。Promise 表示”即将发生的操作”,如果放在宏任务队列,需要等下一个宏任务才能执行;作为微任务,可以在当前宏任务结束后立即执行,保证 then 回调的时效性。这也是 Vue 的 nextTick 使用微任务的原因——确保 DOM 更新后立即执行回调。
Q3:Promise 构造函数是同步还是异步?
答:同步执行。只有 .then()、.catch()、.finally() 中的回调才是微任务。构造函数中的 executor 立即同步执行,这是最常见的面试陷阱。
Q4:微任务中产生的微任务什么时候执行?
答:在当前微任务清空阶段执行。微任务队列是动态的——执行一个微任务可能产生新的微任务,这些新微任务会被追加到队列末尾,在当前 Checkpoint 中继续执行,直到队列完全清空。
Q5:requestAnimationFrame 是宏任务还是微任务?
答:既不是标准宏任务也不是标准微任务。它在微任务清空后、浏览器渲染前执行,可以视为”渲染前任务”。执行顺序:微任务 → requestAnimationFrame → 渲染 → 宏任务。
Q6:以下代码的输出顺序?
1
2
3
4
5
6
setTimeout(() => console.log(1), 0);
new Promise(resolve => {
console.log(2);
resolve();
}).then(() => console.log(3));
console.log(4);
答:2 → 4 → 3 → 1。
2:Promise executor 同步执行4:同步代码3:Promise.then 微任务1:setTimeout 宏任务
总结与扩展
| 要点 | 说明 |
|---|---|
| 双队列 | 宏任务队列(FIFO,逐个取)+ 微任务队列(FIFO,一次性清空) |
| 执行顺序 | 同步代码 → 清空微任务 → 可能渲染 → 取下一个宏任务 |
| Promise executor | 同步执行,不是微任务 |
| async/await | await 后的代码等价于 .then(),属于微任务 |
| 微任务插队 | 微任务总是在下一个宏任务前执行完毕 |
| 微任务陷阱 | 递归微任务会导致页面卡死(宏任务和渲染被阻塞) |
扩展阅读:
- HTML 规范中的 Event Loop:html.spec.whatwg.org/multipage/webappapis.html#event-loops
- Vue
nextTick的微任务实现原理 - Node.js 事件循环与浏览器的差异(libuv 六阶段模型)
requestIdleCallback与时间切片调度