Skip to content

事件循环

JavaScript 单线程模型

为什么是单线程?

JavaScript 从诞生之初就被设计为单线程语言。1995 年 Brendan Eich 在设计 JavaScript 时,它的定位是浏览器中的脚本语言,主要用于操作 DOM、处理用户交互。如果 JavaScript 是多线程的,那么两个线程同时操作同一个 DOM 节点(一个删除、一个修改),浏览器将无法判断以哪个为准,产生竞态条件(Race Condition)。

单线程的核心含义是:JavaScript 引擎中负责解释和执行代码的线程只有一个——主线程(Main Thread)。所有的 JavaScript 代码都在这个主线程上按顺序执行。

但"单线程"不意味着浏览器只有一个线程。浏览器是多进程多线程的架构:

┌─────────────────────────────────────────────────────┐
│                   浏览器进程架构                       │
├─────────────────────────────────────────────────────┤
│  渲染进程(每个 Tab 一个)                              │
│  ├── JS 引擎线程(主线程,执行 JS 代码)                 │
│  ├── GUI 渲染线程(与 JS 线程互斥)                     │
│  ├── 事件触发线程(管理事件循环和事件队列)               │
│  ├── 定时器触发线程(setTimeout / setInterval)         │
│  ├── 异步 HTTP 请求线程(XMLHttpRequest / fetch)      │
│  └── ...                                            │
├─────────────────────────────────────────────────────┤
│  GPU 进程、网络进程、浏览器主进程 ...                    │
└─────────────────────────────────────────────────────┘

JS 引擎线程与 GUI 渲染线程互斥:当 JS 代码执行时,GUI 渲染被挂起;当 GUI 渲染时,JS 引擎暂停。这就是为什么长时间运行的 JS 代码会导致页面卡顿——渲染线程被阻塞了。

js
const start = Date.now()
while (Date.now() - start < 3000) {}
console.log('done')

上面的代码会让页面完全冻结 3 秒,期间无法响应任何用户交互,因为主线程被同步代码完全占据。

单线程如何处理异步?

单线程不代表只能同步执行。JavaScript 通过**事件循环(Event Loop)**机制实现异步非阻塞。核心思想是:将耗时操作委托给其他线程(定时器线程、网络线程等),当操作完成后,将回调函数放入任务队列,等待主线程空闲时执行。

js
console.log('1')

setTimeout(() => {
  console.log('2')
}, 0)

console.log('3')

输出顺序:132setTimeout 的回调被放入任务队列,等到同步代码执行完毕后才会被取出执行。


调用栈(Call Stack)

工作原理

调用栈是一种 LIFO(后进先出) 的数据结构,用于记录程序当前的执行位置。每当执行一个函数时,就会在栈顶压入(push)一个新的栈帧(Stack Frame);当函数执行完毕返回时,栈帧被弹出(pop)。

js
function multiply(a, b) {
  return a * b
}

function square(n) {
  return multiply(n, n)
}

function printSquare(n) {
  const result = square(n)
  console.log(result)
}

printSquare(4)

调用栈的变化过程:

步骤 1: [printSquare(4)]
步骤 2: [printSquare(4), square(4)]
步骤 3: [printSquare(4), square(4), multiply(4, 4)]
步骤 4: [printSquare(4), square(4)]           ← multiply 返回 16,弹出
步骤 5: [printSquare(4)]                      ← square 返回 16,弹出
步骤 6: [printSquare(4), console.log(16)]
步骤 7: [printSquare(4)]                      ← console.log 弹出
步骤 8: []                                    ← printSquare 弹出

栈溢出(Stack Overflow)

调用栈的大小是有限的(不同浏览器不同,通常在 10,000 ~ 25,000 帧左右)。当递归没有终止条件时,调用栈会被撑满,抛出 RangeError: Maximum call stack size exceeded

js
function recursive() {
  recursive()
}
recursive()

异步回调与调用栈

理解调用栈是理解事件循环的关键。事件循环的核心规则之一就是:只有当调用栈清空时,才会从任务队列中取出下一个任务执行

js
function main() {
  console.log('start')

  setTimeout(() => {
    console.log('timeout')
  }, 0)

  console.log('end')
}

main()
1. main() 入栈
2. console.log('start') 入栈 → 执行 → 弹出 → 输出 "start"
3. setTimeout() 入栈 → 将回调注册到定时器线程 → 弹出
4. console.log('end') 入栈 → 执行 → 弹出 → 输出 "end"
5. main() 弹出,调用栈清空
6. 事件循环检查任务队列,取出 timeout 回调
7. 回调入栈 → console.log('timeout') → 输出 "timeout"

任务队列模型

事件循环中有两种任务队列:宏任务队列(Macrotask Queue / Task Queue)微任务队列(Microtask Queue)。它们的执行优先级不同,这是理解事件循环的核心。

宏任务队列(Macrotask Queue)

宏任务队列实际上并不是一个单一队列,而是多个**任务源(Task Source)**各自对应的队列集合。浏览器会根据优先级从这些队列中选取一个任务执行。

