事件循环
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 代码会导致页面卡顿——渲染线程被阻塞了。
const start = Date.now()
while (Date.now() - start < 3000) {}
console.log('done')上面的代码会让页面完全冻结 3 秒,期间无法响应任何用户交互,因为主线程被同步代码完全占据。
单线程如何处理异步?
单线程不代表只能同步执行。JavaScript 通过**事件循环(Event Loop)**机制实现异步非阻塞。核心思想是:将耗时操作委托给其他线程(定时器线程、网络线程等),当操作完成后,将回调函数放入任务队列,等待主线程空闲时执行。
console.log('1')
setTimeout(() => {
console.log('2')
}, 0)
console.log('3')输出顺序:1 → 3 → 2。setTimeout 的回调被放入任务队列,等到同步代码执行完毕后才会被取出执行。
调用栈(Call Stack)
工作原理
调用栈是一种 LIFO(后进先出) 的数据结构,用于记录程序当前的执行位置。每当执行一个函数时,就会在栈顶压入(push)一个新的栈帧(Stack Frame);当函数执行完毕返回时,栈帧被弹出(pop)。
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:
function recursive() {
recursive()
}
recursive()异步回调与调用栈
理解调用栈是理解事件循环的关键。事件循环的核心规则之一就是:只有当调用栈清空时,才会从任务队列中取出下一个任务执行。
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)
微任务队列是一个单独的队列。在每一个宏任务执行完毕后(更精确地说,是在当前调用栈清空后),会清空整个微任务队列中的所有任务,然后才会进行渲染和下一轮宏任务。
如果在执行微任务的过程中又产生了新的微任务,这些新的微任务也会在当前轮次中全部执行完毕。这意味着微任务可能会"饿死"渲染和宏任务:
function infiniteMicrotask() {
Promise.resolve().then(() => infiniteMicrotask())
}
infiniteMicrotask()上面的代码会导致页面完全冻结,因为微任务队列永远不会清空,渲染和宏任务永远得不到执行机会。
两者的关键区别
| 特性 | 宏任务 | 微任务 |
|---|---|---|
| 每轮执行数量 | 一个 | 全部清空 |
| 执行优先级 | 低 | 高(在宏任务之后、渲染之前) |
| 新增任务处理 | 放到下一轮执行 | 在当前轮次中继续执行 |
| 常见来源 | setTimeout, setInterval, I/O | Promise.then, MutationObserver, queueMicrotask |
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 | 周期性执行 |
setImmediate | Node.js | 在当前 poll 阶段完成后立即执行 |
| I/O 回调 | 浏览器 / Node.js | 网络请求、文件读取等 |
| UI 渲染 | 浏览器 | 页面重绘/重排 |
MessageChannel | 浏览器 | 端口间消息传递 |
postMessage | 浏览器 | 跨窗口通信 |
setTimeout / setInterval
setTimeout(fn, delay) 不是"delay 毫秒后立即执行 fn",而是"delay 毫秒后将 fn 放入宏任务队列"。实际执行时间取决于主线程何时空闲。
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 以节省资源
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 的问题在于它不关心回调是否执行完毕就会调度下一个回调。如果回调执行时间超过间隔时间,多个回调会堆积:
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:
function poll() {
doSomething()
setTimeout(poll, 100)
}
poll()这样保证了前一次执行完毕后才会调度下一次。
requestAnimationFrame 的归类讨论
requestAnimationFrame(rAF)是一个特殊的存在。它既不是严格意义上的宏任务,也不是微任务。
根据 HTML 规范,rAF 的回调在浏览器执行渲染之前的一个特定阶段执行。具体时机在事件循环的以下位置:
执行一个宏任务
↓
清空微任务队列
↓
判断是否需要渲染
↓
如需渲染 → 执行 requestAnimationFrame 回调 ← 这里
↓
执行渲染(布局计算、绘制)
↓
如果有空闲时间 → 执行 requestIdleCallback
↓
进入下一轮事件循环rAF 的执行频率与屏幕刷新率一致(通常 60Hz,即约 16.67ms 一次),而不是每一轮事件循环都执行。
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但 rAF 和 setTimeout 的相对顺序并不确定,因为 rAF 取决于浏览器的渲染时机。在某些情况下 setTimeout 可能先于 rAF。
I/O 回调
网络请求(fetch/XMLHttpRequest)、文件读取(Node.js 中的 fs.readFile)等 I/O 操作完成后,回调会被放入宏任务队列:
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 函数是同步执行的。
console.log('1')
new Promise((resolve) => {
console.log('2')
resolve()
console.log('3')
}).then(() => {
console.log('4')
})
console.log('5')输出:1 → 2 → 3 → 5 → 4
new Promise(executor) 中的 executor 是同步执行的,resolve() 调用后,后续的 console.log('3') 仍然会执行。.then() 中的回调被放入微任务队列,等待同步代码执行完毕后执行。
MutationObserver
MutationObserver 用于监听 DOM 变化,它的回调也被作为微任务执行。这是浏览器提供的一种高效观察 DOM 变化的机制:
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 mutatedMutationObserver 的回调作为微任务,会在同步代码执行完毕后、渲染之前执行。它之所以设计为微任务而非宏任务,是因为需要在渲染之前通知开发者 DOM 的变化,以便进行可能的 DOM 调整,避免不必要的重复渲染。
queueMicrotask
queueMicrotask() 是直接将一个函数加入微任务队列的 API,是最明确的微任务创建方式:
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 会在每个阶段切换时优先清空:
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 的回调内部
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 2macro 1 执行完毕后,会先清空微任务队列(micro 1、micro 2),然后才执行下一个宏任务 macro 2。
步骤 5-6:渲染阶段
渲染并非每一轮事件循环都发生。浏览器会根据以下条件判断:
- 是否到了刷新间隔(通常 16.67ms)
- 页面是否可见
- 是否有需要更新的内容
如果需要渲染,会依次执行:
resize/scroll事件派发- 执行 CSS 动画的计算
- 执行
requestAnimationFrame回调 - 执行
IntersectionObserver回调 - 布局(Layout)
- 绘制(Paint)
步骤 7:空闲阶段
如果到达下一帧之前还有空闲时间,执行 requestIdleCallback 回调。
完整示例演练
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>)
- 执行
console.log('=== start ===')→ 输出=== start === - 遇到
setTimeout,注册回调为宏任务 → 宏任务队列:[timeout1] - 执行
new Promise(executor),executor 同步执行 → 输出promise executor resolve()使.then()的回调进入微任务队列 → 微任务队列:[promise1]- 遇到第二个
setTimeout→ 宏任务队列:[timeout1, timeout2] - 执行
console.log('=== end ===')→ 输出=== end === - 调用栈清空,开始清空微任务队列
- 执行
promise 1回调 → 输出promise 1,同时注册一个setTimeout(宏任务队列追加)→ 宏任务队列:[timeout1, timeout2, timeoutInPromise] .then()链产生promise 2的微任务 → 微任务队列:[promise2]- 执行
promise 2回调 → 输出promise 2 - 微任务队列清空
第二轮(宏任务:timeout1)
- 执行
console.log('timeout 1')→ 输出timeout 1 Promise.resolve().then(...)→ 微任务队列:[promiseInTimeout]- 调用栈清空,清空微任务
- 执行 → 输出
promise in timeout
第三轮(宏任务:timeout2)
- 执行 → 输出
timeout 2
第四轮(宏任务:timeoutInPromise)
- 执行 → 输出
timeout in promise
最终输出:
=== start ===
promise executor
=== end ===
promise 1
promise 2
timeout 1
promise in timeout
timeout 2
timeout in promiseNode.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 阶段
执行已到期的 setTimeout 和 setInterval 的回调。注意"到期"是指设定的延迟时间已经过去。但如果 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 及之前的版本中,微任务是在阶段切换时才统一清空的,这导致了与浏览器行为不一致。
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 2Node.js 10 及之前:
timeout 1
timeout 2
promise 1
promise 2setTimeout vs setImmediate
这两者的执行顺序取决于调用时的上下文:
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))上面代码的输出顺序不确定。因为 setTimeout(fn, 0) 实际最小延迟为 1ms,如果事件循环进入 timers 阶段时 1ms 还没过去,timeout 就会推迟,先执行 immediate。
但在 I/O 回调内部,setImmediate 总是先于 setTimeout:
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 都会被优先清空:
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)执行一次。
function animate() {
element.style.transform = `translateX(${position}px)`
position += 2
if (position < 300) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)rAF 相比 setTimeout 做动画的优势:
- 自动匹配刷新率:不需要手动计算间隔,浏览器自动在最佳时机调用
- 页面隐藏时暂停:标签页不可见时,rAF 会自动暂停,节省 CPU/GPU 资源
- 不会掉帧:
setTimeout可能因为延迟不精确而掉帧或过度绘制
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 在这里执行requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0) {
doLowPriorityWork()
}
})deadline 对象提供:
timeRemaining():当前空闲期剩余的毫秒数didTimeout:回调是否因为超时而被强制执行
可以传入 timeout 选项,确保回调在指定时间内一定会被执行(即使没有空闲时间):
requestIdleCallback(doWork, { timeout: 1000 })两者对比
| 特性 | requestAnimationFrame | requestIdleCallback |
|---|---|---|
| 执行时机 | 渲染前 | 渲染后的空闲时间 |
| 执行频率 | 每帧一次(~60fps) | 不确定,取决于空闲时间 |
| 适用场景 | 动画、视觉更新 | 非紧急计算、数据上报、预加载 |
| 页面隐藏 | 暂停 | 继续执行(频率降低) |
| 能否操作 DOM | 可以(推荐) | 不推荐(可能触发重新布局) |
React 的 Fiber 架构中,时间切片(Time Slicing)的概念就借鉴了 requestIdleCallback 的思想(实际使用 MessageChannel 实现)。
async/await 在事件循环中的执行分析
async/await 的本质
async/await 是 Promise 的语法糖。理解它在事件循环中的行为,关键在于理解它如何被"脱糖"(desugar)为 Promise。
async function foo() {
console.log('foo start')
const result = await bar()
console.log('foo end')
return result
}等价于:
function foo() {
console.log('foo start')
return bar().then((result) => {
console.log('foo end')
return result
})
}核心规则:await 之前的代码是同步执行的,await 之后的代码相当于 .then() 回调,是微任务。
执行分析示例
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')逐步分析:
- 执行
console.log('script start')→ 输出script start - 调用
async1() - 进入
async1,执行console.log('async1 start')→ 输出async1 start - 遇到
await async2(),先执行async2() - 进入
async2,执行console.log('async2')→ 输出async2 async2()返回Promise.resolve(undefined)await暂停async1的执行,将await之后的代码(console.log('async1 end'))注册为微任务- 控制权返回到
async1()的调用处,继续执行同步代码 - 执行
console.log('script end')→ 输出script end - 调用栈清空,执行微任务
- 执行
console.log('async1 end')→ 输出async1 end
输出:
script start
async1 start
async2
script end
async1 endawait 后面跟不同值的情况
await 后面的值会被隐式包装为 Promise.resolve(value):
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 123await 123 等价于 await Promise.resolve(123),await 之后的代码仍然被放入微任务队列。
await 与 Promise 混合的复杂场景
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')逐步分析:
同步代码执行:
- 输出
script start - 注册
setTimeout到宏任务队列 - 调用
async1() - 输出
async1 start - 调用
async2() - 输出
async2 start - 执行
new Promise(executor)→ 输出async2 promise resolve()被调用,.then(() => console.log('async2 end'))加入微任务队列 → 微任务队列:[async2End]async2()返回一个 pending 的 Promise(因为它 return 的 Promise 链还没 resolve)await暂停async1,等待async2()返回的 Promise resolve- 回到主流程,执行
new Promise(executor)→ 输出promise1 resolve()→.then()回调入微任务队列 → 微任务队列:[async2End, promise2]- 输出
script end
微任务清空:
- 取出
async2End→ 输出async2 end。此时async2()返回的 Promise 链 resolve 了,await等待的 Promise 也 resolve,将async1 end加入微任务队列 → 微任务队列:[promise2, async1End] - 取出
promise2→ 输出promise2。产生新的.then()→ 微任务队列:[async1End, promise3] - 取出
async1End→ 输出async1 end - 取出
promise3→ 输出promise3
宏任务:
- 输出
setTimeout
最终输出:
script start
async1 start
async2 start
async2 promise
promise1
script end
async2 end
promise2
async1 end
promise3
setTimeout经典面试题
面试题 1:基础 —— setTimeout 与 Promise
console.log('1')
setTimeout(() => {
console.log('2')
}, 0)
Promise.resolve().then(() => {
console.log('3')
})
console.log('4')分析过程:
- 执行同步代码
console.log('1')→ 输出1 setTimeout回调注册为宏任务 → 宏任务队列:[cb2]Promise.resolve().then()回调注册为微任务 → 微任务队列:[cb3]- 执行同步代码
console.log('4')→ 输出4 - 调用栈清空,清空微任务 → 执行
cb3→ 输出3 - 微任务队列清空,取下一个宏任务 → 执行
cb2→ 输出2
输出:1 → 4 → 3 → 2
面试题 2:Promise 嵌套
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')分析过程:
同步阶段:
- 输出
start - 执行
new Promise(executor),executor 同步执行 → 输出promise1 resolve()调用,.then(cb_then1)回调加入微任务队列 → 微任务队列:[then1]- 输出
end
微任务清空 - 第 1 轮:
- 取出
then1执行 → 输出then1 - 内部
new Promise(executor)→ 输出promise2 resolve()→.then(cb_then2)加入微任务队列 → 微任务队列:[then2]then1回调返回undefined(隐式),外层的.then(cb_then3)被 resolve → 微任务队列:[then2, then3]
微任务清空 - 第 2 轮(继续清空):
- 取出
then2→ 输出then2 - 取出
then3→ 输出then3
输出:start → promise1 → end → then1 → promise2 → then2 → then3
关键点:then1 回调执行完毕后返回 undefined,这使得外层链的下一个 .then(cb_then3) 的 Promise 被 resolve,then3 被加入微任务队列。但此时 then2 已经在队列中了,所以 then2 先执行。
面试题 3:async/await 与 Promise 混合
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')分析过程:
同步阶段:
- 输出
script start setTimeout注册宏任务 → 宏任务队列:[setTimeout]- 调用
async1() - 输出
async1 start - 调用
async2()→ 输出async2 async2()返回Promise.resolve(undefined)await暂停async1,async1 end所在代码注册为微任务 → 微任务队列:[async1End]- 执行
new Promise(executor)→ 输出promise1 resolve()→.then()回调注册为微任务 → 微任务队列:[async1End, promise2]- 输出
script end
微任务清空:
- 取出
async1End→ 输出async1 end - 取出
promise2→ 输出promise2
宏任务:
- 输出
setTimeout
输出:script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout
面试题 4:综合 —— 多层嵌套
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>
| 步骤 | 操作 | 输出 | 宏任务队列 | 微任务队列 |
|---|---|---|---|---|
| 1 | console.log('1') | 1 | [] | [] |
| 2 | 注册 setTimeout1 | - | [st1] | [] |
| 3 | Promise executor: console.log('5') | 5 | [st1] | [] |
| 4 | resolve() → .then(cb6) | - | [st1] | [cb6] |
| 5 | 注册 setTimeout2 | - | [st1, st2] | [cb6] |
| 6 | Promise executor: console.log('10') | 10 | [st1, st2] | [cb6] |
| 7 | resolve() → .then(cb11) | - | [st1, st2] | [cb6, cb11] |
| 8 | console.log('12') | 12 | [st1, st2] | [cb6, cb11] |
清空微任务:
- 执行
cb6→ 输出6 - 执行
cb11→ 输出11
第二轮 —— 宏任务:setTimeout1
| 步骤 | 操作 | 输出 | 微任务队列 |
|---|---|---|---|
| 1 | console.log('2') | 2 | [] |
| 2 | Promise executor: console.log('3') | 3 | [] |
| 3 | resolve() → .then(cb4) | - | [cb4] |
清空微任务:
- 执行
cb4→ 输出4
第三轮 —— 宏任务:setTimeout2
| 步骤 | 操作 | 输出 | 微任务队列 |
|---|---|---|---|
| 1 | console.log('7') | 7 | [] |
| 2 | Promise executor: console.log('8') | 8 | [] |
| 3 | resolve() → .then(cb9) | - | [cb9] |
清空微任务:
- 执行
cb9→ 输出9
最终输出:1 → 5 → 10 → 12 → 6 → 11 → 2 → 3 → 4 → 7 → 8 → 9
面试题 5:终极 —— async/await + Promise + setTimeout 全方位
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')分析过程:
同步阶段:
- 输出
script start - 注册
setTimeout1→ 宏任务队列:[st1] - 调用
async1() - 输出
async1 start - 调用
async2() - 输出
async2 - 执行
new Promise(executor)→ 输出async2 promise resolve()被调用,async2返回一个 resolved 的 Promiseawait暂停async1,将恢复执行(async1 end后续代码)注册为微任务 → 微任务队列:[async1Resume]- 回到主流程
- 执行
new Promise(executor)→ 输出promise1 resolve()→.then(cb_p2)加入微任务队列 → 微任务队列:[async1Resume, promise2]- 注册
setTimeout2→ 宏任务队列:[st1, st2] - 输出
script end
微任务清空 —— 详细追踪:
取出
async1Resume:- 输出
async1 end - 调用
async3()→ 输出async3 async3()返回Promise.resolve(undefined)await暂停,将async1 final注册为微任务 → 微任务队列:[promise2, async1Final]
- 输出
取出
promise2回调执行:- 输出
promise2 - 执行
new Promise(executor)→ 输出promise2 inner resolve().then(cb_p2)回调返回这个新 Promise,外层.then(cb_p3)等待它 resolve- 新 Promise 已 resolved →
promise3加入微任务队列 → 微任务队列:[async1Final, promise3]
- 输出
取出
async1Final→ 输出async1 final取出
promise3→ 输出promise3
宏任务 —— setTimeout1:
- 输出
setTimeout1 Promise.resolve().then()→ 微任务队列:[promiseInST]- 清空微任务 → 输出
promise in setTimeout
宏任务 —— setTimeout2:
- 输出
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 与事件循环
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')分析过程:
同步阶段:
- 输出
start Promise.all接收一个数组,数组中的两个new Promise的 executor 同步执行- 输出
p1 executor,注册setTimeout(100ms) → 宏任务队列 - 输出
p2 executor,注册setTimeout(50ms) → 宏任务队列 Promise.resolve().then()→ 微任务队列:[micro1]- 输出
end
微任务清空:
- 输出
micro 1,产生新微任务 → 微任务队列:[micro2] - 输出
micro 2
宏任务 —— 50ms 后 p2 的 setTimeout 先到期:
- 输出
p2 timeout,p2resolve,但Promise.all还在等p1
宏任务 —— 100ms 后 p1 的 setTimeout 到期:
- 输出
p1 timeout,p1resolve - 两个 Promise 都 resolve 了 →
Promise.allresolve →.then()回调进入微任务队列 - 输出
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 的优先级
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')分析过程:
同步阶段:
- 输出
start queueMicrotask注册回调 → 微任务队列:[qM1]Promise.resolve().then()注册回调 → 微任务队列:[qM1, pT1]queueMicrotask注册回调 → 微任务队列:[qM1, pT1, qM2]Promise.resolve().then()注册回调 → 微任务队列:[qM1, pT1, qM2, pT2]- 输出
end
微任务清空(FIFO 顺序):
- 取出
qM1→ 输出queueMicrotask 1,新增qM3→ 队列:[pT1, qM2, pT2, qM3] - 取出
pT1→ 输出promise then 1,新增pT3→ 队列:[qM2, pT2, qM3, pT3] - 取出
qM2→ 输出queueMicrotask 2 - 取出
pT2→ 输出promise then 2 - 取出
qM3→ 输出queueMicrotask 3 - 取出
pT3→ 输出promise then 3
最终输出:
start
end
queueMicrotask 1
promise then 1
queueMicrotask 2
promise then 2
queueMicrotask 3
promise then 3关键点:queueMicrotask 和 Promise.then 的回调共享同一个微任务队列,按照入队顺序(FIFO)执行,不存在谁优先级更高的问题。它们在微任务队列中是平等的。