事件循环与异步
前端面试里最常考、也最容易考出区分度的一块。几乎必有一道「输出顺序」的题。
🔥 为什么需要事件循环
JS 是单线程的 —— 因为它要操作 DOM,多线程同时改同一个节点需要锁机制,对脚本语言代价太高。
但单线程意味着一个耗时任务会卡死整个页面。解法是把耗时操作交给宿主环境(浏览器 / Node)去做,完成后把回调放进队列,等主线程空了再执行 —— 这就是事件循环。
关键认知:setTimeout、fetch、DOM 事件这些都不是 JS 引擎提供的,它们是宿主环境(Web APIs)的能力。JS 引擎只负责执行代码和管理调用栈。
执行机制
┌─────────────────────────┐
│ 调用栈 Stack │ ← 同步代码在这里执行
└───────────┬─────────────┘
│ 栈空时
↓
┌─────────────────────────┐
│ 微任务队列 Microtask │ ← 全部清空
└───────────┬─────────────┘
↓
┌─────────────────────────┐
│ 渲染(如果需要) │
└───────────┬─────────────┘
↓
┌─────────────────────────┐
│ 宏任务队列 Macrotask │ ← 只取一个
└───────────┬─────────────┘
└──→ 回到微任务检查
一轮事件循环的顺序:
- 执行同步代码(这本身是一个宏任务)
- 调用栈空了之后,清空所有微任务(执行过程中新产生的微任务也要在本轮清空)
- 必要时渲染页面
- 从宏任务队列取一个宏任务执行
- 回到第 2 步
核心区别:微任务是「清空队列」,宏任务是「每轮一个」。
宏任务与微任务的分类
| 类型 | 浏览器 | Node |
|---|---|---|
| 宏任务 | setTimeout、setInterval、I/O、UI 渲染、事件回调、MessageChannel | 同上 + setImmediate |
| 微任务 | Promise.then/catch/finally、queueMicrotask、MutationObserver | 同上 + process.nextTick(优先级更高) |
requestAnimationFrame 既不是宏任务也不是微任务 —— 它在「渲染前」执行,属于渲染流水线的一部分。requestIdleCallback 在浏览器空闲时执行。
async/await 的本质是 Promise + generator 的语法糖:
async function f() {
console.log(1)
await g() // 等价于 g().then(() => { 后面的代码 })
console.log(2) // 这行进微任务队列
}
所以 await 后面的代码相当于放进了 .then() 里 —— 这是做输出顺序题的关键转换。
🔥 经典输出顺序题
console.log('1')
setTimeout(() => {
console.log('2')
Promise.resolve().then(() => console.log('3'))
}, 0)
new Promise((resolve) => {
console.log('4') // 注意:Promise 的构造函数是同步执行的
resolve()
}).then(() => console.log('5'))
async function f() {
console.log('6')
await null
console.log('7')
}
f()
setTimeout(() => console.log('8'), 0)
console.log('9')
答案:1 4 6 9 5 7 2 3 8
逐步推:
| 阶段 | 执行 | 说明 |
|---|---|---|
| 同步 | 1 | — |
| 同步 | 4 | Promise 构造函数是同步的,常见陷阱 |
| 同步 | 6 | async 函数体在遇到第一个 await 前是同步的 |
| — | await null | 后续代码进微任务队列 |
| 同步 | 9 | — |
| 微任务 | 5 | .then 先注册,先执行 |
| 微任务 | 7 | await 后的代码 |
| 宏任务1 | 2 | 第一个 setTimeout |
| 微任务 | 3 | 宏任务执行完立刻清空微任务,不等下一个宏任务 |
| 宏任务2 | 8 | — |
:::tip 做这类题的三条规则
- Promise 构造函数同步执行,只有
.then的回调是微任务 await x之后的代码 =.then(() => ...),先把 async 函数转写成 Promise 链- 每个宏任务执行完都要清空微任务队列,不是所有宏任务跑完才处理微任务 :::
await 的一个细节:await 一个 thenable 对象(有 .then 方法)比 await 普通值多两个微任务周期(需要额外的 PromiseResolve 和 .then 调用)。老版本规范里 await 普通值也要 3 个 tick,Node 12 / V8 7.2 后优化成了 1 个。这个细节偶尔会出现在压轴题里。
Promise 原理
三个状态:pending → fulfilled / rejected。状态不可逆,一旦改变就固定。
关键特性:
// 1. then 返回新的 Promise,所以可以链式调用
p.then(a).then(b) // b 收到的是 a 的返回值
// 2. then 里返回 Promise 会被自动展开(等它完成)
p.then(() => fetch(url)).then(res => ...)
// 3. 错误会沿链向下冒泡,直到被 catch
p.then(a).then(b).catch(e => ...) // a 或 b 抛错都会进这个 catch
// 4. catch 之后链路恢复,后面的 then 仍会执行
p.catch(e => 'fallback').then(v => console.log(v)) // 'fallback'
静态方法对比(高频):
| 方法 | 行为 | 返回 |
|---|---|---|
all | 任一失败就立刻失败,全部成功才成功 | 结果数组(顺序和输入一致) |
allSettled | 永不失败,等全部结束 | [{status, value/reason}] |
race | 第一个敲定(成功或失败)的决定结果 | 那一个的结果 |
any | 第一个成功的决定结果,全失败才失败 | 第一个成功值 / AggregateError |
用途区分:并发请求且都必须成功用 all;都要结果不管成败用 allSettled(最常用);超时控制用 race;多个备选源取最快可用的用 any。
finally 的特点:不接收参数,且透传上游的值/错误(除非它自己抛错或返回 rejected Promise)。
错误处理
// ✅ async/await 用 try/catch
try {
const r = await fetch(url)
} catch (e) { /* 处理 */ }
// ❌ 这样捕获不到异步错误
try {
setTimeout(() => { throw new Error('x') }, 0)
} catch (e) { /* 永远不会执行 */ }
为什么:try/catch 只能捕获同步执行栈里的错误。setTimeout 的回调在新的宏任务里执行,那时 try 块早已出栈。
全局兜底:
window.addEventListener('error', e => {}) // 同步错误 + 资源加载错误
window.addEventListener('unhandledrejection', e => {}) // 未捕获的 Promise 拒绝
资源加载错误(图片 404)不冒泡,所以 window.onerror 捕获不到,必须用 addEventListener('error', fn, true) 在捕获阶段监听。这是前端监控 SDK 的必备知识点。
Node 的事件循环
和浏览器不同,Node 的事件循环分六个阶段:
timers → setTimeout / setInterval 的回调
pending → 上一轮延迟的 I/O 回调
idle, prepare → 内部使用
poll → 取新的 I/O 事件,执行 I/O 回调(大部分时间在这里)
check → setImmediate 的回调
close → close 事件的回调
两个 Node 特有的点:
process.nextTick优先级高于所有微任务,包括Promise.then。它有自己的队列,在每个阶段切换时清空setImmediatevssetTimeout(fn, 0)——setImmediate在 check 阶段,setTimeout在 timers 阶段。在 I/O 回调里setImmediate总是先执行;在主模块里顺序不确定(取决于进程启动耗时是否超过 1ms)
Node 11 之后对齐了浏览器行为 —— 每个宏任务执行完就清空微任务队列。11 之前是把同一阶段的所有宏任务执行完才清微任务,所以老文章里的输出顺序结论可能和现在不同。
其他异步话题
setTimeout(fn, 0) 实际延迟多少? 规范规定最小 4ms(嵌套层级超过 5 层后)。而且即使到期,也要等当前宏任务和微任务都执行完,所以实际延迟可能远大于设定值。
requestAnimationFrame 为什么比 setTimeout 适合动画? 它和浏览器的刷新率同步(通常 60fps,每 16.7ms 一次),在渲染前执行,不会出现「一帧内执行多次」或「跳帧」;页面不可见时自动暂停,省电。
怎么实现一个不卡顿的大数据渲染? 时间切片 —— 用 requestIdleCallback 或 MessageChannel 把任务拆成小块,每块执行后让出主线程。React 的并发渲染就是这个思路(见React 原理)。注意不要用 setTimeout 做切片,它有 4ms 最小延迟,拆成 1000 块就白白多出 4 秒。
Web Worker 能做什么? 真正的多线程,适合大量计算(图像处理、加解密、大数据解析)。限制是访问不到 DOM 和 window,通过 postMessage 通信(数据是结构化克隆的副本,大数据可用 Transferable 零拷贝转移)。