事件循环与异步编程

22 分钟

为什么面试总爱问事件循环

事件循环不是一道只考输出顺序的题。它真正考察的是:JavaScript 单线程执行模型下,异步任务如何被调度,为什么某些代码会先执行,为什么页面会卡顿,为什么同一段异步代码放到浏览器和 Node.js 里结果可能不同。

准备面试时,最容易踩的坑有三个:

  • 把“宏任务”和“微任务”的概念背熟了,却说不清浏览器什么时候渲染。
  • 只知道 Node.js 也有事件循环,却不知道它按 phase 分阶段执行。
  • 看到 process.nextTickPromisesetTimeoutsetImmediate 混在一起时,只能靠记忆猜顺序。

这篇文章按面试复习的口径梳理:先建立通用模型,再分别看浏览器和 Node.js,最后总结高频追问。

一句话理解事件循环

事件循环可以理解为 JavaScript 运行时的调度器:同步代码先进入调用栈执行,异步任务由宿主环境接管,等条件满足后再被放回任务队列,主线程空闲时按规则取出任务继续执行。

这里有两个关键词:

  • JavaScript 引擎:负责执行同步代码,例如 V8。
  • 宿主环境:负责提供异步能力,例如浏览器的 Web APIs、Node.js 的 libuv。

JavaScript 语言本身没有 setTimeout、DOM 事件、网络 I/O 这些能力。真正决定异步任务如何排队、什么时候回到主线程的,是浏览器或 Node.js 这样的运行环境。

通用执行模型:同步任务、宏任务、微任务

面试分析时,可以先把事件循环抽象成下面的执行流程:

执行一个宏任务

清空当前产生的所有微任务

宿主环境执行必要的后续工作

进入下一轮事件循环

常见异步调度入口可以按队列优先级分成两类:

类型浏览器常见来源Node.js 常见来源
宏任务scriptsetTimeoutsetInterval、用户交互事件、网络回调setTimeoutsetInterval、I/O 回调、setImmediate、close 回调
微任务与高优先级队列Promise.thenqueueMicrotaskMutationObserverprocess.nextTick 队列、Promise.thenqueueMicrotask

微任务的优先级通常高于下一个宏任务。也就是说,一个宏任务执行结束后,运行时会先把本轮产生的微任务队列清空,再考虑执行下一个宏任务。

console.log('start')

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

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

console.log('end')

输出顺序是:

start
end
promise
timeout

原因很直接:同步代码先执行;Promise.then 进入微任务队列;setTimeout 进入宏任务队列;当前宏任务结束后先清空微任务,再执行下一个宏任务。

浏览器事件循环:重点在渲染时机

浏览器环境里,事件循环不仅要调度 JavaScript,还要协调页面渲染。一次典型循环可以理解为:

取出一个宏任务执行

清空微任务队列

如果需要渲染,执行样式计算、布局、绘制等步骤

进入下一轮宏任务

这也是为什么微任务过多会导致页面迟迟不渲染。浏览器必须等当前宏任务结束,并且微任务队列清空后,才有机会进行渲染。如果微任务不断追加新的微任务,主线程会长时间被占用,用户看到的就是页面卡住。

function loop() {
  Promise.resolve().then(loop)
}

loop()

这段代码不会像 setTimeout 那样把执行权交还给浏览器,而是持续制造微任务。结果是渲染、点击、滚动等任务都很难获得执行机会。

requestAnimationFrame 和渲染的关系

requestAnimationFrame 的回调通常会在浏览器下一次绘制前执行,适合读取或更新和动画相关的状态。它不是普通意义上的微任务,也不应简单归到 setTimeout 那类计时器任务里。

对面试表达来说,可以这样回答:

  • 微任务会在当前宏任务结束后尽快清空。
  • 浏览器通常在清空微任务后,才进入一次可能的渲染流程。
  • requestAnimationFrame 更贴近渲染管线,一般在绘制前回调。
  • setTimeout(fn, 0) 只是尽快排入后续任务,不保证紧贴下一帧渲染。

Node.js 事件循环:重点在 phases

Node.js 的事件循环由 libuv 驱动。和浏览器相比,它更强调服务端 I/O 调度,所以事件循环被拆成多个 phase。常见 phases 如下:

phase主要处理内容
timers执行到期的 setTimeoutsetInterval 回调
pending callbacks执行部分系统操作延迟到下一轮的 I/O 回调
idle, prepareNode.js 内部使用,业务代码一般不用关心
poll获取新的 I/O 事件,执行 I/O 回调;在合适条件下等待 I/O
check执行 setImmediate 回调
close callbacks执行关闭事件回调,例如 socket 的 close

按 Node.js 官方文档中业务代码最常接触的阶段,可以画成这样:

Node事件循环

Node.js 每进入一个 phase,会执行该阶段对应队列里的回调。阶段之间还会处理 process.nextTick 队列和 Promise microtask 队列,所以理解 Node.js 的关键不是只背“宏任务、微任务”,而是把 phase、nextTick 队列和微任务队列一起看。

还要注意版本差异:从 libuv 1.45.0,也就是 Node.js 20 开始,timers 只会在 poll phase 之后运行;更早版本里,timers 可能在 poll phase 前后都运行。这个变化会影响某些场景下 setTimeoutsetImmediate 的相对时机,面试或排查问题时最好先确认 Node.js 版本。

process.nextTickPromise 的优先级

在 Node.js 中,process.nextTick 是一个非常特殊的队列。它不属于 libuv 的某个 phase,优先级通常高于普通微任务队列里的 Promise.then

console.log('start')

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

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

console.log('end')

在 Node.js 中通常输出:

start
end
nextTick
promise

在一次 JavaScript 回调执行结束后,可以按下面的优先级理解 Node.js 的收尾顺序:

当前同步代码或当前回调执行完

清空 process.nextTick 队列

清空 Promise microtask 队列

继续事件循环后续阶段

process.nextTick 的风险也来自这个高优先级。如果递归创建 nextTick,可能让事件循环长时间无法进入后续 phase,I/O 回调被饿死。

function spin() {
  process.nextTick(spin)
}

spin()

生产代码里不应把 process.nextTick 当成普通的“异步延迟执行”工具滥用。它更适合做当前调用栈结束后的兼容性收尾,例如保证 API 回调异步化。

setTimeoutsetImmediate 的差异

setTimeout(fn, 0)setImmediate(fn) 都可以把回调延后执行,但它们进入的阶段不同:

  • setTimeout 回调进入 timers phase,等计时器到期后执行。
  • setImmediate 回调进入 check phase,在 poll phase 之后执行。

在主模块里直接比较,两者顺序不稳定,可能受启动耗时、系统调度、计时器精度影响:

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

setImmediate(() => {
  console.log('immediate')
})

这段代码在 Node.js 主模块中不能稳定保证谁先输出。

但如果它们出现在 I/O 回调内部,setImmediate 通常会先于 setTimeout

const fs = require('node:fs')

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

  setImmediate(() => {
    console.log('immediate')
  })
})

原因是 I/O 回调一般在 poll phase 执行。poll phase 执行完后,会进入 check phase,所以 setImmediate 更容易马上执行;而 setTimeout 要等下一轮 timers phase。

面试里回答这题时,不要只说“setImmediate 一定比 setTimeout 快”。更准确的说法是:主模块里顺序不稳定;I/O 回调里通常 setImmediate 先执行。

浏览器与 Node.js 的核心差异

把两套环境放在一起看,差异主要集中在四点:

对比点浏览器Node.js
宿主目标页面交互与渲染服务端 I/O 与任务调度
宏任务来源script、定时器、DOM 事件、网络事件定时器、I/O、setImmediate、close 回调
微任务重点PromisequeueMicrotaskMutationObserverprocess.nextTick 优先级队列、PromisequeueMicrotask
渲染时机微任务清空后,浏览器才有机会渲染没有页面渲染流程,重点是 libuv phases
特有机制requestAnimationFrame、DOM 渲染管线timers、poll、check 等 phase

一句话总结:浏览器事件循环要兼顾 JavaScript 执行和页面渲染;Node.js 事件循环要围绕 I/O 阶段调度回调。

高频输出题怎么分析

分析输出顺序时,不建议靠背答案。可以按下面四步走:

  1. 先执行所有同步代码,遇到异步 API 时标记它进入哪个队列或 phase。
  2. 当前同步代码结束后,清空微任务队列。
  3. 在 Node.js 中,先考虑 process.nextTick,再考虑 Promise 微任务。
  4. 根据运行环境选择后续规则:浏览器看下一轮任务和渲染机会,Node.js 看 libuv phase。

看一个混合例子:

console.log('A')

setTimeout(() => {
  console.log('B')
  Promise.resolve().then(() => console.log('C'))
}, 0)

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

