# EventEmitter：同步通知、错误通道与监听器寿命

## 本章目标与前置

本章用团队助手的“导入进度通知”解释事件机制。前置是错误传播与事件循环：你已经知道回调何时运行和 Promise 如何报告失败，现在要回答一个更具体的问题——同一个进度变化需要通知多个观察者时，谁负责调用它们，通知是否等待它们，以及观察者何时退出。目标是能正确使用 EventEmitter，也能判断某个业务流程其实不适合使用事件。

Vue 中的组件事件通常表达子组件向父组件报告状态，浏览器 DOM 事件还带有捕获、冒泡和事件对象。Node 的 EventEmitter 是一个独立的通知容器，不自动形成组件树，也没有 DOM 冒泡规则。事件名通常是字符串或符号，参数由发送方约定。不要因为都叫 emit，就把前端事件系统的全部行为迁移过来。

## 通知模型：发送方不拥有观察者的业务结果

一个导入器可以发布“读到多少字节”“处理了几个任务”“任务结束”等通知，日志模块和进度展示模块分别订阅。发送方不需要知道每个观察者的具体实现，这降低了依赖方向的耦合。但它也失去了普通函数调用中显式的返回关系，因此事件更适合表达已经发生的事实，不适合隐式要求某个未知监听器完成关键保存操作。

如果创建任务必须先保存数据库，再返回编号，使用一个返回 Promise 的服务函数通常更清楚。若把保存工作交给 `emit('save')`，调用方拿到的布尔值不能证明数据已经保存。事件机制没有自动提供事务、重试或持久化；进程退出之后，尚未被业务保存的通知也不会自动恢复。

事件名称与参数就是接口。发送方一开始发出数字进度，后来改成一个对象，所有监听方都会受影响。可以使用明确对象字段表达语义，并对公开接口保持版本兼容。传递对象时监听器接收到的是同一引用；一个监听器修改对象，后面的监听器可能看到修改后的值。是否冻结、复制或约定只读，需要在业务边界决定。

## 核心 API 的输入、返回和错误

`new EventEmitter()` 创建实例，默认不会捕获异步监听器返回的拒绝。`on(eventName, listener)` 添加监听器并返回当前 emitter，便于链式调用；重复注册同一个函数会增加多条监听记录。`once(eventName, listener)` 添加最多执行一次的监听器，在首次触发时自动移除对应注册。监听函数必须是函数，非法输入会抛出类型错误。

`emit(eventName, ...args)` 按注册顺序同步调用监听器，并传递参数。返回 true 表示当时有监听器，false 表示没有；它不收集监听器的返回值，也不等待 async 监听器完成。同步监听器抛错会中断这次调用链，后续监听器未必执行，因此不能把通知视为天然隔离的广播。

`off(eventName, listener)` 移除匹配函数的一条注册，返回 emitter。移除时必须拿到相同函数引用，重新写一个内容相同的箭头函数不是同一对象。`listenerCount(eventName)` 返回当前监听数量，适合观察生命周期。`removeAllListeners` 会移除别人拥有的监听器，库代码不应把它当成自己的通用清理方法。

默认监听器数量告警阈值通常为十，它是发现潜在泄漏的提示，不是最多只能注册十个的硬限制。提高阈值不会释放任何内存，也不会证明原来没有泄漏。先确认每个监听器的拥有者和退出时机，再判断是否确实需要大量长期观察者。这个思路与组件卸载时移除浏览器监听器一致。

## 实验一：同步通知与一次性监听

环境 Node 22.22.0，无依赖。保存为 `emitter-sync.mjs`。实验使用数组记录执行顺序，避免把多个控制台输出的副作用混进结论。

```js emitter-sync.mjs
import { EventEmitter } from 'node:events';
import assert from 'node:assert/strict';

const importer = new EventEmitter();
const seen = [];
const log = (count) => seen.push(`log:${count}`);
importer.on('progress', log);
importer.once('progress', (count) => seen.push(`once:${count}`));
seen.push('before');
assert.equal(importer.emit('progress', 1), true);
seen.push('after');
importer.emit('progress', 2);
importer.off('progress', log);
assert.equal(importer.emit('progress', 3), false);
assert.equal(importer.listenerCount('progress'), 0);
assert.deepEqual(seen, ['before', 'log:1', 'once:1', 'after', 'log:2']);
console.log(seen.join(' -> '));
```