每一轮事件循环只从宏任务队列中取出一个任务执行。

微任务队列(Microtask Queue)

微任务队列是一个单独的队列。在每一个宏任务执行完毕后(更精确地说,是在当前调用栈清空后),会清空整个微任务队列中的所有任务,然后才会进行渲染和下一轮宏任务。

如果在执行微任务的过程中又产生了新的微任务,这些新的微任务也会在当前轮次中全部执行完毕。这意味着微任务可能会"饿死"渲染和宏任务:

js
function infiniteMicrotask() {
  Promise.resolve().then(() => infiniteMicrotask())
}
infiniteMicrotask()

上面的代码会导致页面完全冻结,因为微任务队列永远不会清空,渲染和宏任务永远得不到执行机会。

两者的关键区别

特性宏任务微任务
每轮执行数量一个全部清空
执行优先级高(在宏任务之后、渲染之前)
新增任务处理放到下一轮执行在当前轮次中继续执行
常见来源setTimeout, setInterval, I/OPromise.then, MutationObserver, queueMicrotask
js
console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

Promise.resolve().then(() => {
  console.log('promise1')
}).then(() => {
  console.log('promise2')
})

console.log('script end')

输出:

script start
script end
promise1
promise2
setTimeout

分析:整个 <script> 本身就是一个宏任务。执行完同步代码后,微任务队列中有 promise1 的回调,执行 promise1 后产生的 promise2 回调也被加入微任务队列并立即执行。微任务全部清空后,才执行下一个宏任务 setTimeout


宏任务(Macrotask)详解

常见宏任务

宏任务环境说明
setTimeout浏览器 / Node.js延迟执行,最小延迟约 4ms(嵌套 ≥5 层时)
setInterval浏览器 / Node.js周期性执行
setImmediateNode.js在当前 poll 阶段完成后立即执行
I/O 回调浏览器 / Node.js网络请求、文件读取等
UI 渲染浏览器页面重绘/重排
MessageChannel浏览器端口间消息传递
postMessage浏览器跨窗口通信

setTimeout / setInterval

setTimeout(fn, delay) 不是"delay 毫秒后立即执行 fn",而是"delay 毫秒后将 fn 放入宏任务队列"。实际执行时间取决于主线程何时空闲。

js
const start = Date.now()

setTimeout(() => {
  console.log(`实际延迟: ${Date.now() - start}ms`)
}, 100)

const heavy = Date.now()
while (Date.now() - heavy < 500) {}

输出大约为 实际延迟: 500ms 而非 100ms,因为同步阻塞代码占据主线程 500ms,回调只能排队等待。

setTimeout 最小延迟的规范行为:

  • HTML 规范规定,当 setTimeout 嵌套调用超过 5 层时,最小延迟被强制设为 4ms
  • 在未激活的标签页中,延迟会被限制到至少 1000ms 以节省资源
js
let count = 0
const start = Date.now()

function nested() {
  if (count++ < 10) {
    setTimeout(nested, 0)
  } else {
    console.log(`10 次嵌套总耗时: ${Date.now() - start}ms`)
  }
}
nested()

setInterval 的问题在于它不关心回调是否执行完毕就会调度下一个回调。如果回调执行时间超过间隔时间,多个回调会堆积:

js
let count = 0

const id = setInterval(() => {
  count++
  const start = Date.now()
  while (Date.now() - start < 200) {}
  console.log(`第 ${count} 次执行`)
  if (count >= 3) clearInterval(id)
}, 100)

更好的做法是用递归 setTimeout 替代 setInterval

js
function poll() {
  doSomething()
  setTimeout(poll, 100)
}
poll()

这样保证了前一次执行完毕后才会调度下一次。

requestAnimationFrame 的归类讨论

requestAnimationFrame(rAF)是一个特殊的存在。它既不是严格意义上的宏任务,也不是微任务

根据 HTML 规范,rAF 的回调在浏览器执行渲染之前的一个特定阶段执行。具体时机在事件循环的以下位置:

执行一个宏任务

清空微任务队列

判断是否需要渲染

如需渲染 → 执行 requestAnimationFrame 回调  ← 这里

执行渲染(布局计算、绘制)

如果有空闲时间 → 执行 requestIdleCallback

进入下一轮事件循环

rAF 的执行频率与屏幕刷新率一致(通常 60Hz,即约 16.67ms 一次),而不是每一轮事件循环都执行。

js
console.log('start')

setTimeout(() => console.log('setTimeout'), 0)

requestAnimationFrame(() => console.log('rAF'))

Promise.resolve().then(() => console.log('promise'))

console.log('end')

输出通常为:

start
end
promise
rAF
setTimeout

rAFsetTimeout 的相对顺序并不确定,因为 rAF 取决于浏览器的渲染时机。在某些情况下 setTimeout 可能先于 rAF

I/O 回调

网络请求(fetch/XMLHttpRequest)、文件读取(Node.js 中的 fs.readFile)等 I/O 操作完成后,回调会被放入宏任务队列:

js
fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log(data))