console.log('E')

浏览器和 Node.js 中,这段代码通常都会输出:

A
E
D
B
C

解释过程是:AE 是同步输出;D 是当前宏任务结束后的微任务;B 是后续计时器回调;C 是计时器回调内部创建的微任务,会在该回调结束后执行。

生产环境里的常见问题

事件循环不是只存在于面试题里。线上页面卡顿、Node.js 接口抖动,都可能和它有关。

浏览器:长任务阻塞渲染

如果一次点击事件里执行大量同步计算,浏览器无法及时处理后续输入,也无法进入渲染流程。用户感知就是按钮点了没反应、动画掉帧。

常见处理方式:

  • 把大任务拆小,分批交还主线程。
  • 使用 Web Worker 承担 CPU 密集计算。
  • 动画相关更新优先考虑 requestAnimationFrame
  • 避免无边界的微任务递归。

Node.js:CPU 任务阻塞 I/O

Node.js 适合 I/O 密集场景,但不适合在主线程里长时间执行 CPU 密集任务。同步加密、大 JSON 解析、大量正则回溯,都可能拖慢整个进程的 I/O 响应。

常见处理方式:

  • 避免在请求链路里执行重 CPU 同步任务。
  • 大计算放到 Worker Threads、独立进程或专门服务。
  • 对递归 nextTick、递归微任务保持警惕。
  • 通过监控 event loop delay 判断主线程是否被阻塞。

Promise 原理与核心 API

事件循环决定了异步任务的调度顺序,而 Promise 是 JavaScript 中管理异步操作的核心机制。理解 Promise 的状态机、链式调用和微任务入队时机,是分析异步执行顺序的基础。

三种状态与不可逆转换

Promise 有且只有三种状态:

  • pending:初始状态,等待结果
  • fulfilled:操作成功完成
  • rejected:操作失败

状态一旦从 pending 变为 fulfilled 或 rejected,就不可再改变。这就是为什么下面的代码只会输出 'first'

const p = new Promise((resolve, reject) => {
  resolve('first');
  resolve('second'); // 无效,状态已锁定
  reject('error');   // 无效
});
p.then(console.log); // 'first'

链式调用的关键:then 返回新 Promise

.then() 不是在原 Promise 上操作,而是返回一个全新的 Promise。这个新 Promise 的状态由回调函数的返回值决定:

Promise.resolve(1)
  .then(val => val + 1)           // 返回普通值 → 新 Promise 以该值 fulfilled
  .then(val => Promise.reject(val)) // 返回 rejected Promise → 新 Promise rejected
  .catch(err => err + 1)          // catch 也返回新 Promise,以回调返回值 fulfilled
  .then(console.log);             // 输出 3

面试追问:then 的回调中抛错会怎样?

回调中 throw 的异常会被自动 catch,等效于返回 Promise.reject(thrownError)

静态方法对比

方法行为适用场景
Promise.all全部成功才成功,一个失败就失败并行请求,全部成功才继续
Promise.allSettled等所有 Promise 落定,不管成功还是失败批量操作,需要知道每个结果
Promise.race取第一个落定的结果(无论成功失败)超时控制、竞速场景
Promise.any取第一个成功的,全部失败才失败多源竞速,只需一个成功
// 用 race 实现请求超时
function fetchWithTimeout(url, ms) {
  return Promise.race([
    fetch(url),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Timeout')), ms)
    )
  ]);
}

微任务入队时机

Promise 的 .then/.catch/.finally 回调不是立即执行的,而是在 Promise 状态变更后被放入微任务队列。关键时序规则:

  1. 同步代码执行完毕后,才开始清空微任务队列
  2. 微任务中产生的新微任务会在当前轮次继续清空(不等下一个宏任务)
  3. .then 链上每一层都是一个新的微任务
Promise.resolve()
  .then(() => console.log(1))
  .then(() => console.log(2));

Promise.resolve()
  .then(() => console.log(3))
  .then(() => console.log(4));

// 输出:1, 3, 2, 4
// 第一轮微任务:1 和 3 入队并执行
// 它们执行后产生新微任务:2 和 4 入队并执行

async/await 的本质

async/await 是 Promise 的语法糖,但它改变了异步代码的书写方式和执行时机,面试中经常与事件循环结合出题。

await 做了什么

await 后面的表达式会被求值,然后:

  1. 如果是 Promise,等待其落定
  2. 如果是普通值,包装为 Promise.resolve(值)
  3. await 之后的代码相当于被放入 .then() 的回调中