执行 `node emitter-sync.mjs`，预期 `before -> log:1 -> once:1 -> after -> log:2`。第一次 emit 返回之前两个监听器已经同步执行，所以 after 在它们之后。第二次触发时 once 对应注册已移除；off 再移除长期日志监听器，第三次触发返回 false。整个实验没有安排异步工作，不能因为使用了事件对象就称它为异步执行。

这里的 `log` 被保存为变量，使订阅与退订使用同一引用。真实项目可以在建立订阅时返回一个 dispose 函数，把引用封装起来，调用方只需要在生命周期结束时执行。相比让每个调用方自行记住事件名、函数与各种错误分支，集中封装更不容易遗漏。

## 特殊的 error 事件

EventEmitter 对名称为 error 的事件有特殊处理：没有对应错误监听器时发出该事件，会抛出错误，通常导致未捕获异常。普通未知事件则只是没有监听器并返回 false。这个差异是 Node 流、网络连接等 API 使用事件报告操作失败的重要约定。接入一个可能发出 error 的对象时，应在启动操作前建立错误处理。

错误监听器也不是万能保护罩。它只能接收通过这个 emitter 的 error 事件报告的失败；某个普通监听器同步 throw，并不会自动转换成同一 emitter 的 error 事件。async 监听器的拒绝在默认情况下也不会由 emit 等待。应分别理解通知、同步异常与异步拒绝三个通道。

构造时设置 `captureRejections: true`，可以让返回 Promise 的监听器拒绝进入相应的拒绝处理路径，通常转为 error 通知。这是便利功能，不会让 emit 变成返回 Promise 的工作流调度器，也不会汇总所有监听器结果。错误处理器自身不宜再使用容易拒绝的 async 实现，否则可能制造新的错误链。关键业务仍应使用显式可等待接口。

## 等待事件的 Promise 桥接

`events.once(emitter, name, options)` 与实例方法 `emitter.once(name, listener)` 不同：前者返回 Promise，成功时兑现为事件参数数组，并会在等待其他事件时处理 error 通道。可选的 `signal` 允许取消等待，取消通常以 AbortError 拒绝。它负责移除自己的监听器，不应该影响其他订阅者。

桥接必须在事件发生前建立。若一个同步函数连续发出 ready 和 done，而你先 await ready，再调用 once 等待 done，done 可能早已发出。事件不是自动缓存的历史记录；第二次订阅不会补发已经错过的通知。应先创建两个等待 Promise，再触发工作，最后一起等待。这是事件与状态查询的重要区别。

## 实验二：提前订阅多个事件并取消等待

保存为 `emitter-wait.mjs`。程序同时验证顺序订阅风险的正确解法与取消后的监听器清理，不会留下悬挂等待。

```js emitter-wait.mjs
import { EventEmitter, once } from 'node:events';
import assert from 'node:assert/strict';

const importer = new EventEmitter();
// 两个监听都先建立，再触发可能同步连续发生的通知。
const ready = once(importer, 'ready');
const done = once(importer, 'done');
importer.emit('ready', 'team-a');
importer.emit('done', 3);
assert.deepEqual(await Promise.all([ready, done]), [['team-a'], [3]]);

const controller = new AbortController();
const waiting = once(importer, 'progress', { signal: controller.signal });
// 先接住拒绝，再发出取消，避免制造未处理拒绝窗口。
const canceled = assert.rejects(waiting, { name: 'AbortError' });
controller.abort();
await canceled;
assert.equal(importer.listenerCount('progress'), 0);
assert.equal(importer.listenerCount('error'), 0);
console.log('连续事件等待与取消清理通过');
```

执行 `node emitter-wait.mjs`，预期通过。Promise 已在事件发生前注册相关监听，即使 ready 和 done 在同一同步执行过程中出现，也不会错过。取消只结束等待者的订阅，不会自动停止导入器；若导入器也需要停止读取文件，必须把信号传递到实际执行操作的那一层。

第二段先创建 `assert.rejects` 的观察，再 abort，表达“这次拒绝是预期结果”。最终检查 error 监听数量，是因为桥接在等待普通事件时也会管理错误通道。只检查 progress 监听可能漏掉辅助监听器的残留。清理应该覆盖一次操作创建的全部资源，而不只是最显眼的那条订阅。

