技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

宏任务和微任务

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/95-宏任务和微任务
本文目录10 个章节
  1. 先建立三层模型
  2. 什么是 task(常说的宏任务)?
  3. 计时器不是精确时钟
  4. 什么是 microtask(常说的微任务)?
  5. microtask checkpoint
  6. 微任务为什么可能造成页面卡顿?
  7. 浏览器中的典型顺序
  8. Node.js 中的差异
  9. 小结
  10. 参考资料

宏任务和微任务

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.nextTick queue;
  • V8 的 Promise/queueMicrotask microtask 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 截图只能作为一次浏览器运行记录,不能证明所有浏览器都一定在两个计时器之间插入相同的任务:

原文配图:计时器任务的 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')

在常见浏览器中,syncsync 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。需要主动让出当前轮次时,可以分批处理并使用计时器、MessageChannelscheduler 能力或 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 阶段和渲染模型不同。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS