前端知库

Node.js 的 EventEmitter 有哪些需要注意的地方?

EventEmitter 的 emit 会同步、按注册顺序调用监听器;没有 error 监听器时 emit('error') 会直接抛出,可能让进程崩溃;同一事件超过 10 个监听器会打印 MaxListenersExceededWarning,常是泄漏的信号。events.once() 把一次事件变成 Promise,events.on() 把事件流变成异步迭代器,都会在 error 时拒绝或抛出。

参考答案

简答

Node.js 里的 HTTP 服务、流、子进程,背后都是 EventEmitter:用 on 注册监听器,用 emit 触发事件。要先知道一点,emit 是同步的,它会按注册顺序把监听器一个个调用完才返回,并不是丢到下一轮去执行。

最容易出事的是 error 事件。如果一个 emitter 没有 error 监听器,emit('error') 会直接把错误抛出来,没人捕获的话进程就崩了。所以官方建议总是给 error 事件加监听器。

还有监听器上限。同一个事件默认超过 10 个监听器,Node.js 会打印 MaxListenersExceededWarning,提示可能有内存泄漏。这不是硬限制,监听器照样能加,但它通常说明每个请求都在加监听器却没有移除。

另外 events 模块提供了两个工具函数:once() 返回一个 Promise,等到事件触发时兑现;on() 返回一个异步迭代器,可以用 for await 一条条处理事件。两者在等待时遇到 error 事件都会拒绝或抛出,on() 的循环要靠 break 或 AbortSignal 结束。

emit 是同步调用监听器的

下面的代码用 Node.js 22.23.3 运行。emit 之前和之后各打印一次,看监听器什么时候执行。

JavaScript
import { EventEmitter } from 'node:events';

const orders = new EventEmitter();
orders.on('created', (id) => console.log('记录日志', id));
orders.once('created', (id) => console.log('只发一次通知', id));

console.log('触发前');
orders.emit('created', 1); // 依次打印:记录日志 1、只发一次通知 1
console.log('触发后');
console.log(orders.emit('created', 2), orders.listenerCount('created')); // 先打印“记录日志 2”,再打印 true 1

输出顺序是“触发前、记录日志 1、只发一次通知 1、触发后”。两个监听器都在 emit 返回之前执行完了,这就是官方文档说的同步、按注册顺序调用。所以监听器里如果有很重的同步计算,会直接阻塞调用 emit 的那段代码;需要异步时,要在监听器里自己用 setImmediate() 或 process.nextTick() 推迟。

once 注册的监听器触发一次就被移除,所以第二次 emit 只剩一个监听器。emit 的返回值表示这个事件有没有监听器,有就是 true。

on、off、once 和 emit 怎样手写,以及 emit 过程中增删监听器会怎样,见《怎样手写一个支持 on、off、once 和 emit 的事件中心?》。

为什么没有 error 监听器会让进程崩溃

error 是一个特殊的事件名。官方文档规定,emitter 没有任何 error 监听器时,触发 error 事件会把错误抛出,打印堆栈后 Node.js 进程退出。

JavaScript
import { EventEmitter } from 'node:events';

const emitter = new EventEmitter();
try {
  emitter.emit('error', new Error('连接断开'));
} catch (err) {
  console.log('被抛出了', err.message); // 被抛出了 连接断开
}

emitter.on('error', (err) => console.log('已处理', err.message));
emitter.emit('error', new Error('连接断开')); // 已处理 连接断开

为什么这样设计?因为错误是不能被默默吞掉的。普通事件没人听,emit 返回 false 就算了;错误没人处理,就按异常抛出去,逼开发者正视它。所以用流、socket 这类对象时,一定要监听 error,否则一次网络断开就可能让整个服务退出。

如果只想观察错误、比如上报监控,又不想改变“没人处理就抛出”的行为,可以监听 events.errorMonitor 这个符号。它的监听器先于 error 监听器执行,但不算“处理了错误”,没有真正的 error 监听器时错误照样抛出。

监听器上限是在提醒什么

默认情况下,同一个事件的监听器超过 10 个,Node.js 会往 stderr 打印一条警告:

text
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 data listeners added to [EventEmitter]. MaxListeners is 10.

这不是硬限制,第 11 个监听器照样会加上去。它的作用是提醒:常见的错误写法是每来一个请求,就给一个全局的 emitter 加一个监听器,用完不移除,监听器越积越多,它们闭包里引用的数据也释放不掉。

所以看到这个警告,第一反应应该是找出在哪里重复添加了监听器,而不是调大上限。确实需要更多监听器时,用 emitter.setMaxListeners(n) 只改这一个实例;events.defaultMaxListeners 会影响所有实例,包括修改之前创建的,官方文档提醒要谨慎使用。启动时加 --trace-warnings 能看到是哪行代码加的监听器。排查泄漏的完整方法见《Node.js 服务内存泄漏怎样排查?》。

events.once() 和 events.on() 怎样配合 async/await

emitter.once() 是注册回调,而 events 模块的 once() 函数返回一个 Promise,适合“等某个事件发生一次”:

JavaScript
import { EventEmitter, once } from 'node:events';

const server = new EventEmitter();
const ready = once(server, 'ready');
server.emit('ready', 8000);
const [port] = await ready;
console.log(port); // 8000

Promise 兑现的值是一个数组,里面是 emit 时传的所有参数,所以要解构取值。等待期间如果触发了 error,Promise 会被拒绝,这样 try/catch 就能捕获。

on() 函数返回异步迭代器,适合一条条处理持续到来的事件:

JavaScript
import { EventEmitter, on } from 'node:events';

const ticks = new EventEmitter();
const controller = new AbortController();
process.nextTick(() => {
  ticks.emit('tick', 'a');
  ticks.emit('tick', 'b');
  controller.abort();
});

try {
  for await (const [value] of on(ticks, 'tick', { signal: controller.signal })) {
    console.log('收到', value); // 收到 a、收到 b
  }
} catch (err) {
  console.log(err.name); // AbortError
}

这个循环不会自己结束,因为事件随时可能再来。结束它有三种办法:循环里 break,传入 signal 后调用 abort()(循环会抛出 AbortError),或者用 close 选项指定某些事件触发时结束。退出循环时它会移除自己加的监听器。

官方文档还提醒,for await 的循环体是一次处理一个事件的,即使里面有 await,下一个事件也要等这一个处理完。需要并发处理时不要用它。

常见追问

EventEmitter 和浏览器的 EventTarget 有什么区别?

EventEmitter 的监听器直接收到 emit 传的参数,同一个函数可以重复注册多次,error 事件有“没人监听就抛出”的特殊规则。EventTarget 的监听器收到的是一个 Event 对象,同一个监听器对同一类型只能注册一次,error 也没有特殊行为。Node.js 也实现了 EventTarget,但没有事件冒泡这类层级传播;events.once() 对 EventTarget 同样适用。

监听器是 async 函数,里面抛错会怎样?

emit 不会等待它,官方文档指出,这时抛出的错误会变成未处理的 Promise 拒绝。可以在创建 emitter 时传 { captureRejections: true },这样被拒绝的 Promise 会转成 error 事件;或者在 async 监听器里自己 try/catch。