这里 .then 本身是微任务,但触发 .then 执行的前提是 fetch 完成——这个完成事件属于 I/O 回调(宏任务级别的事件源)。


微任务(Microtask)详解

Promise.then / catch / finally

Promise.then(), .catch(), .finally() 注册的回调是微任务。注意:Promise 构造函数中的 executor 函数是同步执行的。

js
console.log('1')

new Promise((resolve) => {
  console.log('2')
  resolve()
  console.log('3')
}).then(() => {
  console.log('4')
})

console.log('5')

输出:12354

new Promise(executor) 中的 executor 是同步执行的,resolve() 调用后,后续的 console.log('3') 仍然会执行。.then() 中的回调被放入微任务队列,等待同步代码执行完毕后执行。

MutationObserver

MutationObserver 用于监听 DOM 变化,它的回调也被作为微任务执行。这是浏览器提供的一种高效观察 DOM 变化的机制:

js
const observer = new MutationObserver(() => {
  console.log('DOM mutated')
})

const target = document.getElementById('app')
observer.observe(target, { childList: true })

target.appendChild(document.createElement('div'))
console.log('sync done')

输出:

sync done
DOM mutated

MutationObserver 的回调作为微任务,会在同步代码执行完毕后、渲染之前执行。它之所以设计为微任务而非宏任务,是因为需要在渲染之前通知开发者 DOM 的变化,以便进行可能的 DOM 调整,避免不必要的重复渲染。

queueMicrotask

queueMicrotask() 是直接将一个函数加入微任务队列的 API,是最明确的微任务创建方式:

js
console.log('start')

queueMicrotask(() => {
  console.log('microtask 1')
})

queueMicrotask(() => {
  console.log('microtask 2')
  queueMicrotask(() => {
    console.log('microtask 3')
  })
})

console.log('end')

输出:

start
end
microtask 1
microtask 2
microtask 3

注意 microtask 3 是在 microtask 2 执行过程中创建的,但它仍然在当前微任务清空阶段执行,不会推迟到下一轮。

process.nextTick(Node.js 专属)

process.nextTick 是 Node.js 特有的微任务,但它的优先级高于 Promise 微任务。在 Node.js 中,nextTickQueue 会在每个阶段切换时优先清空:

js
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))

在 Node.js 中输出:

nextTick
promise

事件循环的完整执行流程

浏览器事件循环(一次完整循环)

┌──────────────────────────────────────────┐
│         一次完整的事件循环(Tick)           │
├──────────────────────────────────────────┤
│                                          │
│  1. 从宏任务队列中取出最老的一个任务执行      │
│     (第一次循环时,整个 <script> 是         │
│      第一个宏任务)                         │
│                                          │
│  2. 执行过程中遇到的微任务加入微任务队列,    │
│     遇到的宏任务加入对应的宏任务队列          │
│                                          │
│  3. 当前宏任务执行完毕(调用栈清空)          │
│                                          │
│  4. 检查微任务队列,依次执行所有微任务         │
│     ├── 执行微任务过程中产生的新微任务         │
│     │   也会在本轮全部执行                    │
│     └── 直到微任务队列清空                   │
│                                          │
│  5. 判断是否需要渲染更新                     │
│     (通常 16.67ms 一次,约 60fps)          │
│                                          │
│  6. 如需渲染:                              │
│     ├── 执行 requestAnimationFrame 回调    │
│     ├── 执行布局计算(Layout)              │
│     ├── 执行绘制(Paint)                   │
│     └── 执行合成(Composite)               │
│                                          │
│  7. 如果主线程空闲且有注册的                  │
│     requestIdleCallback,则执行它           │
│                                          │
│  8. 回到步骤 1                             │
│                                          │
└──────────────────────────────────────────┘

详细步骤分解

步骤 1:取出一个宏任务

浏览器从多个任务队列中选择一个任务。不同任务源(用户交互、定时器、网络)有不同的队列,浏览器可以对它们进行优先级排序(例如,用户输入的优先级通常高于定时器)。

步骤 2-3:执行宏任务

将宏任务的代码压入调用栈执行。期间产生的微任务不会立即执行,而是加入微任务队列。

步骤 4:清空微任务队列(Microtask Checkpoint)

这是最关键的步骤。Microtask Checkpoint 不仅在每个宏任务之后执行,还在以下时机执行:

  • 每个宏任务结束后
  • 每个回调结束后(如果调用栈已清空)
  • MutationObserver 的回调内部
js
setTimeout(() => {
  console.log('macro 1')
  Promise.resolve().then(() => console.log('micro 1'))
  Promise.resolve().then(() => console.log('micro 2'))
}, 0)

setTimeout(() => {
  console.log('macro 2')
}, 0)

输出:

macro 1
micro 1
micro 2
macro 2

macro 1 执行完毕后,会先清空微任务队列(micro 1micro 2),然后才执行下一个宏任务 macro 2

步骤 5-6:渲染阶段

