Node.js 事件循环和浏览器事件循环有什么区别?
简化版
Node.js 事件循环由 libuv 驱动,除了宏任务和微任务,还分 timers、poll、check、close callbacks 等阶段。Node 里 process.nextTick 优先级高于普通 Promise 微任务,setImmediate 通常在 check 阶段执行。
详细版
Node 事件循环常见阶段:
- timers:执行到期的
setTimeout、setInterval。 - pending callbacks:执行部分系统回调。
- poll:处理 I/O 回调。
- check:执行
setImmediate。 - close callbacks:执行关闭回调。
微任务方面:
process.nextTick队列优先级很高。- Promise 微任务也会在阶段切换时被清空。
浏览器更关注渲染、用户交互和宏/微任务;Node 更关注 I/O、文件、网络和 libuv 阶段。
完整版教学
一、Node 为什么需要事件循环
Node 主线程执行 JavaScript,但文件 I/O、网络 I/O、定时器等工作由底层系统和 libuv 协调。事件循环负责在合适阶段把完成的回调拿回来执行。
这让 Node 能用少量线程处理大量并发 I/O,而不是每个请求都占一个业务线程。
二、setTimeout 和 setImmediate 的区别
setTimeout(fn, 0) 会进入 timers 阶段,setImmediate(fn) 会进入 check 阶段。它们谁先执行不总是绝对,取决于当前代码是否在 I/O 回调中、事件循环所处阶段等。
在 I/O 回调内部,setImmediate 通常会比 setTimeout(0) 更先执行,因为当前 poll 阶段结束后会进入 check。
三、process.nextTick 的特殊性
process.nextTick 不属于 libuv 事件循环阶段,它会在当前操作结束后尽快执行,并且优先于 Promise 微任务。滥用 nextTick 递归会饿死事件循环,让 I/O 回调没有机会执行。它适合在 API 状态一致后异步通知调用方,不适合作为无限切分 CPU 任务的调度器。
四、面试追问与工程落地
常见追问是“Node 单线程为什么还能高并发”。准确说 JS 执行是单线程,但 I/O 不是只靠这一个线程完成。Node 把耗时 I/O 交给操作系统或线程池,主线程负责调度回调。
工程里不要在 Node 主线程做长时间 CPU 计算,否则事件循环会被阻塞,所有请求响应都会变慢。CPU 密集任务应考虑 worker_threads、进程池或外部服务。
五、阶段、微任务与执行顺序
一次 Node.js 事件循环可简化为以下阶段,阶段之间还会穿插 Node 自己的微任务检查:
timers → pending callbacks → poll → check → close callbacks
│ │
I/O 回调 setImmediate
每个 JavaScript 回调完成后,Node 会先处理 process.nextTick 队列,再处理 Promise 等微任务,然后才继续事件循环。nextTick 不属于 libuv 的某个阶段,因此递归塞入 nextTick 可以一直阻止循环回到 poll;Promise 微任务递归同样可能造成饥饿,只是二者使用不同队列。
| API | 进入的位置 | 面试中的可靠结论 |
|---|---|---|
setTimeout(fn, 0) | timers | 0 是最小阈值,不保证立刻执行 |
setImmediate(fn) | check | 在 I/O 回调内通常先于 setTimeout(0) |
process.nextTick(fn) | nextTick 队列 | 当前操作后优先处理,滥用会饿死 I/O |
Promise.then(fn) | 微任务队列 | 当前回调后、继续事件循环前处理 |
定时器只保证“不早于阈值”,不保证准点。若一个 100ms 定时器开始后,95ms 时 poll 收到 I/O,而 I/O 回调执行了 10ms,那么定时器大约要到 105ms 后才有机会运行;系统调度还可能让实际时间更晚。
六、I/O 委托、版本差异与阻塞
网络 I/O 通常由操作系统的异步能力通知 libuv,文件系统、部分 DNS、加密和压缩任务则可能使用 libuv 线程池。工作完成后,真正执行 JavaScript 回调的仍是主线程,所以“异步 API”不等于“回调不会被 CPU 长任务拖延”。
从 Node 20 使用的 libuv 1.45 开始,timers 改为在 poll 之后运行,而不是一次循环中在 poll 前后都运行。这会影响某些 setImmediate 与 timer 的边界顺序,因此面试回答应讲阶段和上下文,不应背诵一个全局固定先后关系。
CPU 长任务 / 无限微任务
↓
主线程无法回到 poll
↓
网络、文件和定时器回调全部排队
Node 的并发优势来自等待 I/O 时不占住 JavaScript 主线程;只要主线程连续计算,事件循环上的所有请求仍会一起变慢。
顺序题的推理步骤
遇到输出顺序题时,不要先背结论,而是逐层标记:
- 先执行当前同步调用栈,记录注册了哪些回调。
- 当前回调结束后,依次检查 nextTick 与 Promise 微任务。
- 标明代码处于顶层、timer 回调还是 I/O 回调。
- 再按所在阶段判断 timers、poll、check 等回调机会。
- 若题目依赖 Node 版本或操作系统调度,应明确说明结果并非跨环境绝对稳定。
这套方法既能解释常见输出题,也能避免把 I/O 回调里的稳定现象错误推广到顶层代码。
七、常见误区与追问
- 误区:
setTimeout(fn, 0)会在 0ms 后立即执行。 0 只是调度阈值,回调还要等待当前代码、微任务和对应事件循环阶段。 - 误区:Node 是单线程,所以底层 I/O 也只使用一个线程。 JavaScript 默认在主线程执行,但操作系统和 libuv 线程池会并行完成多类 I/O 工作。
- 误区:
setImmediate永远比setTimeout(0)快。 顶层代码中的顺序受环境和循环状态影响,只有在 I/O 回调内通常能稳定看到 immediate 先执行。 - 追问:为什么递归
process.nextTick会饿死 I/O? Node 会在继续事件循环前清空 nextTick 队列,回调不断补充队列就无法回到 poll。 - 追问:Promise 微任务与
process.nextTick谁先执行? 在 Node 的同一次回调边界上,nextTick 队列先于 Promise 微任务队列处理。 - 追问:CPU 密集任务应该怎么处理? 先切分或减少计算,确实需要并行时使用 worker_threads、独立进程或外部计算服务。
- 追问:浏览器和 Node 事件循环的核心区别是什么? 浏览器还要协调渲染与用户事件,Node 由 libuv 围绕 I/O 阶段调度,并提供 nextTick、check 等 Node 特有机制。
八、加强记忆
浏览器事件循环要想着“任务、微任务和渲染”,Node 事件循环要想着“libuv 阶段、I/O 和回调边界”。nextTick 队列先于 Promise 微任务,setImmediate 在 check 阶段,timer 只保证达到阈值。判断顺序时一定说明代码位于顶层还是 I/O 回调内,并留意 Node 20 之后的 timers 调度变化。