微任务、宏任务与 Event Loop
Category(分类): JavaScript Status: 已整理
前言:先限定“单线程”说的是什么
在一个 JavaScript agent 中,执行上下文按 run-to-completion 运行:同一时刻不会有两个 JavaScript 回调交错执行。浏览器可以同时拥有 Window agent 和多个 Worker agent,Node.js 也可以通过 worker_threads 创建独立线程;因此“JavaScript 在全世界只有一条线程”不是准确的语言结论。
如果主 agent 正在执行大量计算,后续代码和页面交互确实会等待。例如下面的 alert() 会阻塞当前页面的交互,直到用户关闭对话框:
alert('请先关闭弹框')
console.log('这行要等弹框关闭后才执行')
这说明的是当前 agent 的同步代码会阻塞它自己的执行,不是说宿主不能创建其他 agent。
异步 API 的意义是:把工作交给宿主或底层系统,在结果可用时安排回调;当前 JavaScript 回调返回后,主 agent 才有机会执行其他任务。异步完成也不代表回调立刻运行,它还要等待宿主选择相应 task,并等待当前同步代码和必要的 microtask 处理结束。

图片来源:原文配图,已本地化。原图来自第三方字节图床,原始转载授权未核实;图片只能作为历史教学示意,不能代替 WHATWG 的处理模型。
微任务与 task 的区别
银行类比
可以把一次 task 想成银行柜员正在办理的一位客户。柜员处理完当前业务后,才会接待下一位客户。办理当前业务时如果产生了同一位客户必须立即完成的补充事项,可以把它类比为 microtask:在柜员接待下一位前处理完。
但类比不能替代规范。浏览器不是只有一个“所有客户按取号时间排队”的全局 task queue。HTML 中存在 task source,用户代理从可运行的队列中选择 task;用户交互、timer、消息和脚本等来源的优先级与选择由宿主决定。
对于浏览器,可以把一次常见循环抽象为:
- 选择一个 runnable task 并运行
- task 内的 JavaScript 按 run-to-completion 执行
- 执行 microtask checkpoint,清空当前 microtask 以及期间新增的 microtask
- 用户代理可能在 rendering opportunity 中更新渲染
- 选择下一个 task
所以“当前 microtask 没完成时不会进入下一个 task”可以作为浏览器 task 边界的入门表述;不要把它扩大为所有宿主都共享同一套队列,更不要说 microtask 是 task 的一个普通回调。
Promise executor 是同步的
下面的代码在浏览器 classic script 或常见 Node 入口中通常输出 1、2、3、4:
setTimeout(() => console.log(4), 0)
new Promise((resolve) => {
resolve()
console.log(1)
}).then(() => {
console.log(3)
})
console.log(2)
new Promise() 会同步调用 executor,所以先打印 1;.then() 注册的是 Promise reaction job,不会在当前调用栈中同步执行;当前 script 继续打印 2。浏览器在 task 结束后执行 microtask checkpoint,打印 3,之后才有机会运行 timer task,打印 4。
1
2
3
4
microtask 会清空到耗尽
setTimeout(() => console.log('timer'), 0)
Promise.resolve().then(() => {
console.log('first')
queueMicrotask(() => {
console.log('nested microtask')
})
Promise.resolve().then(() => {
console.log('another reaction')
})
})
console.log('sync')
典型顺序是 sync、first、nested microtask、another reaction、timer。这些是 reaction job 或 queueMicrotask(),在本次 checkpoint 中继续排队并清空;timer 属于另一个 task source。
浏览器中的术语表
| 层次 | 浏览器示例 | 说明 |
|---|---|---|
| task | classic script、timer、用户交互、消息 | 由具体 task source 产生,用户代理选择可运行任务 |
| microtask | Promise reaction、queueMicrotask()、MutationObserver 通知 | 在 checkpoint 中持续清空 |
| rendering opportunity | requestAnimationFrame() 回调、更新渲染 | 独立于普通 timer task,由用户代理按刷新率和可见性调度 |
requestAnimationFrame() 不应被勉强称为宏任务。DOM 属性修改可能使样式或布局失效,并可能在后续渲染更新中反映,但它不是“直接触发一个微任务”,也不保证每次修改都会立即绘制。不能从“某次修改后注册了 rAF”推出 rAF 一定早于某个 setTimeout。
Event Loop 是什么
Event Loop 是宿主不断选择并运行任务的过程;真正的业务逻辑仍由 JavaScript 引擎执行。下面的伪代码只演示“task 结束后 drain microtask”的思想,不是浏览器规范中的数据结构,也不是每个 task 各自拥有一个 microtask queue:
const conceptualTasks = [
() => console.log('task 1'),
() => console.log('task 2'),
]
const conceptualMicrotasks = []
for (const task of conceptualTasks) {
task()
while (conceptualMicrotasks.length > 0) {
const microtask = conceptualMicrotasks.shift()
microtask()
}
}
现实中的 task source、agent、microtask queue 和渲染机会由宿主管理。这个简化模型只适合帮助理解“microtask 要清空后才有下一个 task”。
在浏览器中的表现
事件传播是同步的,但完整输出不要写成“一定”
假设页面中有内外两个元素:
<style>
#outer {
padding: 20px;
background: #616161;
}
#inner {
width: 100px;
height: 100px;
background: #757575;
}
</style>
<div id="outer">
<div id="inner"></div>
</div>
const inner = document.querySelector('#inner')
const outer = document.querySelector('#outer')
function handler() {
console.log('click')
Promise.resolve().then(() => console.log('promise'))
setTimeout(() => console.log('timeout'), 0)
requestAnimationFrame(() => console.log('animationFrame'))
outer.setAttribute('data-random', String(Math.random()))
}
new MutationObserver((records) => {
console.log('observer', records.length)
}).observe(outer, { attributes: true })
inner.addEventListener('click', handler)
outer.addEventListener('click', handler)
真实用户点击 #inner 时,事件路径上的 listener 调用和冒泡关系是同步的;浏览器在宿主 callback 的清理边界执行 microtask 的具体时机,还会受到当前调用栈和事件派发方式影响。Promise reaction 和 MutationObserver 通知可以在事件 callback 结束后的 checkpoint 中运行。
因此可以保留“真实 click 与脚本触发 .click() 的观察差异”这个教学点,但不要给出一条对所有浏览器、版本和刷新状态都成立的完整序列。尤其是 requestAnimationFrame 与 timer 的相对顺序由 rendering opportunity 和 timer task 的选择共同决定,不能断言 rAF 一定先于 timer。需要展示固定输出时,应注明具体浏览器、版本和测试环境。
setAttribute() 可能使渲染失效,但不等于立刻绘制;requestAnimationFrame() 表示下一次合适渲染机会附近的回调。
.click() 与手动点击
下面两句都会同步触发相应的事件派发代码;它们和用户输入 task 的宿主入口不同:
document.body.addEventListener('click', () => console.log('click'))
document.body.click()
document.body.dispatchEvent(new Event('click'))
console.log('done')
这段同步脚本通常先打印两次 click,再打印 done。但 listener 中排入的 microtask 何时检查,取决于当前 callback cleanup 和调用栈边界。不要把手动 .click() 与真实用户输入简单视为同一种 task。
MutationObserver 是按 checkpoint 批量通知
同一个 observer 在一次同步修改批次中通常只得到一次通知,但 records 可能包含多条记录;observer 回调内再次修改 DOM、或者后续 task 再修改,都可能在新的 checkpoint 产生新的通知:
const observer = new MutationObserver((records) => {
console.log('observer records:', records.length)
})
observer.observe(document.body, { attributes: true })
document.body.setAttribute('data-one', '1')
document.body.setAttribute('data-two', '2')
document.body.setAttribute('data-three', '3')
这不是“以后永远只回调一次”,而是同一批 mutation records 会合并到一次通知中。
在 Node.js 中的表现
Node.js 的 JavaScript 回调在一个线程中按顺序执行,但 Node 的调度不是 HTML event loop。它有 libuv phases、Node 的 next-tick queue 和 V8 microtask queue。
process.nextTick() 与 queueMicrotask()
Promise.resolve().then(() => console.log('promise'))
queueMicrotask(() => console.log('microtask'))
process.nextTick(() => console.log('nextTick'))
在 CommonJS 顶层常见顺序是 nextTick、promise、microtask;在 ESM 顶层常见顺序是 promise、microtask、nextTick。不要把 process.nextTick() 叫成 Promise microtask;它是 Node 独立的优先队列。跨环境只需要排入微任务时,通常优先 queueMicrotask()。
setImmediate() 与 setTimeout()

