之前在仔细的说过事件循环,但是那个事件循环是基于浏览器背景下实现的。除此之外,javascript还有一个很重要的执行环境------Node.js。在Node中,事件循环有了些许的变化,接下来就仔仔细细的看到底有什么变化。
资料基本来源于Node 官方文档的 "The Node.js Event Loop" :https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
一、明确概念
为什么 Node 需要自己的事件循环
JavaScript 是单线程的,但 Node 要用它来写服务器------服务器要同时处理成千上万的网络请求、读写文件、查数据库,这些都是耗时的 I/O 操作。如果继续单线程,服务器根本没法用。
浏览器用事件循环解决了这个问题,Node 也需要同样的机制。但 Node 的运行环境和浏览器不同(没有 DOM、没有渲染、却有大量文件和网络 I/O),所以它没有直接照搬浏览器,而是基于一个专门的 C 语言库:libuv来实现事件循环。
先看看libuv做了什么
libuv 是一个用 C 语言编写的跨平台异步 I/O 库,是 Node "单线程却不阻塞"的底层支柱。它主要做三件事:
第一,提供事件循环本身。 整个循环机制(timers → pending → poll → check → close,后面会详细说明)就是 libuv 实现的,Node 的 JS 层只是调用它。
第二,封装跨平台的异步 I/O。 不同操作系统的高效 I/O 机制不一样------Linux 用 epoll、macOS 用 kqueue、Windows 用 IOCP。libuv 把这些差异抹平,对上层提供统一接口,这样 Node 代码才能在三大平台行为一致。
第三,管理一个线程池。 这点最关键、也最容易被误解。很多人以为"Node 完全是单线程的",其实不准确------JavaScript 的执行是单线程的,但 libuv 背后有一个线程池(默认 4 个线程)。
为什么需要线程池?因为不是所有操作都有操作系统级的异步接口。网络 I/O 大多有原生异步支持(靠 epoll/kqueue/IOCP,不占线程池),但文件系统操作、DNS 解析、一些 CPU 密集的加密操作 (如 crypto.pbkdf2)没有可靠的跨平台异步方案,libuv 就把这些丢进线程池去跑,跑完再通过事件循环把回调交回主线程。
所以关于"Node 是不是单线程",准确的表述是:
Node 执行 JavaScript 的主线程是单线程的 ,但 libuv 用线程池 + 操作系统的异步机制,在背后并发处理耗时 I/O,完成后把回调塞回主线程的事件循环。这就是"单线程却非阻塞"的真相。
Node事件循环的不同阶段
浏览器的事件循环只有一个笼统的"宏任务队列",而 Node 把宏任务细分成了几个有固定执行顺序的阶段(phase)。每一轮事件循环,都会按固定顺序走过这些阶段:
┌───────────────────────────┐
┌─>│ timers │ 执行到期的 setTimeout / setInterval 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 执行上一轮延迟的系统级 I/O 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ 仅 libuv 内部使用,JS 层碰不到
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ 最核心:获取并执行 I/O 回调,必要时阻塞等待
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ 执行 setImmediate 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ 执行 close 事件回调,如 socket.on('close')
└───────────────────────────┘
(idle/prepare 是内部阶段,JS 层无法访问,日常可忽略,下面不展开):
timers(定时器阶段) :执行已到期 的 setTimeout 和 setInterval 回调。注意是"到期"才执行------定时器设定的是"至少等这么久",不是精确时间,实际由 poll 阶段控制何时回到这里。
pending callbacks(待定回调阶段) :执行一些被推迟到本轮的系统级 I/O 回调,比如某些类型的 TCP 错误(如收到 ECONNREFUSED)。这个阶段和业务代码关系不大。
poll(轮询阶段) :整个事件循环最核心的阶段 ,做两件事------获取新的 I/O 事件并执行对应回调(几乎所有 I/O 回调,如 fs.readFile、网络数据到达,都在这里执行);以及在没有其他任务时,决定是否阻塞在这里等待新的 I/O。它是事件循环"停下来等活干"的地方。
check(检查阶段) :专门执行 setImmediate 的回调。它紧跟在 poll 阶段之后。
close callbacks(关闭回调阶段) :执行各种关闭事件的回调,比如 socket.on('close', ...)。
每个阶段都有自己的先进先出(FIFO)回调队列。事件循环进入一个阶段后,会执行完该阶段队列里的回调(或达到系统上限),才进入下一阶段。走完 close 后,绕回 timers 开始新一轮。
Node中的微任务
上面讲的是"宏任务分阶段"。但 Node 里还有优先级更高的微任务 ,它们不属于任何阶段,而是在阶段之间被清空。Node 里有两类微任务,优先级还不一样:
process.nextTick队列 :优先级最高,自成一队。- Promise 微任务队列 (
.then/.catch/.finally、await之后的代码、queueMicrotask):优先级次之。
记住他们的优先级,整个 Node 事件循环差不多都能记住了:
每当事件循环执行完一个宏任务(阶段里的一个回调)后,会先清空整个
nextTick队列,再清空整个 Promise 微任务队列,然后才继续下一个宏任务或进入下一阶段。一句话优先级排序:同步代码 > process.nextTick > Promise 微任务 > 任何阶段的宏任务。
二、关键 API
Node 提供了三个安排"稍后执行"的核心 API,它们落在事件循环的不同位置,理解它们的区别是理解整个模型的关键。
setTimeout / setInterval ------ timers 阶段
setTimeout(fn, delay) 安排一个回调在至少 delay 毫秒后 执行,回调进入 timers 阶段 。setInterval(fn, delay) 类似,但每隔 delay 毫秒重复执行。
要点:delay 是"最小延迟"而非精确时间;setTimeout(fn, 0) 的 0 会被 Node 设置成最小 1ms 。它们返回的是一个 Timeout 对象 (不是数字),可以传给 clearTimeout/clearInterval 取消。
setImmediate ------ check 阶段
setImmediate(fn) 安排回调在 check 阶段 执行,也就是当前这一轮 poll 阶段结束后立即执行。它是 Node 独有的,浏览器没有。
它和 setTimeout(fn, 0) 看起来都像"尽快执行",但落点不同:一个在 check 阶段,一个在 timers 阶段。这个差别导致了它俩顺序的微妙问题(见后面的题)。
process.nextTick ------ 不属于任何阶段,优先级最高
process.nextTick(fn) 安排的回调不属于事件循环的任何阶段 ,而是在当前操作结束后、事件循环继续之前立刻执行,优先级比 Promise 微任务还高。
它强大也危险:如果你递归调用 process.nextTick,会不断往 nextTick 队列里塞任务,导致事件循环永远无法进入下一阶段 (比如永远到不了 poll),这叫"饿死 I/O"。官方因此建议------大多数情况优先用 setImmediate,它更容易推理。
一个常被误解的点:EventEmitter 的 emit 是同步的
顺带澄清一个和事件循环相关的高频误区。EventEmitter(Node 的发布-订阅基础类,server、stream 等都继承自它)的 emit() 默认是同步执行的------触发事件时,所有监听器会被立刻依次调用,不进任何队列:
js
const EventEmitter = require('node:events');
const e = new EventEmitter();
e.on('event', () => console.log('2'));
console.log('1');
e.emit('event'); // 同步调用监听器
console.log('3');
// 输出:1 2 3(不是 1 3 2)
这也解释了一个经典的坑:如果在构造函数里 emit 一个事件,此时使用者还没来得及 on 注册监听器,事件就丢了。解决办法是用 process.nextTick 把 emit 推迟到构造函数执行完、监听器绑定之后。
三、Node 与浏览器的对比
Node 和浏览器的事件循环大方向一致,但细节差异不少。详细对比如下:
事件循环机制对比
| 维度 | 浏览器 | Node |
|---|---|---|
| 底层实现 | 各浏览器引擎自己实现 | 基于 libuv |
| 宏任务组织 | 一个笼统的宏任务队列 | 细分成 timers/pending/poll/check/close 多个阶段 |
| 微任务 | Promise 微任务、MutationObserver | Promise 微任务 + 额外的 process.nextTick(优先级更高) |
| 微任务清空时机 | 每个宏任务后清空 | 每个宏任务后先清 nextTick、再清 Promise 微任务 |
| 渲染步骤 | 有(每轮可能重绘) | 无(服务端无渲染) |
核心差异一句话:浏览器是"宏任务队列 + 微任务队列"两层;Node 是"多阶段宏任务 + nextTick 队列 + Promise 微任务队列"三层,且 nextTick 优先级最高。
定时器 API 对比
setTimeout 和 setInterval 两个环境都有、用法一致,但有几处区别;此外各自有独占的 API:
| API | 浏览器 | Node | 说明 |
|---|---|---|---|
setTimeout / setInterval |
有 | 有 | 用法一致 |
| 定时器返回值 | 数字 ID | Timeout 对象(带 unref() 等方法) |
都可传给 clear 函数取消 |
| 回调去向 | 宏任务队列 | libuv 的 timers 阶段 | --- |
| 嵌套 5 层强制 4ms 最小延迟 | 有(HTML 规范) | 无 | 浏览器特有的防滥用规则 |
setImmediate |
无 | 有(check 阶段) | Node 独有 |
process.nextTick |
无 | 有(最高优先级) | Node 独有 |
requestAnimationFrame |
有(与渲染同步) | 无 | 浏览器独有,用于动画 |
要点提炼:setTimeout/setInterval 通用但返回值类型和底层调度不同;setImmediate 和 process.nextTick 是 Node 独有;requestAnimationFrame 是浏览器独有。
四、答题时间到~
下面几道题覆盖 Node 事件循环的高频考点,每题先自己推一遍,再看答案和解析。
题目 1:三类任务的优先级
js
console.log('1'); // 同步
setTimeout(() => console.log('2'), 0); // 宏任务(timers)
setImmediate(() => console.log('3')); // 宏任务(check)
Promise.resolve().then(() => console.log('4')); // Promise 微任务
process.nextTick(() => console.log('5')); // nextTick(最高优先级)
console.log('6'); // 同步
先想想:输出顺序是什么?哪些是确定的,哪些不一定?
答案:1 6 5 4 是确定的,然后 2 和 3 的顺序不确定。
逐步推演:
- 先跑同步代码:
console.log('1')和console.log('6')→ 打印 1 、6。 - 同步代码跑完、栈清空,进入微任务清算。按优先级,先清 nextTick 队列 :执行
5→ 打印 5。 - 再清 Promise 微任务队列 :执行
4→ 打印 4。 - 微任务都清完,事件循环正式开始第一轮,进入 timers 阶段。此时看那个
setTimeout(0)到期没有------0被钳到最小 1ms,而从进程启动到这一刻的耗时是飘忽不定 的:如果已 ≥ 1ms,timers 阶段执行2,2在前;如果 < 1ms,定时器没到期,跳过 timers,走到 check 阶段先执行3,3在前。
关键认知:1 6 5 4 由铁律保证(同步 > nextTick > Promise 微任务),完全确定;但在主模块顶层,setTimeout(0) 和 setImmediate 谁先是一场受启动耗时影响的赛跑,顺序不确定。 很多人会把这道题的答案背成固定的 1 6 5 4 2 3,这是不严谨的------2 3 和 3 2 都可能出现。
题目 2:setTimeout(0) vs setImmediate 在 I/O 回调里
js
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
先想想:这次 timeout 和 immediate 谁先?还是不确定吗?
答案:immediate 永远先执行,这次是确定的。
解析:区别就在于这两个定时器是在 fs.readFile 的回调里 安排的,而这个回调本身是在 poll 阶段执行的(I/O 回调都在 poll 阶段)。执行完这个回调后,看阶段顺序:
poll(当前在这)→ check(setImmediate 在这)→ ...下一轮... → timers(setTimeout 在这)
poll 阶段结束后,紧接着就是 check 阶段 ,immediate 立刻执行;而 timeout 属于 timers 阶段,得等事件循环绕完一整圈、到下一轮才轮到。所以 immediate 必然先于 timeout。
关键认知:同样两行代码,在主模块里顺序不确定(题1),在 I/O 回调里 immediate 必先(本题)------差别来自"代码在哪个阶段执行"。 poll 紧邻 check,是 immediate 在 I/O 回调里稳赢的根本原因。这也是判断这类题的通用方法:先问"这段代码运行在哪个阶段"。
题目 3:nextTick 高于 Promise
js
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
先想想:三行的输出顺序?
答案:sync → nextTick → promise
解析:
console.log('sync')是同步代码,最先执行 → sync。- 同步代码跑完,进入微任务清算。Node 里 nextTick 队列的优先级高于 Promise 微任务队列 ,所以先执行
nextTick→ nextTick。 - 再清 Promise 微任务 → promise。
关键认知:在 Node 里,process.nextTick 比 Promise.then 更"急"。 尽管两者都在"同步代码之后、下一个宏任务之前"执行,但 nextTick 自成一个更高优先级的队列,永远排在 Promise 微任务前面。这是 Node 特有的,浏览器里没有 nextTick 这一层。
题目 4:递归 nextTick 会饿死事件循环
js
const fs = require('node:fs');
fs.readFile(__filename, () => console.log('I/O 回调执行了'));
function loop() {
process.nextTick(loop); // 递归安排 nextTick
}
loop();
先想想:那句"I/O 回调执行了"会被打印吗?为什么?
答案:永远不会打印。
解析:loop 每次执行都用 process.nextTick 安排下一个 loop。回想那条规则------事件循环在进入下一阶段之前,必须先把整个 nextTick 队列清空 。但这个队列在清空的过程中,每执行一个 loop 又塞进一个新的 loop,队列永远清不完。
结果:事件循环被死死卡在"清 nextTick 队列"这一步,永远无法推进到 poll 阶段 ,于是 fs.readFile 的回调(在 poll 阶段执行)永远得不到机会。这就是"饿死 I/O"。
关键认知:process.nextTick 的高优先级是把双刃剑------递归调用会让它霸占事件循环,阻止任何阶段推进。 这也是官方建议"优先用 setImmediate"的原因:setImmediate 是宏任务(check 阶段),每轮只执行一次已排队的,不会阻止循环推进,用它做递归/切片是安全的。
题目 5:EventEmitter 的 emit 是同步的
js
const EventEmitter = require('node:events');
const e = new EventEmitter();
e.on('event', () => console.log('监听器'));
console.log('开始');
e.emit('event');
console.log('结束');
先想想:输出顺序是什么?"监听器"会不会被异步推迟?
答案:开始 → 监听器 → 结束,emit 是同步的。
解析:EventEmitter 的 emit() 默认同步 调用所有监听器------它不进任何队列,触发的那一刻就直接、立即依次执行监听器。所以顺序就是老实的从上到下:开始 → emit 立即调用监听器打印 监听器 → 结束。
关键认知:emit 是同步的,别把它当异步。 这解释了那个经典坑:在构造函数里 emit,此时监听器还没 on 上去,事件就丢了;正确做法是用 process.nextTick 把 emit 推迟到构造完成之后。