浏览器的宏任务和微任务,是什么?
回答由 AI 基于 batype 的笔记生成,不代表本人实时观点。
结论:他没有写过这个话题。
以下是我的推测,不代表 batype 本人观点:
我尽量讲得系统一点,但还是先抓主线:浏览器里的 JavaScript 不是“同时处理很多事”,而是靠 Event Loop 在“任务队列”之间切换。宏任务决定一轮一轮地做什么,微任务决定这一轮结束前必须清空什么。
1. 为什么会有宏任务和微任务
JavaScript 在浏览器主线程里通常是单线程执行的。
这意味着:
- 同一时刻只能做一件事
- 但浏览器又要同时处理:
- 执行 JS
- 响应点击、输入、滚动
- 发网络请求
- 做页面渲染
- 执行定时器回调
所以浏览器不会把所有事情都塞进一个同步调用栈里,而是拆成一批一批任务,让 Event Loop 去调度。
为了更细地控制优先级,任务被分成了两类:
- 宏任务(macrotask / task)
- 微任务(microtask)
可以先用一句话理解:
- 宏任务:一次“大的工作单元”
- 微任务:当前这次大工作结束后,立刻补做完的小工作
2. 宏任务是什么
常见宏任务包括:
- 整体脚本执行(
<script>) setTimeoutsetInterval- DOM 事件回调(click、input 等)
- 网络事件
postMessageMessageChannel- 一些 I/O 事件
你可以把宏任务理解成:Event Loop 每次从外面拿一个大任务进来执行。
比如:
console.log('A')
console.log('B')
这整个脚本本身,就是一个宏任务。
再比如:
setTimeout(() => {
console.log('timeout')
}, 0)
这个回调不会立刻执行,它会在未来某个时刻作为一个新的宏任务进入队列。
3. 微任务是什么
常见微任务包括:
Promise.then / catch / finallyqueueMicrotaskMutationObserverasync/await在await之后续上的代码,本质上也进入微任务队列
微任务的特点是:
- 不会立刻打断当前同步代码
- 但会在当前宏任务结束后立即执行
- 而且会一次性清空整个微任务队列
也就是说,微任务优先级高于“下一个宏任务”。
4. Event Loop 的核心流程
浏览器里的简化流程可以理解成这样:
- 取出一个宏任务执行
- 执行过程中,可能产生新的微任务
- 当前宏任务执行结束
- 清空所有微任务
- 浏览器有机会做一次渲染
- 再取下一个宏任务
我故意说“有机会渲染”,因为渲染不是每步都必然发生,但你可以先这么理解。
伪代码可以写成:
while (true) {
执行一个宏任务
while (微任务队列不为空) {
执行一个微任务
}
可能进行渲染
}
这段伪代码是理解一切输出顺序的关键。
5. 先看最经典的例子
console.log('start')
setTimeout(() => {
console.log('timeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise')
})
console.log('end')
结果通常是:
start
end
promise
timeout
执行过程拆解
第一步:整个 script 作为一个宏任务开始执行
执行同步代码:
console.log('start') // 输出 start
遇到:
setTimeout(...)
浏览器把它注册出去,等时间到了以后,把回调放进宏任务队列。现在不会执行。
遇到:
Promise.resolve().then(...)
then 回调会进入微任务队列。
继续:
console.log('end') // 输出 end
第二步:当前宏任务执行结束
这时开始清空微任务队列。
执行:
console.log('promise') // 输出 promise
第三步:进入下一轮 Event Loop
宏任务队列里已经有前面的 setTimeout 回调。
执行它:
console.log('timeout') // 输出 timeout
所以整体顺序是:
start
end
promise
timeout
6. 一个更容易踩坑的例子
setTimeout(() => {
console.log('t1')
Promise.resolve().then(() => {
console.log('p1')
})
console.log('t2')
}, 0)
输出:
t1
t2
p1
为什么不是 t1 p1 t2?
因为微任务不会“插队打断”当前同步代码。
这里 setTimeout 回调本身是一个宏任务。进入这个宏任务后:
- 输出
t1 - 注册一个微任务
p1 - 输出
t2 - 当前宏任务结束
- 清空微任务,输出
p1
关键点:
- 微任务优先于下一个宏任务
- 但不会打断当前正在执行的同步代码
7. 微任务会被“清空”,这点非常重要
看这个例子:
Promise.resolve().then(() => {
console.log('p1')
Promise.resolve().then(() => {
console.log('p2')
})
})
Promise.resolve().then(() => {
console.log('p3')
})
输出:
p1
p3
p2
为什么?
开始时微任务队列里有:
p1p3
执行 p1 时,又塞进去一个新的微任务 p2。
队列就变成:
p3p2
然后继续清空,所以顺序是:
p1p3p2
这说明一件事:微任务队列在当前轮次会一直执行,直到彻底为空。
8. 为什么说微任务可能阻塞渲染
因为浏览器通常会在:
- 一个宏任务结束后
- 且微任务全部清空后
才有机会渲染。
如果你一直往微任务里塞微任务,就可能让浏览器长期没机会渲染页面。
比如:
function loop() {
queueMicrotask(() => {
console.log('microtask')
loop()
})
}
loop()
这会不断制造新的微任务。理论上浏览器会一直忙着清空微任务,页面渲染、事件响应都可能被饿死。
所以:
- 微任务优先级高
- 但不能滥用
这也是为什么一些“让 UI 先更新,再做重活”的场景,不应该用微任务,而更适合用 setTimeout、requestAnimationFrame 或分片调度。
9. setTimeout(fn, 0) 为什么不是“立即执行”
很多人第一次学异步会误解这里。
setTimeout(() => {
console.log('timeout')
}, 0)
console.log('sync')
输出一定是:
sync
timeout
因为 setTimeout(..., 0) 的意思不是“马上执行”,而是:
- 至少等待 0ms
- 然后把回调放入宏任务队列
- 等当前宏任务结束、微任务清空、轮到它时才执行
它只是“尽快安排到下一轮宏任务”,不是同步插入。
另外真实浏览器里还有一些最小延迟、嵌套定时器限制,所以它连“精确 0ms”都不是。
10. Promise.then 为什么比 setTimeout 更早
setTimeout(() => console.log('timeout'), 0)
Promise.resolve().then(() => console.log('promise'))
因为:
setTimeout回调进入宏任务队列Promise.then回调进入微任务队列
而规则是:
- 当前宏任务结束后,先清空微任务
- 再执行下一个宏任务
所以 promise 总是先于 timeout。
11. async/await 和微任务的关系
async/await 本质上是 Promise 的语法糖。
看例子:
async function test() {
console.log('a')
await Promise.resolve()
console.log('b')
}
console.log('start')
test()
console.log('end')
输出:
start
a
end
b
为什么?
执行过程:
console.log('start')- 调用
test() - 进入
test,输出a - 遇到
await Promise.resolve()await后面的部分会被挂起- 后续的
console.log('b')会作为微任务继续执行
- 回到主流程,输出
end - 当前宏任务结束,清空微任务,输出
b
所以可以记住:
await不是阻塞整个线程- 它只是把函数后续逻辑拆到未来的微任务里继续执行
12. DOM 渲染和任务队列的关系
这是前端里特别实用的一点。
假设你写:
button.innerText = 'loading...'
heavyWork()
如果 heavyWork() 很重,页面上可能不会立刻看到 loading...,因为:
- DOM 改了
- 但浏览器还没机会渲染
- 主线程已经继续被
heavyWork()占住了
有时你会想“先让页面渲染,再做重活”,这时如果你写成微任务,往往不够:
button.innerText = 'loading...'
Promise.resolve().then(() => {
heavyWork()
})
这通常还是不行,因为微任务会在渲染前执行,浏览器仍然没机会把 loading... 画出来。
更合理的方式一般是:
button.innerText = 'loading...'
setTimeout(() => {
heavyWork()
}, 0)
或者:
button.innerText = 'loading...'
requestAnimationFrame(() => {
heavyWork()
})
这背后的关键就是:
- 微任务通常发生在渲染之前
- 宏任务切到下一轮时,浏览器才更有机会先渲染一帧
这个区别在写 loading、动画、长任务分片时很有用。
13. 一个综合例子
console.log(1)
setTimeout(() => {
console.log(2)
Promise.resolve().then(() => {
console.log(3)
})
}, 0)
Promise.resolve().then(() => {
console.log(4)
})
console.log(5)
输出:
1
5
4
2
3
拆解
初始 script 宏任务中:
- 输出
1 setTimeout注册一个未来宏任务Promise.then注册一个微任务4- 输出
5
当前宏任务结束,清空微任务:
- 输出
4
进入下一轮宏任务,执行 setTimeout:
- 输出
2 - 注册微任务
3
当前这个 setTimeout 宏任务结束,清空微任务:
- 输出
3
最终:
1
5
4
2
3
14. 再来一个更绕但很典型的面试题
console.log('script start')
setTimeout(() => {
console.log('setTimeout')
}, 0)
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
async1()
new Promise((resolve) => {
console.log('promise1')
resolve()
}).then(() => {
console.log('promise2')
})
console.log('script end')
输出通常是:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
解释重点
script是当前宏任务async1()先同步执行到awaitasync2()里同步输出async2await后续的async1 end进入微任务Promise.then的promise2也进入微任务- 微任务按进入顺序执行
- 最后才轮到
setTimeout
15. 宏任务和微任务的“优先级”到底怎么理解
很多人会说“微任务优先级更高”,这句话不算错,但容易让人误会。
更准确的说法是:
- 当前宏任务内的同步代码永远先执行
- 当前宏任务结束后,必须先清空微任务
- 然后才会执行下一个宏任务
所以微任务不是“随时更高优先级”,而是“在当前轮结束阶段拥有更高调度权”。
16. 浏览器和 Node.js 的差异
你问的是浏览器,我只点一下,不展开太多。
在 Node.js 里也有事件循环,但实现细节和浏览器不完全一样,尤其是:
- 不同 phase
process.nextTicksetImmediate
这些和浏览器里的宏任务 / 微任务模型有相似之处,但不能完全混着背。
所以如果你是在浏览器里写前端代码,先牢牢记住浏览器这套就够了。
17. 实际开发里最常见的几个结论
1)想“本轮同步代码执行完就立刻做”
用微任务:
Promise.resolve().then(fn)
// 或
queueMicrotask(fn)
适合:
- 补齐状态
- 延后一点点执行,但不想拖到下一轮
2)想“下一轮再做”
用宏任务:
setTimeout(fn, 0)
适合:
- 避免阻塞当前轮
- 给渲染留机会
- 拆分大任务
3)想“浏览器下一帧渲染前做”
用:
requestAnimationFrame(fn)
适合:
- 动画
- 读写布局
- 跟渲染节奏对齐
4)不要无限制造微任务
否则可能:
- 卡 UI
- 阻塞渲染
- 阻塞事件响应
18. 一张简单的脑图式记忆法
你可以这么背:
- script / setTimeout / click 回调 → 宏任务
- Promise.then / await 后续 / queueMicrotask → 微任务
执行顺序:
- 先跑当前宏任务里的同步代码
- 再清空所有微任务
- 再去下一个宏任务
- 渲染通常夹在两轮之间
19. 最后给你一个判断模板
以后看到题目,按这个顺序拆:
第一步:找同步代码
先把所有同步 console.log 按出现顺序写出来。
第二步:找当前宏任务里注册的微任务
比如:
Promise.thenawait后续
这些放到“本轮末尾执行”。
第三步:找宏任务
比如:
setTimeout- DOM 事件回调
这些放到“下一轮”。
第四步:注意嵌套
在某个宏任务里新注册的微任务,会在那个宏任务结束后立刻执行。
20. 一句话总结
宏任务决定“下一轮做什么”,微任务决定“这一轮结束前必须清空什么”。
如果你愿意,我下一条可以继续给你:
- 画一张 Event Loop 时序图
- 出 5 道题让你自己判断输出
- 结合 React 讲它和状态更新、渲染批处理的关系
你选一个。
结论:他没有写过这个话题。
以下是我的推测,不代表 batype 本人观点:
我直接画一张够用的图。先看最核心版本。
1. Event Loop 基本时序图
┌──────────────────────────────────────────────┐
│ 宏任务队列 Task Queue │
│ [ script ] [ setTimeout ] [ click ] ... │
└──────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Call Stack │
│ 执行当前任务 │
└─────────────────┘
│
▼
当前宏任务执行完毕? ──────── 否 ────────→ 继续执行同步代码
│
是
│
▼
┌──────────────────────────────────────────────┐
│ 微任务队列 Microtask Queue │
│ [ Promise.then ] [ queueMicrotask ] ... │
└──────────────────────────────────────────────┘
│
▼
清空所有微任务(一直清到空)
│
▼
┌─────────────────┐
│ 浏览器渲染? │
│ layout / paint │
└─────────────────┘
│
▼
回到宏任务队列,取下一个宏任务继续
这张图表达的就是一句话:
执行一个宏任务 → 清空所有微任务 → 浏览器有机会渲染 → 再执行下一个宏任务
2. 对应一段代码来看
console.log('start')
setTimeout(() => {
console.log('timeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise')
})
console.log('end')
3. 这段代码的时序图
时间向下
│
├─ 宏任务 #1:script 开始
│
│ console.log('start')
│ → 输出 start
│
│ setTimeout(...)
│ → 把 timeout 回调放到“未来的宏任务队列”
│
│ Promise.resolve().then(...)
│ → 把 promise 回调放入“微任务队列”
│
│ console.log('end')
│ → 输出 end
│
├─ 宏任务 #1:script 结束
│
├─ 开始清空微任务队列
│ └─ 执行 promise 回调
│ → 输出 promise
│
├─ 微任务清空完毕
│
├─ 浏览器有机会渲染
│
├─ 宏任务 #2:执行 setTimeout 回调
│ └─ 输出 timeout
│
└─ 结束
输出:
start
end
promise
timeout
4. 更像“泳道图”的版本
这个形式通常更直观。
时间 ↓
主线程(Call Stack) 宏任务队列 微任务队列
────────────────────────────────────────────────────────────
取出 script 执行 [script] []
│
├─ console.log('start')
│
├─ setTimeout(cb, 0) [timeout cb] []
│
├─ Promise.then(cb) [timeout cb] [promise cb]
│
├─ console.log('end')
│
└─ script 执行结束 [timeout cb] [promise cb]
开始清空微任务队列
执行 promise cb []
│
└─ 输出 promise
微任务清空完成
取出下一个宏任务 [] []
执行 timeout cb
│
└─ 输出 timeout
注意这里我把 script 也当成一个宏任务看,这是理解 Event Loop 最稳定的方式。
5. 再画一个“宏任务里再产生微任务”的图
代码:
setTimeout(() => {
console.log('t1')
Promise.resolve().then(() => {
console.log('p1')
})
console.log('t2')
}, 0)
时序图:
时间 ↓
宏任务队列 Call Stack 微任务队列
────────────────────────────────────────────────────────────
[ timeout cb ] 空 []
取出 timeout cb 执行
执行 timeout cb []
│
├─ console.log('t1')
│
├─ Promise.then(...)
│ [p1]
│
├─ console.log('t2')
│
└─ timeout cb 结束 [p1]
开始清空微任务队列
执行 p1 []
└─ console.log('p1')
输出:
t1
t2
p1
这说明:
微任务不会打断当前同步代码,只会在当前宏任务结束后立刻执行。
6. 再画一个“微任务套微任务”的图
代码:
Promise.resolve().then(() => {
console.log('p1')
Promise.resolve().then(() => {
console.log('p2')
})
})
Promise.resolve().then(() => {
console.log('p3')
})
时序图:
时间 ↓
当前宏任务执行结束后,微任务队列初始为:
[p1, p3]
1. 执行 p1
输出 p1
p1 内部再注册一个微任务 p2
此时队列变成:
[p3, p2]
2. 执行 p3
输出 p3
队列变成:
[p2]
3. 执行 p2
输出 p2
队列清空
输出:
p1
p3
p2
关键点:
清空微任务队列时,如果微任务内部又产生新的微任务,这些新微任务也会在本轮继续执行。
7. 把“渲染”也放进图里
浏览器不是执行完每一行 JS 就渲染一次。更接近这样:
一轮 Event Loop:
[1] 执行一个宏任务
│
▼
[2] 清空所有微任务
│
▼
[3] 如果需要,执行渲染
│
▼
[4] 进入下一轮
所以如果你写:
element.textContent = 'A'
Promise.resolve().then(() => {
element.textContent = 'B'
})
setTimeout(() => {
element.textContent = 'C'
}, 0)
大概率视觉上你会更接近看到:
- 当前宏任务里改成
A - 微任务里又马上改成
B - 浏览器渲染时可能直接显示
B - 下一轮宏任务再改成
C
也就是说:
A不一定有机会单独出现在屏幕上- 因为在渲染前,微任务已经把它改成
B
8. 一张“为什么微任务可能卡 UI”的图
宏任务结束
│
▼
清空微任务:
microtask 1
└─ 又塞一个 microtask
microtask 2
└─ 又塞一个 microtask
microtask 3
└─ 又塞一个 microtask
...
...
...
(始终清不完)
│
└─ 浏览器一直没机会渲染
这就是为什么滥用 Promise.then / queueMicrotask 可能让页面卡住。
9. 最后给你一张“记忆版极简图”
┌──────────────┐
│ 1. 执行宏任务 │
└──────┬───────┘
▼
┌──────────────┐
│ 2. 清空微任务 │
└──────┬───────┘
▼
┌──────────────┐
│ 3. 可能渲染 │
└──────┬───────┘
▼
┌──────────────┐
│ 4. 下一轮 │
└──────────────┘
你只要记住两句话基本就够用了:
Promise.then比setTimeout(..., 0)更早执行- 每个宏任务结束后,会把微任务一次性清空
如果你愿意,我下一条可以继续给你画一张更具体的:
async/await的 Event Loop 时序图- 或者 浏览器 Event Loop vs Node.js Event Loop 对比图