浏览器的事件循环处理的是用户交互、网络请求和渲染任务,而 Node.js 的事件循环则面对的是完全不同的场景:文件读写、网络 I/O、数据库查询、定时器等后端任务。虽然基本原理相似——都是单线程 + 异步非阻塞,但 Node.js 的事件循环在实现细节和阶段划分上要复杂得多。
6.3.1 事件循环的底层支撑:libuv
Node.js 并非从零实现事件循环,而是基于一个跨平台的异步 I/O 库——libuv。libuv 负责抽象操作系统的异步能力(如 Linux 的 epoll、macOS 的 kqueue、Windows 的 IOCP),向上层提供一个统一的事件循环接口。因此,理解 Node.js 事件循环,本质上是在理解 libuv 的事件循环模型。
6.3.2 六大阶段执行逻辑
libuv 的事件循环将一个完整的迭代周期划分为六个阶段,每个阶段都有一个 FIFO(先进先出)回调队列,专门处理特定类型的任务。下图展示了一轮循环中各阶段的顺序:
┌───────────────────────────┐
┌─>│ timers │ 执行 setTimeout、setInterval 的回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 执行延迟到下一轮循环的 I/O 回调(如某些系统操作的回调)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ 仅系统内部使用
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ 获取新的 I/O 事件;执行除了 close、timer、setImmediate 以外的回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ 执行 setImmediate 的回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │ 执行关闭事件的回调(如 socket.on('close'))
│ └─────────────┬─────────────┘
└───────────────────────────┘
下面对每个阶段展开说明:
1. timers 阶段
这个阶段用来执行由 setTimeout 和 setInterval 安排的回调。但是需要注意,这里检查的是执行时刻,而非精确的到期时刻。如果某个定时器设定的延迟是 100ms,但事件循环在 poll 阶段停留了 120ms 才进入 timers 阶段,那么该定时器回调会在此时立即执行。这就解释了为什么 Node.js 中的定时器并不严格准时。
2. pending callbacks 阶段
执行一部分被推迟到下一轮循环的 I/O 回调。比如,某些操作系统层面的 TCP 错误回调会在这里处理,而不是在 poll 阶段。
3. idle, prepare 阶段
这两个阶段是 libuv 内部使用的,开发者无法直接干预,可以忽略。
4. poll 阶段
这是事件循环最核心的部分,主要做两件事:
- 计算需要阻塞多久并等待新的 I/O 事件到来(如文件读取完成、网络数据到达)。
- 执行队列中与 I/O 相关的回调(除了 setImmediate、定时器回调和 close 回调)。
当 poll 队列为空时,事件循环会检查是否有 setImmediate 回调在等待,或者是否有到期的定时器。如果有,它会结束 poll 阶段,进入 check 或 timers 阶段;如果没有,它可能会在此阻塞等待,以节省 CPU 资源。
5. check 阶段
专门用于执行 setImmediate 的回调。setImmediate 看起来和定时器很像(延迟 0 毫秒),但它的设计意图就是在当前 poll 阶段完成后立刻执行,不会和定时器争抢。它的优先级高于 setTimeout(fn, 0),这一点在后面的优先级部分会细讲。
6. close callbacks 阶段
处理一些关闭事件的回调,例如 socket.destroy() 触发的 'close' 事件。如果网络连接或文件流被突然关闭,相关的清理工作会在此处完成。
6.3.3 process.nextTick 与微任务
除了上述六大阶段,Node.js 还有两个特殊的异步队列:process.nextTick 和 微任务(主要是 Promise 的回调)。它们并不属于 libuv 事件循环的某个阶段,而是在每个阶段转换之间被插队执行——类似于浏览器中微任务在宏任务之后、渲染之前执行的机制。
- process.nextTick 队列:当一段同步代码执行完毕,或者当前阶段的操作完成后,引擎会立即检查并清空 nextTick 队列。它的优先级甚至高于微任务。
- 微任务队列:包括 Promise.then/catch/finally 的回调。在 nextTick 队列清空后,会立即清空微任务队列。
换句话说,在 Node.js 中,每完成一个阶段的回调,都会先处理完 nextTick 和微任务,然后才进入下一个阶段。这种设计使得 process.nextTick 成为打破事件循环正常节律的利器,滥用可能导致 I/O 回调被无限推迟(开发者需谨慎使用)。
6.3.4 宏任务与微任务的优先级
将概念对应起来:
- 宏任务:setTimeout、setInterval、setImmediate、I/O 回调等。
- 微任务:Promise.then/catch/finally、queueMicrotask。
- 特殊的 nextTick:process.nextTick,比微任务更优先。
执行顺序示例:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
在 Node.js 中输出顺序为:
sync
nextTick
promise
timeout 或 immediate(顺序不确定,取决于事件循环启动时的上下文)
nextTick 和 promise 总是在同步代码后立即执行,因为它们都位于阶段转换的“插队点”。而 timeout 和 immediate 的顺序是不确定的:如果事件循环在 timers 阶段就准备就绪,setTimeout(fn, 0) 可能比 setImmediate 早执行;但在 poll 阶段之后首次运行,setImmediate 通常先于 setTimeout(fn, 0)。为确保顺序,通常使用 setImmediate 来保证在 I/O 回调之后立即执行某段逻辑。
6.3.5 与浏览器事件循环的核心差异
虽然同为事件驱动,但 Node.js 和浏览器的事件循环有本质区别:
| 对比维度 | 浏览器事件循环 | Node.js 事件循环 |
| -------------- | ---------------------------------------------- | ------------------------------------------------ |
| 阶段划分 | 无明确阶段,只有任务队列(宏任务)和微任务队列 | 分为六个明确的阶段,每个阶段有自己独立的回调队列 |
| 微任务执行时机 | 每个宏任务执行完毕后,立即清空微任务队列 | 每完成一个阶段的操作后,清空 nextTick 和微任务 |
| 渲染时机 | 可以在宏任务之间插入渲染更新 | 无渲染概念,纯粹处理异步 I/O 和定时器 |
| 特殊 API | requestAnimationFrame、MutationObserver 等 | process.nextTick、setImmediate 等 |
| 底层依赖 | 浏览器内核(如 Blink、WebKit)的调度机制 | libuv 封装的 I/O 多路复用和线程池 |
总结一句话:浏览器围绕“页面渲染”设计事件循环,Node.js 围绕“I/O 吞吐”设计事件循环。理解了这两者的异同,你在全栈开发中切换前后端异步代码时,就能清楚地预知执行顺序,避免时序 bug。