宏任务和微任务的区别是什么
Category(分类): JavaScript Status: 已整理
“宏任务”和“微任务”是前端文章中常见的说法。更准确的规范术语是浏览器 HTML Standard 中的 task、task source、microtask 和 event loop,而 ECMAScript 还定义 Promise jobs。它们描述的是宿主调度抽象,不是操作系统的进程、线程或纤程切换。
1. 先区分同步与异步
同步代码按照当前调用栈执行,前一条语句没有完成时,后一条语句不会开始。长时间同步计算会阻塞同一个 JavaScript agent 的输入和其他任务:
console.log('first')
for (let index = 0; index < 1e8; index += 1) {
// 模拟长时间同步计算
}
console.log('second')
异步 API 通常会先注册回调或返回 Promise,稍后由宿主或 ECMAScript job 机制安排后续工作。异步不等于“不会阻塞”:回调真正开始执行后仍然会占用当前 JavaScript agent,回调中的长循环同样会阻塞页面或 Node.js 线程。
JavaScript 可以通过 Worker、Node.js worker_threads、子进程等方式创建额外的执行 agent,但这些能力来自宿主环境,不代表每一段 JavaScript 代码都自动并行。
2. task 与 microtask 不是进程和线程切换
原文中“进程切换肯定是宏任务”“线程切换是微任务”“微任务是纤程切换”等说法需要删除。task/microtask 与 OS 调度层没有这种一一对应关系:
| 概念 | 规范/宿主层含义 | 不是它 |
|---|---|---|
| task(常称宏任务) | event loop 从某个 task source 取出并运行的一段工作 | 不等于进程切换 |
| microtask(微任务) | 当前 event loop 的 microtask queue 中排空的短后续工作 | 不等于线程/纤程切换 |
| Promise job | ECMAScript 的 Promise continuation 抽象 | 不等于创建新线程 |
| Worker message | 接收方 agent 中的一次事件任务 | 不等于共享同一个调用栈 |
一个 agent 内的 microtask 通常仍在同一个 JavaScript 执行线程中运行。浏览器如何把 agent 映射到 OS 线程或进程,是实现细节;Worker 之间是否真正并行,也取决于宿主调度和硬件资源。
3. 为什么计时器通常被称为 task?
浏览器的 setTimeout 到期后,会把回调安排到计时器相关的 task source;Node.js 则由 libuv/event loop 组织。计时器的延迟是阈值,不是“到点立即执行”的实时保证:
const start = performance.now()
setTimeout(() => {
console.log('elapsed:', performance.now() - start)
}, 0)
// 当前同步代码仍然会先执行;长任务会推迟计时器回调。
for (let index = 0; index < 1e7; index += 1) {
// busy work
}
不能因为计时器由宿主管理,就断言它一定由另一个进程管理。浏览器和 Node.js 都可能在同一进程中完成计时器调度;文件系统、DNS 等部分 Node 工作才可能使用 libuv threadpool,网络 I/O 又有不同的非阻塞实现。
4. 事件不一定都是异步 task
来自用户输入、网络或浏览器调度的事件通常会在某个 task 中交付,但代码主动触发事件时可以同步执行:
const target = new EventTarget()
target.addEventListener('ready', () => {
console.log('listener')
})
console.log('before')
target.dispatchEvent(new Event('ready'))
console.log('after')
// before、listener、after
Node.js 的 EventEmitter.emit() 也会同步调用监听器。不要把所有“事件”都解释成从另一个进程切换回 JavaScript 的宏任务。
5. async/await 与 Promise job
async function 本身仍然是函数,调用它会立即返回 Promise。第一次 await 之前的代码可以同步执行;遇到 await 后,表达式会被 Promise 规范化,async 函数的后续部分会在 Promise reaction job 中恢复:
async function demo() {
console.log('before await')
const value = await 42
console.log('after await', value)
return value
}
console.log('call')
void demo().then((value) => console.log('fulfilled', value))
console.log('after call')
常见输出顺序是:
call
before await
after call
after await 42
fulfilled 42
await 42 不应被解释成“用户可观察地创建了一个新的 Promise、切换了纤程或线程”。更准确的描述是:它通过 PromiseResolve 和后续 reaction job 安排 async 函数的恢复。
6. microtask 能否访问外层上下文?
microtask 和 task 都可以通过闭包访问创建回调时的词法环境。是否能访问变量,不由“宏/微”标签决定:
let value = 1
Promise.resolve().then(() => {
console.log('microtask:', value)
})
setTimeout(() => {
console.log('task:', value)
}, 0)
value = 2
两个回调通常都能读取到 2。如果回调跨越 Worker 或其他 realm,则要遵守消息传递、结构化克隆、Transferable 或 SharedArrayBuffer 的边界;这不是 task/microtask 本身造成的。
7. MutationObserver 与渲染不是同一类工作
MutationObserver 通知由 DOM 标准安排为可合并的 mutation-observer microtask:
const node = document.createElement('div')
const observer = new MutationObserver((records) => {
console.log('mutations:', records.length)
})
observer.observe(node, { childList: true })
node.append('a')
node.append('b')
多个同步修改可能在一次通知中合并。不能把所有 Observer 都归为同一模型,也不能说“渲染是微任务”:布局、绘制和 requestAnimationFrame 属于浏览器的 rendering/update-the-rendering 流程,受渲染机会、刷新率、可见性和节流影响。
浏览器的概念时间线更接近:
task
→ microtask checkpoint(排空 Promise/queueMicrotask 等)
→ 可能的渲染更新和 requestAnimationFrame
→ 后续 task
浏览器不保证每个 task 后都绘制,也不保证 requestAnimationFrame 与计时器的相对顺序固定。
8. 为什么微任务通常先于后续 task?
当前同步 task 结束后,event loop 通常进行 microtask checkpoint,并持续执行直到队列为空:
console.log('sync')
queueMicrotask(() => {
console.log('microtask 1')
queueMicrotask(() => console.log('microtask 2'))
})
setTimeout(() => console.log('timer task'), 0)
常见浏览器结果是 sync、microtask 1、microtask 2、timer task。这并不意味着 microtask 可以抢占当前同步代码,也不意味着所有已经存在的 task 都按同一全局 FIFO 重新排序。不同 task source 的选择由宿主决定。
Node.js 还要把 process.nextTick 与 Promise/queueMicrotask 分开:在 CommonJS 中 nextTick 通常先于 V8 microtask;ESM 顶层的执行本身已经处在 Promise job 中,观察到的顺序可能不同。
9. 浏览器与 Node.js 的边界
浏览器
脚本/输入/计时器等 task
→ microtask checkpoint
→ 可能渲染
→ 下一个 task
Node.js
当前回调返回
→ process.nextTick queue
→ V8 Promise/queueMicrotask queue
→ libuv 的后续阶段
Node.js 常见 libuv 阶段包括 timers、pending callbacks、poll、check、close callbacks。文件系统和部分 DNS 操作可能使用 threadpool,网络 I/O 通常由 event loop 与操作系统非阻塞机制处理。Node 20/libuv 1.45 之后 timers 与 poll 的安排也有变化,不能把一张旧版浏览器图当作所有 Node 版本的模型。
10. 选择 task 还是 microtask?
- 需要在当前同步工作结束后进行很短的状态收尾:可以考虑
queueMicrotask或 Promise continuation; - 需要等待浏览器让出当前轮次、避免连续占用调用栈:可以考虑计时器、
MessageChannel、Scheduler API 或拆分任务; - 需要在下一次绘制前更新视觉数据:考虑
requestAnimationFrame; - 需要大量 CPU 计算:优先拆分任务或放到 Worker,而不是制造无限 microtask;
- Node.js 中需要快速安排回调时,要明确区分
process.nextTick、queueMicrotask和setImmediate的语义及版本差异。
小结
- task/microtask 是宿主和 ECMAScript 的调度抽象,不是进程、线程或纤程切换。
- 计时器和外部事件通常通过 task 交付,但主动
dispatchEvent/EventEmitter.emit可以同步执行。 - async/await 是 Promise continuation,不是迭代器加纤程的规范实现。
- MutationObserver 可以使用 microtask;布局、绘制和 rAF 属于渲染更新流程。
- task 与 microtask 都能访问闭包;真正的跨 agent 隔离来自 Worker/realm 和消息传递边界。
- 浏览器和 Node.js 的 event loop 需要分开学习。
参考资料
- WHATWG HTML:Event loops
- WHATWG HTML:Event loop processing model
- WHATWG HTML:Timers
- WHATWG HTML:Update the rendering
- DOM Standard:Mutation observers
- ECMAScript:Await
- ECMAScript:NewPromiseReactionJob
- Node.js:Event loop、timers 和 nextTick
- Node.js:queueMicrotask 与 process.nextTick
- Node.js:Events
- libuv:Design overview
作者:甜酸混合物1226 原文链接:https://juejin.cn/post/6880787856353132552 来源:稀土掘金。