渲染并非每一轮事件循环都发生。浏览器会根据以下条件判断:

  • 是否到了刷新间隔(通常 16.67ms)
  • 页面是否可见
  • 是否有需要更新的内容

如果需要渲染,会依次执行:

  1. resize / scroll 事件派发
  2. 执行 CSS 动画的计算
  3. 执行 requestAnimationFrame 回调
  4. 执行 IntersectionObserver 回调
  5. 布局(Layout)
  6. 绘制(Paint)

步骤 7:空闲阶段

如果到达下一帧之前还有空闲时间,执行 requestIdleCallback 回调。

完整示例演练

js
console.log('=== start ===')

setTimeout(() => {
  console.log('timeout 1')
  Promise.resolve().then(() => console.log('promise in timeout'))
}, 0)

new Promise((resolve) => {
  console.log('promise executor')
  resolve()
}).then(() => {
  console.log('promise 1')
  setTimeout(() => {
    console.log('timeout in promise')
  }, 0)
}).then(() => {
  console.log('promise 2')
})

setTimeout(() => {
  console.log('timeout 2')
}, 0)

console.log('=== end ===')

逐步分析:

第一轮(宏任务:<script>

  1. 执行 console.log('=== start ===') → 输出 === start ===
  2. 遇到 setTimeout,注册回调为宏任务 → 宏任务队列:[timeout1]
  3. 执行 new Promise(executor),executor 同步执行 → 输出 promise executor
  4. resolve() 使 .then() 的回调进入微任务队列 → 微任务队列:[promise1]
  5. 遇到第二个 setTimeout → 宏任务队列:[timeout1, timeout2]
  6. 执行 console.log('=== end ===') → 输出 === end ===
  7. 调用栈清空,开始清空微任务队列
  8. 执行 promise 1 回调 → 输出 promise 1,同时注册一个 setTimeout(宏任务队列追加)→ 宏任务队列:[timeout1, timeout2, timeoutInPromise]
  9. .then() 链产生 promise 2 的微任务 → 微任务队列:[promise2]
  10. 执行 promise 2 回调 → 输出 promise 2
  11. 微任务队列清空

第二轮(宏任务:timeout1

  1. 执行 console.log('timeout 1') → 输出 timeout 1
  2. Promise.resolve().then(...) → 微任务队列:[promiseInTimeout]
  3. 调用栈清空,清空微任务
  4. 执行 → 输出 promise in timeout

第三轮(宏任务:timeout2

  1. 执行 → 输出 timeout 2

第四轮(宏任务:timeoutInPromise

  1. 执行 → 输出 timeout in promise

最终输出:

=== start ===
promise executor
=== end ===
promise 1
promise 2
timeout 1
promise in timeout
timeout 2
timeout in promise

Node.js 事件循环与浏览器的区别

Node.js 的事件循环基于 libuv 库实现,与浏览器的事件循环存在显著差异。Node.js 的事件循环分为 6 个阶段,每个阶段维护自己的 FIFO 回调队列。

6 个阶段

   ┌───────────────────────────┐
┌─>│         timers            │  执行 setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  执行延迟到下一轮的 I/O 回调(系统级)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  仅 Node.js 内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          poll             │  检索新的 I/O 事件;执行 I/O 相关回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │         check             │  执行 setImmediate() 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  执行 close 事件回调(如 socket.on('close'))
│  └───────────────────────────┘

各阶段详解

1. timers 阶段

执行已到期的 setTimeoutsetInterval 的回调。注意"到期"是指设定的延迟时间已经过去。但如果 poll 阶段花费了较长时间,定时器回调的执行可能会延迟。

2. pending callbacks 阶段

执行某些系统操作的回调,如 TCP 错误等。大部分 I/O 回调不在这里,而是在 poll 阶段。

3. idle, prepare 阶段

Node.js 内部使用,开发者无法直接使用。

4. poll 阶段

这是最重要的阶段。它有两个主要功能:

  • 计算应该阻塞多长时间来等待 I/O 事件
  • 处理 poll 队列中的事件回调

当事件循环进入 poll 阶段时:

  • 如果 poll 队列不为空 → 同步执行队列中的回调,直到队列清空或达到系统限制
  • 如果 poll 队列为空:
    • 如果有 setImmediate → 进入 check 阶段
    • 如果没有 setImmediate → 在 poll 阶段等待新的 I/O 事件
    • 如果有已到期的 timer → 回到 timers 阶段

5. check 阶段

专门执行 setImmediate() 的回调。setImmediate 实际上是一个特殊的定时器,在 poll 阶段完成后执行。

6. close callbacks 阶段

执行资源关闭的回调,如 socket.on('close', ...)

微任务在 Node.js 中的执行时机

在 Node.js 11+ 之后,微任务的执行时机与浏览器对齐:每个宏任务执行完毕后立即清空微任务队列

在 Node.js 10 及之前的版本中,微任务是在阶段切换时才统一清空的,这导致了与浏览器行为不一致。

js
setTimeout(() => {
  console.log('timeout 1')
  Promise.resolve().then(() => console.log('promise 1'))
}, 0)

setTimeout(() => {
  console.log('timeout 2')
  Promise.resolve().then(() => console.log('promise 2'))
}, 0)

Node.js 11+(与浏览器一致):

timeout 1
promise 1
timeout 2
promise 2

Node.js 10 及之前:

timeout 1
timeout 2
promise 1
promise 2

setTimeout vs setImmediate

这两者的执行顺序取决于调用时的上下文:

js
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))

上面代码的输出顺序不确定。因为 setTimeout(fn, 0) 实际最小延迟为 1ms,如果事件循环进入 timers 阶段时 1ms 还没过去,timeout 就会推迟,先执行 immediate

但在 I/O 回调内部,setImmediate 总是先于 setTimeout

js
const fs = require('fs')

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0)
  setImmediate(() => console.log('immediate'))
})

