跳到主要内容

事件循环与异步

前端面试里最常考、也最容易考出区分度的一块。几乎必有一道「输出顺序」的题。

🔥 为什么需要事件循环​

JS 是单线程的 —— 因为它要操作 DOM,多线程同时改同一个节点需要锁机制,对脚本语言代价太高。

但单线程意味着一个耗时任务会卡死整个页面。解法是把耗时操作交给宿主环境(浏览器 / Node)去做,完成后把回调放进队列,等主线程空了再执行 —— 这就是事件循环。

关键认知:setTimeout、fetch、DOM 事件这些都不是 JS 引擎提供的,它们是宿主环境(Web APIs)的能力。JS 引擎只负责执行代码和管理调用栈。

执行机制​

┌─────────────────────────┐
│ 调用栈 Stack │ ← 同步代码在这里执行
└───────────┬─────────────┘
│ 栈空时
↓
┌─────────────────────────┐
│ 微任务队列 Microtask │ ← 全部清空
└───────────┬─────────────┘
↓
┌─────────────────────────┐
│ 渲染(如果需要) │
└───────────┬─────────────┘
↓
┌─────────────────────────┐
│ 宏任务队列 Macrotask │ ← 只取一个
└───────────┬─────────────┘
└──→ 回到微任务检查

一轮事件循环的顺序:

  1. 执行同步代码(这本身是一个宏任务)
  2. 调用栈空了之后,清空所有微任务(执行过程中新产生的微任务也要在本轮清空)
  3. 必要时渲染页面
  4. 从宏任务队列取一个宏任务执行
  5. 回到第 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—
同步4Promise 构造函数是同步的,常见陷阱
同步6async 函数体在遇到第一个 await 前是同步的
—await null后续代码进微任务队列
同步9—
微任务5.then 先注册,先执行
微任务7await 后的代码
宏任务12第一个 setTimeout
微任务3宏任务执行完立刻清空微任务,不等下一个宏任务
宏任务28—

:::tip 做这类题的三条规则

  1. Promise 构造函数同步执行,只有 .then 的回调是微任务
  2. await x 之后的代码 = .then(() => ...),先把 async 函数转写成 Promise 链
  3. 每个宏任务执行完都要清空微任务队列,不是所有宏任务跑完才处理微任务 :::

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 特有的点:

  1. process.nextTick 优先级高于所有微任务,包括 Promise.then。它有自己的队列,在每个阶段切换时清空
  2. setImmediate vs setTimeout(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 零拷贝转移)。