JavaScript 中的宏任务(Macrotask)和微任务(Microtask)
在 JavaScript 中,宏任务(Macrotask)和微任务(Microtask)是事件循环(Event Loop)机制的核心概念。理解它们对于掌握 JavaScript 的异步编程、解决代码执行顺序问题(尤其是面试中的输出顺序题)至关重要。
JavaScript 是一门单线程语言,为了不阻塞主线程,它引入了事件循环机制。所有的异步任务都会被放入任务队列中,而任务队列分为两种:宏任务队列和微任务队列。
一、 宏任务(Macrotask)
宏任务通常是由宿主环境(浏览器或 Node.js)发起的,代表一个个独立、离散的工作单元。
常见的宏任务包括:
- 整体代码
script(可以看作是第一个宏任务) setTimeoutsetIntervalsetImmediate(Node.js 独有)- I/O 操作(如文件读写、网络请求)
- UI 渲染(浏览器)
- DOM 事件回调(如
click事件)
特点: 每次事件循环只执行一个宏任务。
二、 微任务(Microtask)
微任务通常是由 JavaScript 引擎本身发起的,用于处理需要在当前宏任务执行结束后、下一个宏任务开始前立即执行的较紧急任务。
常见的微任务包括:
Promise.then() / .catch() / .finally()async/await(本质上是 Promise 的语法糖)MutationObserver(浏览器环境下,用于监听 DOM 变化)process.nextTick(Node.js 独有,且优先级高于其他微任务)
特点: 每次执行微任务队列时,会清空整个队列(包括在执行微任务过程中产生的新微任务)。
三、 事件循环的执行顺序(核心规律)
事件循环的运转遵循以下“黄金法则”:
- 执行同步代码: 从上到下执行当前所在的宏任务(通常是整体
script代码),这期间如果遇到新的宏任务,将其加入宏任务队列;遇到新的微任务,将其加入微任务队列。 - 清空微任务队列: 当前宏任务的同步代码执行完毕后,主线程会去检查微任务队列。如果有微任务,就依次执行,直到微任务队列为空。
- UI 渲染:(仅限浏览器)在微任务队列清空后,浏览器会根据需要进行页面的重新渲染。
- 执行下一个宏任务: 从宏任务队列中取出一个最早进入队列的宏任务,开始执行。
- 重复上述步骤: 宏任务 -> 微任务 -> 渲染 -> 宏任务 -> 微任务...
简记口诀: 同步代码优先 -> 耗尽微任务 -> 执行一个宏任务 -> 耗尽微任务 -> 循环。
四、 经典代码实战解析
示例 1:基础的宏任务与微任务
javascript
console.log('1. 同步代码开始');
setTimeout(() => {
console.log('2. 宏任务 setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('3. 微任务 Promise');
});
console.log('4. 同步代码结束');
输出结果: 1 -> 4 -> 3 -> 2
解析:
- 打印
1(同步)。 - 遇到
setTimeout,放入宏任务队列。 - 遇到
Promise.then,放入微任务队列。 - 打印
4(同步)。 - 当前宏任务执行完毕,检查微任务队列,发现有
Promise.then,执行并打印3。 - 微任务清空,开启下一个宏任务,执行
setTimeout,打印2。
示例 2:嵌套的复杂场景(高频面试题)
javascript
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);
new Promise((resolve) => {
console.log('4'); // 注意:Promise 构造函数里的代码是同步执行的!
resolve();
}).then(() => {
console.log('5');
setTimeout(() => {
console.log('6');
}, 0);
});
console.log('7');
输出结果: 1 -> 4 -> 7 -> 5 -> 2 -> 3 -> 6
逐步推导:
- [宏任务(整体代码)执行中]:打印
1。遇到setTimeout,将其回调记为Macro1。 - 遇到
new Promise,内部代码同步执行,打印4。调用resolve()后,将.then的回调记为Micro1。 - 打印
7。 - [清空微任务队列]:执行
Micro1。打印5。遇到内部的setTimeout,将其回调记为Macro2。 - [执行下一个宏任务]:取出
Macro1执行。打印2。遇到.then,记为Micro2。 - [清空微任务队列]:执行
Micro2。打印3。 - [执行下一个宏任务]:取出
Macro2执行。打印6。
示例 3:引入 async/await
记住一点:await 后面的代码,相当于被放到了 Promise.then() 里面,属于微任务。
javascript
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end'); // 这一句等于放进了微任务
}
async function async2() {
console.log('async2');
}
console.log('script start');
async1();
setTimeout(function() {
console.log('setTimeout');
}, 0);
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
输出结果:script start -> async1 start -> async2 -> promise1 -> script end -> async1 end -> promise2 -> setTimeout
五、 为什么要区分宏任务和微任务?
其实主要为了解决异步任务的优先级和插队问题。
- 宏任务是按顺序执行的。如果两个宏任务之间,DOM 发生了变化,浏览器会捕捉到并渲染。
- 微任务的存在是为了在当前任务执行完后,立即执行一些高优先级的逻辑,而不必等待下一个周期的到来。如果在执行宏任务之后立刻进行 UI 渲染,那么在渲染前通过微任务更新 DOM(如 Vue 的
nextTick,或者批量状态更新),就能避免浏览器进行无用的多次渲染,提升性能。
六、 总结对比
| 特性 | 宏任务 (Macrotask) | 微任务 (Microtask) |
|---|---|---|
| 发起者 | 宿主环境(浏览器 / Node.js) | JavaScript 引擎 |
| 包含内容 | script, setTimeout, setInterval, I/O, UI交互 |
Promise.then, MutationObserver, process.nextTick |
| 执行时机 | 在每个 Event Loop 的阶段执行一个 | 在当前宏任务结束后,下个宏任务开始前,或页面渲染前执行 |
| 清空机制 | 每次执行一个 | 每次执行队列中的所有(直到为空) |