图片来源:原文 Node.js 调度配图,已本地化。原图来自第三方字节图床,授权风险未核实;当前语义以 Node/libuv 官方文档为准。
主模块中同时注册两者,先后顺序不保证:
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
setTimeout(0) 是达到最小延迟阈值后才有资格执行,setImmediate() 在 check 阶段处理。Node 20 使用的 libuv 1.45.0 以后,正常循环迭代中 timers 通常在 poll 之后,旧的固定六阶段顺序只能作为近似图。
在同一个 I/O 回调中,setImmediate() 通常先于同处注册的 timer。下面是可运行的 CommonJS 示例:
const fs = require('node:fs')
fs.readFile(__filename, (error) => {
if (error) throw error
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
})
原文中用忙等循环让 timer 先到期的做法依赖机器速度并阻塞事件循环,不适合用作一般规则。
用 nextTick 延迟 EventEmitter 事件
构造函数同步发事件时,外部 listener 可能还没有注册:
const { EventEmitter } = require('node:events')
class Lib extends EventEmitter {
constructor() {
super()
this.emit('init')
}
}
const lib = new Lib()
lib.on('init', () => console.log('init'))
// 不会打印:事件已经同步发出
可以用 process.nextTick() 或在很多跨环境场景使用 queueMicrotask(),把事件推迟到当前构造调用返回之后:
const { EventEmitter } = require('node:events')
class DeferredLib extends EventEmitter {
constructor() {
super()
process.nextTick(() => this.emit('init'))
}
}
const lib = new DeferredLib()
lib.on('init', () => console.log('init'))
递归 nextTick 可能让后续 timer 和 I/O 长时间得不到机会。需要处理大量数据时,应分批并在批次之间主动让出事件循环,而不是依赖一个历史上的固定深度限制。
async/await 到底做了什么
async/await 不是“仅仅是生成器语法糖”。它是 ECMAScript 定义的 async function 和 Await 抽象操作:调用 async function 会返回 Promise;函数会同步执行到第一个需要暂停的 await,await 右侧表达式会先被求值,恢复部分通过 Promise reaction jobs 异步运行。
下面是一个当前常见的 classic script 示例:
setTimeout(() => console.log(4), 0)
async function main() {
console.log(1)
await Promise.resolve()
console.log(3)
}
main()
console.log(2)
通常输出 1、2、3、4。可以用 Promise 链帮助理解,但不能把 await 机械地逐字改写为 Promise.resolve(value).then(...):thenable 吸收、异常、返回值和 job 数量都由规范的 Await 语义决定;生成器只是某些转译器可能采用的实现策略。
小结
- 一个 JavaScript agent 内的回调按 run-to-completion 执行,但 Worker 和 Node worker thread 可以拥有独立 agent 并行工作。
- 浏览器使用 task source、microtask checkpoint 和 rendering opportunity;不是一个单一宏任务队列。
- Promise executor 同步,Promise reaction 和
queueMicrotask()异步排入 microtask;MutationObserver 在 checkpoint 中批量通知。 - rAF 不属于普通 timer task,不能写成一定先于 timer;绘制由用户代理调度。
- Node 需要分开理解 next-tick queue、V8 microtask queue 和 libuv phases,并注意 CommonJS/ESM 差异。
- Node 20/libuv 1.45.0 改变了正常迭代的 timers 位置;主模块的 timer/immediate 顺序不保证,I/O 回调中 immediate 通常先。
图片来源与授权风险
images/100-image-01.webp:原文事件循环概念图,来源为第三方字节图床,已本地化;图片转载授权未核实。images/100-image-02.webp:原文 Node.js timer/immediate 示意图,来源为第三方字节图床,已本地化;图片转载授权未核实。
本地化只解决热链稳定性,不代表取得原作者、图床或图中素材的再发布许可。公开发布前应确认授权,或替换为自行绘制的图。
原文归属
作者:Jiasm。历史来源:掘金原文《微任务、宏任务与 Event-Loop》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。
参考链接
- WHATWG HTML:event loop processing model
- WHATWG HTML:perform a microtask checkpoint
- WHATWG HTML:update the rendering
- WHATWG HTML:requestAnimationFrame
- WHATWG DOM:mutation observers
- ECMAScript:Promise reaction jobs
- ECMAScript:Await
- MDN:microtask guide
- Node.js:event loop、timers 和 nextTick
- Node.js:queueMicrotask 与 process.nextTick
- Node.js:setImmediate
- Node.js:worker_threads
- libuv:design overview