输出始终为:

immediate
timeout

因为 I/O 回调在 poll 阶段执行,poll 阶段之后紧接 check 阶段(执行 setImmediate),然后才是下一轮的 timers 阶段。

process.nextTick vs Promise

process.nextTick 不属于事件循环的任何阶段,它维护一个独立的 nextTickQueue。在每个阶段转换前和每个微任务检查点,nextTickQueue 都会被优先清空:

js
setImmediate(() => console.log('immediate'))

process.nextTick(() => {
  console.log('nextTick 1')
  process.nextTick(() => console.log('nextTick 2'))
})

Promise.resolve().then(() => console.log('promise'))

输出:

nextTick 1
nextTick 2
promise
immediate

优先级:process.nextTick > Promise 微任务 > setImmediate


requestAnimationFrame 与 requestIdleCallback

requestAnimationFrame(rAF)

requestAnimationFrame 的设计目的是让动画在浏览器下一次重绘之前执行,从而获得与屏幕刷新率同步的流畅动画效果。

执行时机:在微任务清空之后、浏览器渲染之前。每一帧(约 16.67ms)执行一次。

js
function animate() {
  element.style.transform = `translateX(${position}px)`
  position += 2

  if (position < 300) {
    requestAnimationFrame(animate)
  }
}

requestAnimationFrame(animate)

rAF 相比 setTimeout 做动画的优势:

  1. 自动匹配刷新率:不需要手动计算间隔,浏览器自动在最佳时机调用
  2. 页面隐藏时暂停:标签页不可见时,rAF 会自动暂停,节省 CPU/GPU 资源
  3. 不会掉帧setTimeout 可能因为延迟不精确而掉帧或过度绘制
js
let start = null

function step(timestamp) {
  if (!start) start = timestamp
  const progress = timestamp - start

  element.style.left = Math.min(progress / 10, 200) + 'px'

  if (progress < 2000) {
    requestAnimationFrame(step)
  }
}

requestAnimationFrame(step)

rAF 回调接收一个 DOMHighResTimeStamp 参数,表示回调开始执行的时间。同一帧中所有 rAF 回调接收相同的时间戳。

requestIdleCallback(rIC)

requestIdleCallback 在浏览器空闲时段执行低优先级的工作。

执行时机:在一帧的渲染完成后,如果到下一帧开始之前还有剩余时间(空闲时间),则执行。

一帧的时间线(约 16.67ms):
┌───────────────────────────────────────────────────┐
│ 宏任务 │ 微任务 │ rAF │ 布局/绘制 │  空闲时间(rIC)│
└───────────────────────────────────────────────────┘

                                    rIC 在这里执行
js
requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0) {
    doLowPriorityWork()
  }
})

deadline 对象提供:

  • timeRemaining():当前空闲期剩余的毫秒数
  • didTimeout:回调是否因为超时而被强制执行

可以传入 timeout 选项,确保回调在指定时间内一定会被执行(即使没有空闲时间):

js
requestIdleCallback(doWork, { timeout: 1000 })

两者对比

特性requestAnimationFramerequestIdleCallback
执行时机渲染前渲染后的空闲时间
执行频率每帧一次(~60fps)不确定,取决于空闲时间
适用场景动画、视觉更新非紧急计算、数据上报、预加载
页面隐藏暂停继续执行(频率降低)
能否操作 DOM可以(推荐)不推荐(可能触发重新布局)

React 的 Fiber 架构中,时间切片(Time Slicing)的概念就借鉴了 requestIdleCallback 的思想(实际使用 MessageChannel 实现)。


async/await 在事件循环中的执行分析

async/await 的本质

async/awaitPromise 的语法糖。理解它在事件循环中的行为,关键在于理解它如何被"脱糖"(desugar)为 Promise。

js
async function foo() {
  console.log('foo start')
  const result = await bar()
  console.log('foo end')
  return result
}

等价于:

js
function foo() {
  console.log('foo start')
  return bar().then((result) => {
    console.log('foo end')
    return result
  })
}

核心规则await 之前的代码是同步执行的,await 之后的代码相当于 .then() 回调,是微任务。

执行分析示例

js
async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

console.log('script start')
async1()
console.log('script end')

