事件循环是 JavaScript 异步编程的核心机制。浏览器和 Node.js 都基于事件循环,但两者在实现原理、阶段划分、任务优先级上有显著差异。本文将从基础概念出发,深入剖析浏览器与 Node.js 事件循环的区别,并通过代码示例帮助你在面试和实际开发中游刃有余。
一、为什么需要事件循环?
JavaScript 是单线程 语言,一次只能执行一个任务。如果遇到网络请求、文件读取、定时器等耗时操作,若采用同步阻塞的方式,程序就会卡死,页面无法响应用户操作。为了解决这个问题,JavaScript 引入了异步编程模型,而事件循环正是这套模型的底层调度机制。
简单来说,事件循环就是:
主线程不断从任务队列中取出任务执行,执行过程中产生的新的异步任务会重新放入队列,如此反复。
二、浏览器中的事件循环
浏览器环境下,事件循环的核心是宏任务(MacroTask) 和微任务(MicroTask) 两套队列。
2.1 宏任务与微任务
| 类型 | 特点 | 常见 API |
|---|---|---|
| 宏任务 | 由宿主环境(浏览器)发起的异步任务,通常较为 "重量级" | setTimeout、setInterval、I/O、UI Rendering、script(整体代码) |
| 微任务 | 由 JS 引擎发起的需要尽快执行的小任务,优先级高 | Promise.then/catch/finally、MutationObserver、process.nextTick(Node) |
执行顺序:
- 执行一个宏任务(首次执行整体
script代码) - 清空所有微任务队列
- 执行下一个宏任务(可能触发 UI 渲染)
- 重复上述过程
💡 微任务队列会在每个宏任务执行完后一次性全部清空 ,如果在微任务中继续添加微任务,它们会在同一轮清空中执行,可能导致 "无限微任务" 阻塞后续宏任务(如
setTimeout无法按时执行)。
2.2 代码解析:经典面试题
javascript
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2 end');
}
async1();
new Promise((resolve) => {
console.log('Promise');
resolve();
})
.then(() => {
console.log('promise1');
})
.then(() => {
console.log('promise2');
});
console.log('script end');
输出顺序及原理解析:
-
执行所有同步代码(属于第一个宏任务):
script startasync1 start(async 函数体同步执行)async2 end(async2 同步执行)Promise(Promise 构造器同步执行)script end
-
当前宏任务的同步代码执行完毕,开始清空微任务队列:
await async2()后面的代码相当于Promise.resolve().then(() => console.log('async1 end'))→async1 end- 第一个
then回调 →promise1 - 第二个
then回调 →promise2
-
微任务清空后,执行下一个宏任务:
setTimeout回调 →setTimeout
最终输出:
script start
async1 start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout
注意:
async/await语法糖中,await后面的代码会被包装成微任务。
三、Node.js 中的事件循环
Node.js 基于 libuv 库实现事件循环,其模型比浏览器复杂得多。它分为 6 个阶段 ,每个阶段都有自己的宏任务队列;微任务则会在每个阶段切换时 被清空,且 process.nextTick 拥有最高优先级。
3.1 六阶段概览
┌───────────────────────────┐
┌─>│ timers │ // 执行 setTimeout / setInterval 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ // 执行上一轮未完成的 I/O 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ // 仅内部使用
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ // 获取新的 I/O 事件,执行相关回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ // 执行 setImmediate 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │ // 执行关闭回调 (socket.close 等)
│ └───────────────────────────┘
3.2 各阶段详解
1️⃣ timers(定时器阶段)
执行 setTimeout 和 setInterval 中已经到期的回调。注意:即使延迟设为 0ms,也会在 timers 阶段检查执行,而非立即。
2️⃣ pending callbacks(待定回调阶段)
执行上一轮事件循环中被推迟到下一轮的 I/O 回调。例如某些系统操作(如 TCP 错误)的回调。
3️⃣ idle, prepare(空闲、准备阶段)
仅供 libuv 内部使用,开发者无需关注。
4️⃣ poll(轮询阶段)⭐ 核心阶段
- 先执行队列中已到期的 I/O 回调(如文件读取完成、网络数据到达)。
- 若队列为空:
- 如果设置了
setImmediate,则结束 poll 阶段,进入 check 阶段。 - 如果没有
setImmediate,则线程会阻塞在此处,等待新的 I/O 事件到来(减少 CPU 空转)。
- 如果设置了
- 在此期间会检查 timers 队列中是否有到期的定时器,若有则立即跳回 timers 阶段执行。
❓ 为什么 poll 阶段要阻塞?
避免主线程无意义地空转消耗 CPU,提升性能。Node.js 宁愿让线程在这里等待,直到真有 I/O 事件或定时器到期,才继续工作。
5️⃣ check(检查阶段)
执行 setImmediate 的回调。setImmediate 专门用于在 poll 阶段结束后立即执行,它的优先级高于延迟为 0 的 setTimeout。
6️⃣ close callbacks(关闭回调阶段)
执行关闭请求的回调,例如 socket.on('close', ...)、server.close() 等。
3.3 Node.js 中的微任务
Node.js 同样存在微任务,但执行时机与浏览器不同:
- 每个阶段执行完成后,会清空微任务队列。
- 微任务内部还有优先级:
process.nextTick队列 >Promise.then队列。
javascript
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
输出序列(典型情况):
nextTick
Promise
setImmediate
setTimeout
nextTick总是在当前阶段结束后立即 执行,甚至优先于Promise。setImmediate与setTimeout(0)的执行顺序在 poll 阶段为空时不确定(受性能影响),但若在 I/O 回调内部,setImmediate永远先于setTimeout。
四、浏览器 vs Node.js:核心差异对比
| 维度 | 浏览器 | Node.js |
|---|---|---|
| 实现基础 | Web API(HTML 标准) | libuv 库 |
| 事件循环模型 | 宏任务 ↔ 微任务循环 | 6 个阶段 + 每个阶段后清空微任务 |
| 宏任务队列 | 单一宏任务队列(按时间排序) | 每个阶段独立的队列(timers、poll、check 等) |
| 微任务优先级 | 统一队列,无额外分层 | process.nextTick 优先级 > Promise |
| 微任务清空时机 | 每个宏任务执行结束后 | 每个阶段结束后(即阶段切换时) |
| I/O 调度 | 依赖浏览器实现 | 基于 epoll/kqueue 等高效异步 I/O |
| 典型 API | setTimeout, Promise, MessageChannel |
setTimeout, setImmediate, process.nextTick |
| UI 渲染时机 | 微任务执行后,下一次宏任务之前 | 无 UI 渲染概念 |
五、延伸思考:poll 阶段的阻塞机制(面试高频)
问题 :Node.js 事件循环中,poll 阶段为什么要阻塞?
答案:
- 当 poll 队列为空且没有
setImmediate时,线程会阻塞等待新的 I/O 事件。 - 这样做可以避免 CPU 空转(否则会疯狂空转检查 timer 是否到期),极大提高 CPU 利用率和性能。
- 阻塞期间如果设定的 timer 到了,事件循环会立即跳出 poll 阶段,回到 timers 阶段执行回调。
六、总结
- 浏览器 的事件循环更简单:一个宏任务 + 全部微任务 → 下一个宏任务。适合处理 UI 交互和渲染。
- Node.js 的事件循环更精细:6 个阶段 + 多优先级微任务,专为高并发 I/O 场景优化。