## 长期服务为什么更容易积累监听器

一次命令行运行几秒就结束，泄漏可能被进程退出掩盖。长期服务中，每个请求若都向一个全局 emitter 注册监听，却在请求结束时忘记移除，监听器闭包会继续引用请求对象、用户数据甚至大块缓冲区。垃圾回收只能回收不可达对象，不能替你判断“这个已经不用了”。

请求正常完成、客户端取消、超时和发生错误，都应进入同一个清理出口。只在成功回调中 off，会让失败路径逐渐积累监听器。一个可以重复调用且不会破坏他人订阅的 dispose 函数，能使多条退出路径共享清理逻辑。幂等清理意味着调用一次或多次最终状态一致，不意味着业务工作本身可以任意重复执行。

还要区分对象寿命。每个短任务拥有自己的 emitter，任务及所有引用一起消失时，监听器也可能被回收；全局长寿命 emitter 则会把短寿命对象通过监听闭包挂住。分析泄漏时应从长寿命根对象出发，追踪它为何还能到达已经结束的请求，而不只是统计闭包数量。

## 练习：创建可释放的进度订阅

实现 `subscribeProgress(emitter, listener)`，返回一个可重复调用的释放函数。释放后不再收到通知，其他订阅者应继续工作。模拟一百次订阅和释放后，监听数量回到初始值。提示：保存本次注册的包装函数，只移除自己的那一条，不要调用 removeAllListeners。

<details><summary>参考答案：完整生命周期验证</summary>

```js emitter-dispose.mjs
import { EventEmitter } from 'node:events';
import assert from 'node:assert/strict';

function subscribeProgress(emitter, listener) {
  let active = true;
  const onProgress = (value) => { if (active) listener(value); };
  emitter.on('progress', onProgress);
  return () => {
    if (!active) return;
    active = false;
    emitter.off('progress', onProgress);
  };
}
const importer = new EventEmitter();
let permanent = 0;
importer.on('progress', () => { permanent += 1; });
for (let index = 0; index < 100; index += 1) {
  let local = 0;
  const dispose = subscribeProgress(importer, () => { local += 1; });
  importer.emit('progress', index);
  dispose();
  dispose();
  importer.emit('progress', index);
  assert.equal(local, 1);
  assert.equal(importer.listenerCount('progress'), 1);
}
assert.equal(permanent, 200);
// 当前 emit 已取得监听列表，释放后包装器仍需检查活动状态。
const during = new EventEmitter();
let lateCalls = 0;
let releaseDuring;
during.on('progress', () => releaseDuring());
releaseDuring = subscribeProgress(during, () => { lateCalls += 1; });
during.emit('progress', 1);
assert.equal(lateCalls, 0);
console.log('100 次释放与同一轮通知中释放均通过');
```

执行 `node emitter-dispose.mjs`，预期通过。活动状态检查还覆盖同一轮 emit 的边界：较早监听器可能先释放本订阅，但本轮分发已经取得监听器列表，单靠 off 不会撤回已经进入该列表的调用。包装器再次检查 active，才满足释放后不再通知业务函数的承诺。这里返回函数使资源所有权可见：创建订阅的一方负责释放。若接入 HTTP 请求，应把释放放到请求完成、取消或连接关闭所共有的清理路径中；若封装层接收 AbortSignal，也可以让信号触发同一 dispose，但仍要移除额外的 abort 监听器。

</details>

## 用两条时间线理解异步监听器

假设导入器发出完成事件，一个监听器开始写日志，另一个监听器更新内存计数。若写日志的监听器是 async，它在第一个 await 前仍然同步执行，随后返回 Promise；emit 忽略这个返回值，继续调用更新计数的监听器，最后返回发送方。文件真正写完是另一条以后才发生的时间线。发送方若立即退出进程，就可能在日志完成前结束。

因此需要等待的收尾应当有明确的完成句柄。例如导入服务返回结果后，入口另外等待日志刷新函数；或者把日志写入作为服务流程中的显式依赖。把这种关系隐藏在事件里，会让入口无法知道哪些工作还没完成。事件依然可以报告“任务已保存”，但关键保存步骤本身应当先有可等待的结果。