逐步分析:

  1. 执行 console.log('script start') → 输出 script start
  2. 调用 async1()
  3. 进入 async1,执行 console.log('async1 start') → 输出 async1 start
  4. 遇到 await async2(),先执行 async2()
  5. 进入 async2,执行 console.log('async2') → 输出 async2
  6. async2() 返回 Promise.resolve(undefined)
  7. await 暂停 async1 的执行,将 await 之后的代码(console.log('async1 end'))注册为微任务
  8. 控制权返回到 async1() 的调用处,继续执行同步代码
  9. 执行 console.log('script end') → 输出 script end
  10. 调用栈清空,执行微任务
  11. 执行 console.log('async1 end') → 输出 async1 end

输出:

script start
async1 start
async2
script end
async1 end

await 后面跟不同值的情况

await 后面的值会被隐式包装为 Promise.resolve(value)

js
async function test() {
  console.log('before await')
  const val = await 123
  console.log('after await', val)
}

test()
console.log('sync')

输出:

before await
sync
after await 123

await 123 等价于 await Promise.resolve(123)await 之后的代码仍然被放入微任务队列。

await 与 Promise 混合的复杂场景

js
async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2 start')
  return new Promise((resolve) => {
    console.log('async2 promise')
    resolve()
  }).then(() => {
    console.log('async2 end')
  })
}

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

async1()

new Promise((resolve) => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('promise2')
}).then(() => {
  console.log('promise3')
})

console.log('script end')

逐步分析:

同步代码执行:

  1. 输出 script start
  2. 注册 setTimeout 到宏任务队列
  3. 调用 async1()
  4. 输出 async1 start
  5. 调用 async2()
  6. 输出 async2 start
  7. 执行 new Promise(executor) → 输出 async2 promise
  8. resolve() 被调用,.then(() => console.log('async2 end')) 加入微任务队列 → 微任务队列:[async2End]
  9. async2() 返回一个 pending 的 Promise(因为它 return 的 Promise 链还没 resolve)
  10. await 暂停 async1,等待 async2() 返回的 Promise resolve
  11. 回到主流程,执行 new Promise(executor) → 输出 promise1
  12. resolve().then() 回调入微任务队列 → 微任务队列:[async2End, promise2]
  13. 输出 script end

微任务清空:

  1. 取出 async2End → 输出 async2 end。此时 async2() 返回的 Promise 链 resolve 了,await 等待的 Promise 也 resolve,将 async1 end 加入微任务队列 → 微任务队列:[promise2, async1End]
  2. 取出 promise2 → 输出 promise2。产生新的 .then() → 微任务队列:[async1End, promise3]
  3. 取出 async1End → 输出 async1 end
  4. 取出 promise3 → 输出 promise3

宏任务:

  1. 输出 setTimeout

最终输出:

script start
async1 start
async2 start
async2 promise
promise1
script end
async2 end
promise2
async1 end
promise3
setTimeout

经典面试题

面试题 1:基础 —— setTimeout 与 Promise

js
console.log('1')

setTimeout(() => {
  console.log('2')
}, 0)

Promise.resolve().then(() => {
  console.log('3')
})

console.log('4')

分析过程:

  1. 执行同步代码 console.log('1') → 输出 1
  2. setTimeout 回调注册为宏任务 → 宏任务队列:[cb2]
  3. Promise.resolve().then() 回调注册为微任务 → 微任务队列:[cb3]
  4. 执行同步代码 console.log('4') → 输出 4
  5. 调用栈清空,清空微任务 → 执行 cb3 → 输出 3
  6. 微任务队列清空,取下一个宏任务 → 执行 cb2 → 输出 2

输出:1432


面试题 2:Promise 嵌套

js
console.log('start')

new Promise((resolve) => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('then1')
  new Promise((resolve) => {
    console.log('promise2')
    resolve()
  }).then(() => {
    console.log('then2')
  })
}).then(() => {
  console.log('then3')
})

console.log('end')

分析过程:

同步阶段:

  1. 输出 start
  2. 执行 new Promise(executor),executor 同步执行 → 输出 promise1
  3. resolve() 调用,.then(cb_then1) 回调加入微任务队列 → 微任务队列:[then1]
  4. 输出 end

微任务清空 - 第 1 轮:

  1. 取出 then1 执行 → 输出 then1
  2. 内部 new Promise(executor) → 输出 promise2
  3. resolve().then(cb_then2) 加入微任务队列 → 微任务队列:[then2]
  4. then1 回调返回 undefined(隐式),外层的 .then(cb_then3) 被 resolve → 微任务队列:[then2, then3]

微任务清空 - 第 2 轮(继续清空):

  1. 取出 then2 → 输出 then2
  2. 取出 then3 → 输出 then3

输出:startpromise1endthen1promise2then2then3

关键点:then1 回调执行完毕后返回 undefined,这使得外层链的下一个 .then(cb_then3) 的 Promise 被 resolve,then3 被加入微任务队列。但此时 then2 已经在队列中了,所以 then2 先执行。


面试题 3:async/await 与 Promise 混合

js
async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

async1()

