之前练习一个订单支付流程,写了这样一段代码:
js
console.log('1. 开始支付')
Promise.resolve().then(() => {
console.log('2. 支付成功回调')
})
setTimeout(() => {
console.log('3. 超时检测')
}, 0)
console.log('4. 等待结果')
以为输出会是:
markdown
1. 开始支付
2. 支付成功回调
3. 超时检测
4. 等待结果
但实际结果却是:
markdown
1. 开始支付
4. 等待结果
2. 支付成功回调
3. 超时检测
"Promise 怎么跑到 setTimeout 后面去了? "------这正是 JavaScript 事件循环中最容易让人困惑的点:宏任务(Macrotask)和微任务(Microtask)的区别。
今天我就用一个真实业务场景,带你彻底搞懂这两个概念。
一、问题场景:支付状态同步的"时序陷阱"
我们有个 H5 支付系统,流程如下:
- 调起微信支付 SDK
- 监听支付结果(通过回调)
- 支付成功后立即更新 UI
- 然后跳转到订单详情页
代码长这样:
js
startWeChatPay(orderId).then(() => {
// 支付成功
updateOrderStatus('paid') // 更新状态
redirectToDetailPage() // 跳转页面
})
// 同时注册一个全局超时检测
setTimeout(() => {
if (order.status !== 'paid') {
showTimeoutWarning()
}
}, 1000)
但测试发现:有时页面还没来得及更新状态,就跳走了,用户看到的还是"待支付"界面。
为什么?因为 updateOrderStatus
里的某些异步操作被"延迟"了。要理解这个问题,我们必须深入事件循环内部。
二、解决方案:用微任务确保关键逻辑优先执行
我们把状态更新包装成一个微任务:
js
startWeChatPay(orderId).then(() => {
// 使用 queueMicrotask 确保优先执行
queueMicrotask(() => {
updateOrderStatus('paid')
redirectToDetailPage()
})
})
或者更常见的写法:
js
startWeChatPay(orderId).then(async () => {
await Promise.resolve() // 切入微任务队列
updateOrderStatus('paid')
redirectToDetailPage()
})
现在,updateOrderStatus
会在当前宏任务结束前立即执行,而不是等到下一个事件循环周期。
三、原理剖析:JavaScript 事件循环的双层调度机制
1. 表面用法:哪些是宏任务?哪些是微任务?
宏任务(Macrotask) | 微任务(Microtask) |
---|---|
setTimeout |
Promise.then/catch/finally |
setInterval |
queueMicrotask |
setImmediate (Node.js) |
MutationObserver (浏览器) |
I/O | process.nextTick (Node.js) |
script 标签整体代码 |
✅ 所有同步代码属于一个特殊的"初始宏任务"
2. 底层机制:事件循环如何调度?
我们来画一张 事件循环执行流程图:
🔍 关键点:每个宏任务执行完后,必须清空所有微任务,才能进入下一个宏任务。
3. 用支付案例演示执行顺序
再看这段代码:
js
console.log('1. 开始支付') // 同步 → 宏任务内
Promise.resolve().then(() => {
console.log('2. 支付成功回调') // 微任务
})
setTimeout(() => {
console.log('3. 超时检测') // 宏任务(下一轮)
}, 0)
console.log('4. 等待结果') // 同步 → 宏任务内
执行步骤分解:
步骤 | 动作 | 当前输出 |
---|---|---|
1 | 执行同步代码第1行 | 1. 开始支付 |
2 | 遇到 Promise → 微任务入队 | --- |
3 | 遇到 setTimeout → 宏任务入队 | --- |
4 | 执行同步代码第7行 | 4. 等待结果 |
5 | 当前宏任务结束 → 清空微任务队列 | 2. 支付成功回调 |
6 | 进入下一事件循环 → 执行 setTimeout 回调 | 3. 超时检测 |
这就是为什么 2
在 4
之后、3
之前执行。
四、设计哲学:为什么需要两种任务?
1. 宏任务:宏观控制流
- 适合 UI 渲染、定时操作、I/O 等耗时较长的任务
- 浏览器可以在两个宏任务之间进行渲染更新,保证流畅性
2. 微任务:微观精细控制
- 适合需要"立即响应"的逻辑,如 Promise 链、状态同步
- 确保异步操作的回调能尽快执行,避免中间被其他任务插入
💡 类比:
- 宏任务 像"会议日程"------每天安排几件大事
- 微任务 像"即时消息"------当前会议结束前必须处理完
五、对比主流异步方案
特性 | 宏任务(setTimeout) | 微任务(Promise) |
---|---|---|
执行时机 | 下一个事件循环周期 | 当前宏任务末尾 |
延迟 | 至少 ~4ms | 极低(<1ms) |
是否阻塞渲染 | 否(中间可渲染) | 是(清空微任务时) |
适用场景 | 超时控制、节流防抖 | 异步链式调用、状态同步 ✅ |
风险 | 可能被延迟太久 | 过多微任务会导致页面卡顿 |
六、实战避坑指南
❌ 错误用法:在微任务中无限递归
js
function bad() {
Promise.resolve().then(bad) // 🔥 微任务永不结束,页面卡死
}
✅ 正确做法:拆分为宏任务
js
function good() {
setTimeout(good, 0) // 让出控制权,允许渲染
}
❌ 错误用法:依赖 setTimeout(0) 做精细时序控制
js
// 你以为有先后?
setTimeout(step1, 0)
setTimeout(step2, 0)
✅ 正确做法:使用 Promise 链保证顺序
js
Promise.resolve()
.then(step1)
.then(step2)
七、举一反三:三个变体场景实现思路
-
批量 DOM 更新优化
将多个状态变更包裹在
queueMicrotask
中,利用微任务合并多次更新,在一次重排中完成。 -
防止微任务风暴
对高频触发的微任务(如监听器),使用节流或降级为宏任务(
setTimeout(fn, 0)
)。 -
跨框架状态同步
在 Vue 的
$nextTick
和 React 的flushSync
中,底层都依赖微任务机制确保视图与状态一致。
小结
宏任务和微任务不是两个独立系统,而是 JavaScript 事件循环的双层调度策略:
- 宏任务:控制整体流程节奏,允许渲染介入
- 微任务:保证关键逻辑"原子性"执行,避免中间状态被破坏
记住这个口诀:
同步代码先执行,
微任务清到底,
渲染更新插个队,
宏任务接着来。
当你写 Promise.then
或 queueMicrotask
时,你是在说:"这件事很重要,请在当前任务结束前立刻处理。"
而当你写 setTimeout(fn, 0)
时,你是在说:"这件事不急,请等下一回合再做。"