async function foo() {
  console.log(1);
  const result = await Promise.resolve(2);
  console.log(result); // 这行相当于 .then 的回调
  console.log(3);
}

foo();
console.log(4);

// 输出:1, 4, 2, 3
// 解释:
// - console.log(1) 同步执行
// - await 暂停 foo,后续代码作为微任务入队
// - console.log(4) 同步执行
// - 微任务执行:输出 2, 3

async 函数的返回值

async 函数始终返回一个 Promise:

  • return 普通值 → Promise.resolve(值)
  • return Promise → 直接返回该 Promise(不会多包一层)
  • throw 异常 → Promise.reject(异常)
async function bar() {
  return 42;
}
bar().then(console.log); // 42

async function baz() {
  throw new Error('fail');
}
baz().catch(err => console.log(err.message)); // 'fail'

错误处理最佳实践

// 方式一:try/catch(推荐,逻辑集中)
async function loadData() {
  try {
    const user = await fetchUser();
    const orders = await fetchOrders(user.id);
    return orders;
  } catch (error) {
    // 统一处理 fetchUser 和 fetchOrders 的错误
    console.error('加载失败:', error);
    return [];
  }
}

// 方式二:.catch 后缀(适合单个 await 的错误需要特殊处理时)
async function loadUserSafe() {
  const user = await fetchUser().catch(() => null);
  if (!user) return; // 降级处理
  // 继续后续逻辑
}

经典场景题:async/await 与事件循环

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        → 同步
async1 start        → 同步(async1 被调用)
async2              → 同步(await 右侧表达式同步求值)
promise1            → 同步(new Promise 构造函数同步执行)
script end          → 同步
async1 end          → 微任务(await 后续代码)
promise2            → 微任务
setTimeout          → 宏任务(下一轮事件循环)

并行与串行的选择

// 串行:后一个依赖前一个的结果
async function serial() {
  const user = await fetchUser();
  const orders = await fetchOrders(user.id); // 必须等 user 拿到
  return orders;
}

// 并行:互不依赖的请求不要串行等待
async function parallel() {
  const [user, products] = await Promise.all([
    fetchUser(),
    fetchProducts()
  ]);
  return { user, products };
}

面试追问:为什么 for 循环中的 await 是串行的,forEach 中的 await 不符合预期?

for...of 循环中 await 会逐个等待,保持串行。而 forEach 的回调虽然是 async 函数,但 forEach 本身不会等待回调的 Promise 完成,导致所有迭代几乎同时发起:

// 串行执行
for (const url of urls) {
  await fetch(url); // 一个完成后才执行下一个
}

// 不符合预期的"伪串行"
urls.forEach(async (url) => {
  await fetch(url); // forEach 不等待,所有请求几乎同时发出
});

面试回答模板

回答“说说事件循环”时,可以按这个结构组织:

  1. JavaScript 是单线程执行,异步能力由宿主环境提供。
  2. 同步代码先执行,异步回调按规则进入任务队列。
  3. 一个宏任务结束后,会先清空微任务,再进入下一轮调度。
  4. 浏览器环境要补充渲染时机:微任务清空后才有机会渲染,requestAnimationFrame 通常在绘制前执行。
  5. Node.js 环境要补充 phases:timers、poll、check 等阶段,其中 setImmediate 在 check phase,计时器在 timers phase。
  6. Node.js 中 process.nextTick 优先级高于 Promise.then,滥用可能导致 I/O 饥饿。
  7. setTimeoutsetImmediate 的顺序要看场景:主模块里不稳定,I/O 回调里通常 setImmediate 更早。

总结

事件循环的核心不是“谁先谁后”的死记硬背,而是理解任务从哪里来、进入哪个队列、在什么时机被取出执行。

  • 浏览器侧要关注宏任务、微任务和渲染之间的关系。
  • Node.js 侧要关注 libuv phases,以及每个 phase 前后微任务的处理。
  • process.nextTickPromise 更特殊,优先级高,也更容易造成饥饿问题。
  • setTimeoutsetImmediate 不能脱离上下文比较,I/O 回调里的结论和主模块不同。
  • 工程上要警惕长同步任务、递归微任务和递归 nextTick,它们都会让事件循环失去调度机会。

面试遇到复杂输出题时,先分环境,再分同步、微任务、宏任务或 phase。只要队列归类正确,执行顺序自然就清楚了。