new Promise((resolve) => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('promise2')
})

console.log('script end')

分析过程:

同步阶段:

  1. 输出 script start
  2. setTimeout 注册宏任务 → 宏任务队列:[setTimeout]
  3. 调用 async1()
  4. 输出 async1 start
  5. 调用 async2() → 输出 async2
  6. async2() 返回 Promise.resolve(undefined)
  7. await 暂停 async1async1 end 所在代码注册为微任务 → 微任务队列:[async1End]
  8. 执行 new Promise(executor) → 输出 promise1
  9. resolve().then() 回调注册为微任务 → 微任务队列:[async1End, promise2]
  10. 输出 script end

微任务清空:

  1. 取出 async1End → 输出 async1 end
  2. 取出 promise2 → 输出 promise2

宏任务:

  1. 输出 setTimeout

输出:script startasync1 startasync2promise1script endasync1 endpromise2setTimeout


面试题 4:综合 —— 多层嵌套

js
console.log('1')

setTimeout(() => {
  console.log('2')
  new Promise((resolve) => {
    console.log('3')
    resolve()
  }).then(() => {
    console.log('4')
  })
})

new Promise((resolve) => {
  console.log('5')
  resolve()
}).then(() => {
  console.log('6')
})

setTimeout(() => {
  console.log('7')
  new Promise((resolve) => {
    console.log('8')
    resolve()
  }).then(() => {
    console.log('9')
  })
})

new Promise((resolve) => {
  console.log('10')
  resolve()
}).then(() => {
  console.log('11')
})

console.log('12')

分析过程:

第一轮 —— 宏任务:<script>

步骤操作输出宏任务队列微任务队列
1console.log('1')1[][]
2注册 setTimeout1-[st1][]
3Promise executor: console.log('5')5[st1][]
4resolve().then(cb6)-[st1][cb6]
5注册 setTimeout2-[st1, st2][cb6]
6Promise executor: console.log('10')10[st1, st2][cb6]
7resolve().then(cb11)-[st1, st2][cb6, cb11]
8console.log('12')12[st1, st2][cb6, cb11]

清空微任务:

  • 执行 cb6 → 输出 6
  • 执行 cb11 → 输出 11

第二轮 —— 宏任务:setTimeout1

步骤操作输出微任务队列
1console.log('2')2[]
2Promise executor: console.log('3')3[]
3resolve().then(cb4)-[cb4]

清空微任务:

  • 执行 cb4 → 输出 4

第三轮 —— 宏任务:setTimeout2

步骤操作输出微任务队列
1console.log('7')7[]
2Promise executor: console.log('8')8[]
3resolve().then(cb9)-[cb9]

清空微任务:

  • 执行 cb9 → 输出 9

最终输出:151012611234789


面试题 5:终极 —— async/await + Promise + setTimeout 全方位

js
async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
  await async3()
  console.log('async1 final')
}

async function async2() {
  console.log('async2')
  return new Promise((resolve) => {
    console.log('async2 promise')
    resolve()
  })
}

async function async3() {
  console.log('async3')
}

console.log('script start')

setTimeout(() => {
  console.log('setTimeout1')
  Promise.resolve().then(() => {
    console.log('promise in setTimeout')
  })
}, 0)

async1()

new Promise((resolve) => {
  console.log('promise1')
  resolve()
})
  .then(() => {
    console.log('promise2')
    return new Promise((resolve) => {
      console.log('promise2 inner')
      resolve()
    })
  })
  .then(() => {
    console.log('promise3')
  })

setTimeout(() => {
  console.log('setTimeout2')
}, 0)

console.log('script end')

分析过程:

同步阶段:

  1. 输出 script start
  2. 注册 setTimeout1 → 宏任务队列:[st1]
  3. 调用 async1()
  4. 输出 async1 start
  5. 调用 async2()
  6. 输出 async2
  7. 执行 new Promise(executor) → 输出 async2 promise
  8. resolve() 被调用,async2 返回一个 resolved 的 Promise
  9. await 暂停 async1,将恢复执行(async1 end 后续代码)注册为微任务 → 微任务队列:[async1Resume]
  10. 回到主流程
  11. 执行 new Promise(executor) → 输出 promise1
  12. resolve().then(cb_p2) 加入微任务队列 → 微任务队列:[async1Resume, promise2]
  13. 注册 setTimeout2 → 宏任务队列:[st1, st2]
  14. 输出 script end

微任务清空 —— 详细追踪:

  1. 取出 async1Resume

    • 输出 async1 end
    • 调用 async3() → 输出 async3
    • async3() 返回 Promise.resolve(undefined)
    • await 暂停,将 async1 final 注册为微任务 → 微任务队列:[promise2, async1Final]
  2. 取出 promise2 回调执行:

    • 输出 promise2
    • 执行 new Promise(executor) → 输出 promise2 inner
    • resolve()
    • .then(cb_p2) 回调返回这个新 Promise,外层 .then(cb_p3) 等待它 resolve
    • 新 Promise 已 resolved → promise3 加入微任务队列 → 微任务队列:[async1Final, promise3]
  3. 取出 async1Final → 输出 async1 final

  4. 取出 promise3 → 输出 promise3

