事件循环与异步编程
为什么面试总爱问事件循环
事件循环不是一道只考输出顺序的题。它真正考察的是:JavaScript 单线程执行模型下,异步任务如何被调度,为什么某些代码会先执行,为什么页面会卡顿,为什么同一段异步代码放到浏览器和 Node.js 里结果可能不同。
准备面试时,最容易踩的坑有三个:
- 把“宏任务”和“微任务”的概念背熟了,却说不清浏览器什么时候渲染。
- 只知道 Node.js 也有事件循环,却不知道它按 phase 分阶段执行。
- 看到
process.nextTick、Promise、setTimeout、setImmediate混在一起时,只能靠记忆猜顺序。
这篇文章按面试复习的口径梳理:先建立通用模型,再分别看浏览器和 Node.js,最后总结高频追问。
一句话理解事件循环
事件循环可以理解为 JavaScript 运行时的调度器:同步代码先进入调用栈执行,异步任务由宿主环境接管,等条件满足后再被放回任务队列,主线程空闲时按规则取出任务继续执行。
这里有两个关键词:
- JavaScript 引擎:负责执行同步代码,例如 V8。
- 宿主环境:负责提供异步能力,例如浏览器的 Web APIs、Node.js 的 libuv。
JavaScript 语言本身没有 setTimeout、DOM 事件、网络 I/O 这些能力。真正决定异步任务如何排队、什么时候回到主线程的,是浏览器或 Node.js 这样的运行环境。
通用执行模型:同步任务、宏任务、微任务
面试分析时,可以先把事件循环抽象成下面的执行流程:
执行一个宏任务
↓
清空当前产生的所有微任务
↓
宿主环境执行必要的后续工作
↓
进入下一轮事件循环
常见异步调度入口可以按队列优先级分成两类:
| 类型 | 浏览器常见来源 | Node.js 常见来源 |
|---|---|---|
| 宏任务 | script、setTimeout、setInterval、用户交互事件、网络回调 | setTimeout、setInterval、I/O 回调、setImmediate、close 回调 |
| 微任务与高优先级队列 | Promise.then、queueMicrotask、MutationObserver | process.nextTick 队列、Promise.then、queueMicrotask |
微任务的优先级通常高于下一个宏任务。也就是说,一个宏任务执行结束后,运行时会先把本轮产生的微任务队列清空,再考虑执行下一个宏任务。
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 | 执行到期的 setTimeout、setInterval 回调 |
| pending callbacks | 执行部分系统操作延迟到下一轮的 I/O 回调 |
| idle, prepare | Node.js 内部使用,业务代码一般不用关心 |
| poll | 获取新的 I/O 事件,执行 I/O 回调;在合适条件下等待 I/O |
| check | 执行 setImmediate 回调 |
| close callbacks | 执行关闭事件回调,例如 socket 的 close |
按 Node.js 官方文档中业务代码最常接触的阶段,可以画成这样:

Node.js 每进入一个 phase,会执行该阶段对应队列里的回调。阶段之间还会处理 process.nextTick 队列和 Promise microtask 队列,所以理解 Node.js 的关键不是只背“宏任务、微任务”,而是把 phase、nextTick 队列和微任务队列一起看。
还要注意版本差异:从 libuv 1.45.0,也就是 Node.js 20 开始,timers 只会在 poll phase 之后运行;更早版本里,timers 可能在 poll phase 前后都运行。这个变化会影响某些场景下 setTimeout 与 setImmediate 的相对时机,面试或排查问题时最好先确认 Node.js 版本。
process.nextTick 与 Promise 的优先级
在 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 回调异步化。
setTimeout 与 setImmediate 的差异
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 回调 |
| 微任务重点 | Promise、queueMicrotask、MutationObserver | process.nextTick 优先级队列、Promise、queueMicrotask |
| 渲染时机 | 微任务清空后,浏览器才有机会渲染 | 没有页面渲染流程,重点是 libuv phases |
| 特有机制 | requestAnimationFrame、DOM 渲染管线 | timers、poll、check 等 phase |
一句话总结:浏览器事件循环要兼顾 JavaScript 执行和页面渲染;Node.js 事件循环要围绕 I/O 阶段调度回调。
高频输出题怎么分析
分析输出顺序时,不建议靠背答案。可以按下面四步走:
- 先执行所有同步代码,遇到异步 API 时标记它进入哪个队列或 phase。
- 当前同步代码结束后,清空微任务队列。
- 在 Node.js 中,先考虑
process.nextTick,再考虑Promise微任务。 - 根据运行环境选择后续规则:浏览器看下一轮任务和渲染机会,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
解释过程是:A、E 是同步输出;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 状态变更后被放入微任务队列。关键时序规则:
- 同步代码执行完毕后,才开始清空微任务队列
- 微任务中产生的新微任务会在当前轮次继续清空(不等下一个宏任务)
.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 后面的表达式会被求值,然后:
- 如果是 Promise,等待其落定
- 如果是普通值,包装为
Promise.resolve(值) 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 不等待,所有请求几乎同时发出
});
面试回答模板
回答“说说事件循环”时,可以按这个结构组织:
- JavaScript 是单线程执行,异步能力由宿主环境提供。
- 同步代码先执行,异步回调按规则进入任务队列。
- 一个宏任务结束后,会先清空微任务,再进入下一轮调度。
- 浏览器环境要补充渲染时机:微任务清空后才有机会渲染,
requestAnimationFrame通常在绘制前执行。 - Node.js 环境要补充 phases:timers、poll、check 等阶段,其中
setImmediate在 check phase,计时器在 timers phase。 - Node.js 中
process.nextTick优先级高于Promise.then,滥用可能导致 I/O 饥饿。 setTimeout和setImmediate的顺序要看场景:主模块里不稳定,I/O 回调里通常setImmediate更早。
总结
事件循环的核心不是“谁先谁后”的死记硬背,而是理解任务从哪里来、进入哪个队列、在什么时机被取出执行。
- 浏览器侧要关注宏任务、微任务和渲染之间的关系。
- Node.js 侧要关注 libuv phases,以及每个 phase 前后微任务的处理。
process.nextTick比Promise更特殊,优先级高,也更容易造成饥饿问题。setTimeout与setImmediate不能脱离上下文比较,I/O 回调里的结论和主模块不同。- 工程上要警惕长同步任务、递归微任务和递归
nextTick,它们都会让事件循环失去调度机会。
面试遇到复杂输出题时,先分环境,再分同步、微任务、宏任务或 phase。只要队列归类正确,执行顺序自然就清楚了。