如果某个观察者失败不应该影响主业务，也不能只是忽略失败。应由观察层捕获并记录自己的失败，并限制积压数量。允许观察失败与允许无限丢失错误是不同决定；例如进度展示断开可以不影响导入，审计记录失败是否允许任务继续则需要产品和系统契约。事件库无法替你决定这些政策。

## 监听器清理如何与取消配合

一个请求可能先正常结束，随后又收到连接关闭通知；也可能超时取消与底层成功几乎同时出现。清理函数可重复调用，就能让这些竞争出口共享同一段资源释放逻辑。标记 inactive 应在移除订阅前完成，使清理期间若发生重入也不会重复执行关键步骤。这是一种小范围状态机，不需要先引入复杂框架。

但释放监听只意味着不再接收未来通知，已经开始执行的异步监听工作不会被自动撤销。它可能已经拿到数据副本并正在等待写入。若希望取消这部分工作，需要另外传递信号，并让该工作在合适边界检查信号。不要把 off 当成中止函数执行的能力，它没有这种语义。

在调试时，可以在请求开始和结束处记录本次拥有的监听器数量，配合稳定任务标识观察。不要把全部事件参数都输出到共享日志，文档内容和身份信息可能混在其中。真正用于验证清理的通常是数量、注册位置和所有权，而不是业务数据本身。

## 何时需要状态查询而不是等待下一次通知

事件描述变化发生的瞬间，状态查询描述当前事实。前端进度页面刷新后，不能只订阅下一次更新就希望恢复完整状态；如果任务早已完成，它可能再也收不到新事件。合理接口通常先读取当前任务状态，再订阅后续变化，并通过版本号或序列号处理两者之间的时间窗口。

在同一进程内部也有类似问题。模块导入得晚，可能错过初始化完成通知。与其要求所有调用方都在极短窗口注册事件，不如提供一个表示初始化结果的 Promise 或一个明确的状态查询方法。事件与状态可以配合，但不能相互假扮。把接口的时间语义说清楚，调用方才知道是否需要补偿查询。

## 事件机制的选型边界

同一进程中少量观察者接收进度，用 EventEmitter 很合适；一次操作只有一个最终结果，用 Promise 往往更直接；持续消费大量数据且需要背压，流或异步迭代通常比无限 emit 数据更合适；跨进程可靠传递任务，则需要 IPC 或持久化队列。工具之间不是新旧替代关系，而是提供不同契约。

发出进度太频繁也有成本。每个字节都通知一次，会让日志与展示工作超过真正处理成本。可以按字节阈值或时间窗口合并通知，但最终完成通知仍要准确。节流不应该让观察者永远看不到最终状态，也不应该把错误通知吞掉。把“可合并的进度”和“不可遗漏的完成结果”区分开，能形成更稳定的前后端交互。

不要在监听器里长时间阻塞。因为 emit 是同步的，一个慢监听器会延迟发送方返回，也会推迟后面的监听器。如果观察工作很重，应该明确交给异步任务，并自行处理失败与容量，而不是随手加 async 就假定问题消失。前一章关于 CPU 与等待的模型在这里仍然成立。

## 本章实际验证范围

三个文件实际通过，并额外修正和验证同一轮 emit 中较早监听器释放后续订阅的边界。取消后辅助监听归零，一百次释放不影响原订阅，同轮释放也不再通知业务函数。

## 验收、自测与参考

验收要求三个完整实验通过；你能解释 emit 的 true 为什么不代表业务成功；你能指出取消等待和取消底层任务的区别；一百次订阅释放后监听数量没有增长。自测一：EventEmitter 是否默认异步通知？答案：不是，监听器同步调用。自测二：为什么重新写一个相同箭头函数不能 off 原监听器？答案：函数引用不同。自测三：先 await 一个事件再订阅另一个，为何可能永远等不到？答案：第二个事件可能已同步发出，事件系统不会自动补历史。

完整 API 与 error、captureRejections 规则见 [Events 官方文档](https://nodejs.org/docs/latest-v24.x/api/events.html)；取消信号见 [AbortController](https://nodejs.org/docs/latest-v24.x/api/globals.html#class-abortcontroller)；与事件相关的失败边界见 [Errors](https://nodejs.org/docs/latest-v24.x/api/errors.html)。