宏任务 —— setTimeout1:

  1. 输出 setTimeout1
  2. Promise.resolve().then() → 微任务队列:[promiseInST]
  3. 清空微任务 → 输出 promise in setTimeout

宏任务 —— setTimeout2:

  1. 输出 setTimeout2

最终输出:

script start
async1 start
async2
async2 promise
promise1
script end
async1 end
async3
promise2
promise2 inner
async1 final
promise3
setTimeout1
promise in setTimeout
setTimeout2

注意:第 16 步中 .then() 返回一个新 Promise 的场景涉及 Promise Resolution 的 unwrapping 行为。当 .then() 回调返回一个 thenable(Promise),引擎需要创建一个 PromiseResolveThenableJob 微任务来解析它。在不同的引擎版本中,这里可能会多产生 1-2 个额外的微任务跳转,导致 promise3 的实际执行时机可能略有差异。上述分析基于 V8 当前版本的优化行为。


面试题 6(附加):Promise.all 与事件循环

js
console.log('start')

Promise.all([
  new Promise((resolve) => {
    console.log('p1 executor')
    setTimeout(() => {
      console.log('p1 timeout')
      resolve('p1')
    }, 100)
  }),
  new Promise((resolve) => {
    console.log('p2 executor')
    setTimeout(() => {
      console.log('p2 timeout')
      resolve('p2')
    }, 50)
  }),
]).then((results) => {
  console.log('all resolved:', results)
})

Promise.resolve()
  .then(() => console.log('micro 1'))
  .then(() => console.log('micro 2'))

console.log('end')

分析过程:

同步阶段:

  1. 输出 start
  2. Promise.all 接收一个数组,数组中的两个 new Promise 的 executor 同步执行
  3. 输出 p1 executor,注册 setTimeout(100ms) → 宏任务队列
  4. 输出 p2 executor,注册 setTimeout(50ms) → 宏任务队列
  5. Promise.resolve().then() → 微任务队列:[micro1]
  6. 输出 end

微任务清空:

  1. 输出 micro 1,产生新微任务 → 微任务队列:[micro2]
  2. 输出 micro 2

宏任务 —— 50ms 后 p2 的 setTimeout 先到期:

  1. 输出 p2 timeoutp2 resolve,但 Promise.all 还在等 p1

宏任务 —— 100ms 后 p1 的 setTimeout 到期:

  1. 输出 p1 timeoutp1 resolve
  2. 两个 Promise 都 resolve 了 → Promise.all resolve → .then() 回调进入微任务队列
  3. 输出 all resolved: ['p1', 'p2'](结果顺序与传入顺序一致,不是 resolve 顺序)

最终输出:

start
p1 executor
p2 executor
end
micro 1
micro 2
p2 timeout
p1 timeout
all resolved: [ 'p1', 'p2' ]

关键点:Promise.all 的结果数组中元素的顺序始终与传入的 Promise 数组顺序一致,与各 Promise 实际 resolve 的时间顺序无关。


面试题 7(附加):queueMicrotask 与 Promise 的优先级

js
console.log('start')

queueMicrotask(() => {
  console.log('queueMicrotask 1')
  queueMicrotask(() => {
    console.log('queueMicrotask 3')
  })
})

Promise.resolve().then(() => {
  console.log('promise then 1')
  Promise.resolve().then(() => {
    console.log('promise then 3')
  })
})

queueMicrotask(() => {
  console.log('queueMicrotask 2')
})

Promise.resolve().then(() => {
  console.log('promise then 2')
})

console.log('end')

分析过程:

同步阶段:

  1. 输出 start
  2. queueMicrotask 注册回调 → 微任务队列:[qM1]
  3. Promise.resolve().then() 注册回调 → 微任务队列:[qM1, pT1]
  4. queueMicrotask 注册回调 → 微任务队列:[qM1, pT1, qM2]
  5. Promise.resolve().then() 注册回调 → 微任务队列:[qM1, pT1, qM2, pT2]
  6. 输出 end

微任务清空(FIFO 顺序):

  1. 取出 qM1 → 输出 queueMicrotask 1,新增 qM3 → 队列:[pT1, qM2, pT2, qM3]
  2. 取出 pT1 → 输出 promise then 1,新增 pT3 → 队列:[qM2, pT2, qM3, pT3]
  3. 取出 qM2 → 输出 queueMicrotask 2
  4. 取出 pT2 → 输出 promise then 2
  5. 取出 qM3 → 输出 queueMicrotask 3
  6. 取出 pT3 → 输出 promise then 3

最终输出:

start
end
queueMicrotask 1
promise then 1
queueMicrotask 2
promise then 2
queueMicrotask 3
promise then 3

关键点:queueMicrotaskPromise.then 的回调共享同一个微任务队列,按照入队顺序(FIFO)执行,不存在谁优先级更高的问题。它们在微任务队列中是平等的。

用心学习,用代码说话 💻