宏任务和微任务
Category(分类): JavaScript Status: 已整理
“宏任务”和“微任务”是社区常用说法。WHATWG HTML Standard 更常使用 task、task source、microtask 和 event loop;ECMAScript 还定义 Promise reaction job,并通过宿主钩子接入具体运行时。理解这些边界,可以避免把浏览器、V8 和 Node.js 的实现细节混成一套规则。
先建立三层模型
ECMAScript:同步执行与 Promise job
普通同步 JavaScript 在当前调用栈中执行,函数不会被另一个 JavaScript 任务从中间抢占。Promise 的 then/catch/finally 回调属于 Promise reaction job;await 恢复 async 函数时也会使用 Promise 相关 job。
ECMAScript 描述的是抽象的 job 和 HostEnqueuePromiseJob,没有直接规定浏览器必须叫它“微任务队列”,也不规定 Node.js 的 process.nextTick。
浏览器:task、microtask checkpoint 与渲染机会
浏览器 event loop 通常可以用下面的教学时间线理解:
task(脚本、计时器、用户输入、网络事件等)
↓
microtask checkpoint:排空当前 event loop 的 microtask queue
↓
可能发生 rendering opportunity / update-the-rendering / requestAnimationFrame
↓
选择下一个可运行的 task
这是帮助理解的顺序,不是“每个 task 后必然绘制”的硬保证。浏览器可以根据刷新率、页面可见性、资源和调度策略跳过或推迟渲染。不同 task source 之间也不是一个全局严格 FIFO 队列。
Node.js:libuv 阶段与 V8 microtask
Node.js 没有浏览器渲染阶段,通常还要区分:
process.nextTickqueue;- V8 的 Promise/
queueMicrotaskmicrotask queue; - libuv 的 timers、pending callbacks、poll、check、close callbacks 等阶段。
Node 的具体顺序受 CommonJS/ESM、Node 和 libuv 版本影响。不要把浏览器的“宏任务队列”直接套到 Node。
什么是 task(常说的宏任务)?
页面中的脚本、用户输入、计时器回调、网络完成回调等,通常会以 task 的形式被宿主排入某个 task source。task 执行时,当前 JavaScript 代码会连续运行到完成;长 task 会阻塞同一 agent 的输入和渲染。
HTML 标准不是把所有消息放进一个简单的 FIFO 队列。浏览器可以有多个 task source,宿主选择一个可运行的 task queue,再执行其中符合顺序的任务。通常可以认为同一来源的任务保持顺序,但不同来源之间不应推导出全局“谁先入队谁先执行”。
常见的 task 来源包括:
- parser 执行脚本;
- 用户交互事件;
setTimeout/setInterval到期后的回调;- 网络、文件或其他宿主 API 完成后的回调;
- Worker 消息到达后的事件。
“渲染事件都是宏任务”是过度简化。布局、绘制和 update-the-rendering 有独立的浏览器渲染流程,不应简单和普通计时器 task 划等号。
计时器不是精确时钟
<!doctype html>
<html lang="zh-CN">
<body>
<script>
function timerCallback2() {
console.log(2)
}
function timerCallback() {
console.log(1)
setTimeout(timerCallback2, 0)
}
setTimeout(timerCallback, 0)
</script>
</body>
</html>
两个回调通常会先后执行,但第二个回调的实际时间无法精确预测:计时器延迟只是一个不早于约定值的阈值,HTML parser、用户输入、渲染、其他脚本和浏览器节流都可能影响调度。浏览器还会对嵌套计时器和后台页面施加最小延迟或节流规则。
下面的 Performance 截图只能作为一次浏览器运行记录,不能证明所有浏览器都一定在两个计时器之间插入相同的任务:

如果任务需要在下一次绘制前更新视觉状态,可考虑 requestAnimationFrame;如果只需要把工作推迟到当前 task 结束后,才考虑 microtask 或计时器。选择 API 应由目标语义决定,而不是只看“宏/微”标签。
什么是 microtask(常说的微任务)?
microtask 是当前 event loop 中的一类短后续工作。常见来源包括:
queueMicrotask(callback);- Promise reaction,例如
Promise.resolve().then(callback); await的后续恢复;MutationObserver通知。
有几个容易混淆的点:
Promise.resolve()本身只是返回一个 fulfilled Promise,不会凭空执行用户回调;通常是.then()注册 reaction 后才排入 Promise job;Promise.reject()创建 rejected Promise,没有 handler 时会进入宿主的 rejection tracking,但不会因此自动执行用户回调;- 读取 thenable、
await和 Promise reaction 可能各自排入不同的 Promise job; - MutationObserver 通知可以合并多个同步 DOM 修改,不是每次修改都一定产生一个独立回调。
一个浏览器实验:
console.log('sync')
queueMicrotask(() => console.log('queueMicrotask'))
Promise.resolve().then(() => console.log('promise reaction'))
const target = document.createElement('div')
const observer = new MutationObserver(() => {
console.log('mutation observer')
})
observer.observe(target, { childList: true })
target.append('child')
setTimeout(() => console.log('timer task'), 0)
requestAnimationFrame(() => console.log('animation frame'))
console.log('sync end')
在常见浏览器中,sync 和 sync end 先同步输出,随后同一 checkpoint 中的 microtask 会被排空,计时器和渲染回调在后续宿主阶段执行。但 requestAnimationFrame 与计时器的相对顺序受浏览器和当前帧调度影响,不能写成跨环境绝对顺序。
microtask checkpoint
HTML 标准定义的是每个 event loop 的 microtask queue,而不是“每个宏任务拥有一个私有微任务队列”。一个 task 的脚本执行结束后,宿主通常进行 microtask checkpoint,并持续取出 microtask,直到队列为空:
queueMicrotask(() => {
console.log('microtask 1')
queueMicrotask(() => console.log('microtask 2'))
})
setTimeout(() => console.log('later task'), 0)
microtask 2 会在同一次 checkpoint 中继续执行,通常先于后续的 later task。microtask 不会抢占当前正在运行的同步代码;只有当前 task 的同步部分结束后,checkpoint 才有机会开始。


微任务为什么可能造成页面卡顿?
microtask checkpoint 会尽量排空队列。如果 microtask 不断生产新的 microtask,后续 task、用户输入和可能的渲染都会被推迟,这叫 event-loop starvation:
let count = 0
function keepRunning() {
count += 1
if (count < 1000) {
queueMicrotask(keepRunning)
}
}
queueMicrotask(keepRunning)
微任务适合短小的状态收尾、Promise continuation 和同一轮次的依赖更新,不适合把大量 CPU 计算拆成无限 microtask。需要主动让出当前轮次时,可以分批处理并使用计时器、MessageChannel、scheduler 能力或 Worker;具体方案要按目标浏览器和任务类型测试。
浏览器中的典型顺序
可以用下面的概念顺序记忆:
同步脚本
→ 当前 task 结束
→ microtask checkpoint(排空至空)
→ 可能的 rendering opportunity / rAF
→ 后续 task(timer、输入、网络、消息……)
注意:
- 浏览器不保证每个 task 之后都立即绘制;
- task source 的跨来源选择不保证全局 FIFO;
requestAnimationFrame属于渲染更新相关流程,不是 microtask;- Worker 有自己的 agent/event loop,不会直接共用页面的 DOM 或微任务队列;
- 无限同步脚本和无限 microtask 都可以阻塞页面,只是阻塞发生的位置不同。
Node.js 中的差异
Node.js 示例:
console.log('start')
process.nextTick(() => console.log('nextTick'))
queueMicrotask(() => console.log('queueMicrotask'))
Promise.resolve().then(() => console.log('promise'))
setImmediate(() => console.log('setImmediate'))
setTimeout(() => console.log('timeout'), 0)
在 CommonJS 中,process.nextTick 通常会先于 Promise/queueMicrotask;在 ESM 顶层,模块本身通过 Promise job 执行,观察到的相对顺序可能不同。顶层 setTimeout(0) 与 setImmediate() 也不应断言固定顺序;在 I/O callback 中,setImmediate() 通常更早,但仍应以目标 Node 版本和具体场景验证。
Node 20 使用的 libuv 版本调整了 timers 与 poll 的阶段安排。Node 的 timer 仍是阈值,不是实时硬时钟;文件系统和部分 DNS 工作可能使用 libuv threadpool,网络 I/O 通常由操作系统非阻塞机制和 event loop 处理。浏览器的渲染机会在 Node 中不存在。
小结
- “宏任务”是社区俗称,规范讨论优先使用 task/task source;“微任务”对应 event loop 的 microtask queue。
- Promise reaction 和
queueMicrotask通常在当前 task 结束后的 checkpoint 中运行;Promise.resolve()本身不等于执行一个回调。 - microtask 会排空到队列为空,持续生成 microtask 可能阻塞后续 task、输入和渲染。
- 渲染和
requestAnimationFrame不应简单归为普通宏任务或微任务。 - 浏览器与 Node.js 的 event loop、
process.nextTick、libuv 阶段和渲染模型不同。
参考资料
- WHATWG HTML:Event loops
- WHATWG HTML:Event loop processing model
- WHATWG HTML:Microtask queuing
- WHATWG HTML:Update the rendering
- DOM Standard:Mutation observers
- ECMAScript:NewPromiseReactionJob
- Node.js:Event loop、timers 和
nextTick - Node.js:
queueMicrotask与process.nextTick - libuv:Design overview
- 原文:宏任务和微任务(历史来源)