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

显示模式

登录
ARCHIVE DOCUMENTJS

微任务、宏任务与 Event Loop

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/100-微任务、宏任务与Event-Loop
本文目录10 个章节
  1. 前言:先限定“单线程”说的是什么
  2. 微任务与 task 的区别
  3. Event Loop 是什么
  4. 在浏览器中的表现
  5. 在 Node.js 中的表现
  6. async/await 到底做了什么
  7. 小结
  8. 图片来源与授权风险
  9. 原文归属
  10. 参考链接

微任务、宏任务与 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、消息和脚本等来源的优先级与选择由宿主决定。

对于浏览器,可以把一次常见循环抽象为:

  1. 选择一个 runnable task 并运行
  2. task 内的 JavaScript 按 run-to-completion 执行
  3. 执行 microtask checkpoint,清空当前 microtask 以及期间新增的 microtask
  4. 用户代理可能在 rendering opportunity 中更新渲染
  5. 选择下一个 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')

典型顺序是 syncfirstnested microtaskanother reactiontimer。这些是 reaction job 或 queueMicrotask(),在本次 checkpoint 中继续排队并清空;timer 属于另一个 task source。

浏览器中的术语表

层次浏览器示例说明
taskclassic script、timer、用户交互、消息由具体 task source 产生,用户代理选择可运行任务
microtaskPromise reaction、queueMicrotask()MutationObserver 通知在 checkpoint 中持续清空
rendering opportunityrequestAnimationFrame() 回调、更新渲染独立于普通 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 顶层常见顺序是 nextTickpromisemicrotask;在 ESM 顶层常见顺序是 promisemicrotasknextTick。不要把 process.nextTick() 叫成 Promise microtask;它是 Node 独立的优先队列。跨环境只需要排入微任务时,通常优先 queueMicrotask()

setImmediate()setTimeout()

Node.js timer 与 immediate 历史示意图

图片来源:原文 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》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。

参考链接

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS