本节摘要:EventEmitter 是 Node 事件驱动的基石——fs 的 data 事件、http 的 connection 事件、Stream 的 close 事件,底层全是它。本节讲 on/emit/off 的语义细节、error 事件的特殊地位、监听器上限的由来,并实战一个带超时与撤销的异步任务事件类,这是写任何自定义事件模块前该抄的范本。
回调适合「一问一答」:发起一次操作,等一个结果。但很多场景是「一问多答」或「没有问、只有通知」:文件流会持续吐出 data、服务器会不断迎来 connection、任务进度会反复汇报 progress。这类多次发生、发生时机不定的信号,需要的是订阅模型——这就是 EventEmitter:
const EventEmitter = require('events'); const emitter = new EventEmitter(); emitter.on('进度', p => console.log('进度', p + '%')); setInterval(() => emitter.emit('进度', Math.random() * 100), 500);
on 注册监听器,emit 触发事件。emit 是同步的——它当场逐个调用所有监听器,不经过事件循环排队。这一点常被误解:所谓「事件驱动」驱动的不是异步,而是「注册与触发解耦」;异步性来自触发时机(比如 I/O 完成时),不来自 emit 本身。
emitter.on('a', () => console.log('监听器在 emit 调用栈里同步执行')); console.log('前'); emitter.emit('a'); console.log('后'); // 输出:前 → 监听器… → 后
const e = new EventEmitter(); e.once('首次', handler); // 只触发一次后自动移除,适合初始化类事件 e.off('首次', handler); // 精确移除(注意必须传同一个函数引用) e.removeAllListeners('首次'); // 清掉某事件的全部监听 e.prependListener('日志', h); // 插队到监听列表最前 e.listenerCount('日志'); // 数一数挂了几个
两个高频坑:
EventEmitter 有一条铁律:emit('error') 且无人监听时,进程直接抛出未捕获异常退出。普通事件没人理就没人理,error 事件没人理是致命的。所以自定义事件类必须保证 error 事件始终有出口:
class TaskRunner extends EventEmitter { async run(task) { try { const result = await task(); this.emit('done', result); } catch (err) { this.emit('error', err); // 若无人监听, 进程会崩——设计上就是故意的 } } }
这个「故意让你崩」的设计哲学值得咀嚼:错误被静默吞掉的系统比崩溃的系统更危险。与其让进程带病运行输出错数据,不如当场死给你看,交给第 7 章的进程守护拉起来。
综合运用以上知识写一个像样的范本——一个可观察的下载任务:
const EventEmitter = require('events'); class DownloadTask extends EventEmitter { constructor(url) { super(); this.url = url; this.aborted = false; this.timer = null; } start() { let received = 0; const total = 1024 * 1024; // 假装总量 1MB const tick = setInterval(() => { if (this.aborted) return; received += 64 * 1024; this.emit('progress', Math.min(100, Math.round(received / total * 100))); if (received >= total) { clearInterval(tick); clearTimeout(this.timer); this.emit('done', { url: this.url, bytes: received }); } }, 100); // 超时保护:20 秒没完成就报错 this.timer = setTimeout(() => { this.abort(); this.emit('error', new Error('下载超时')); }, 20000); return this; } abort() { this.aborted = true; this.emit('aborted', this.url); } } // 用法 const task = new DownloadTask('https://example.com/big.iso'); task.on('progress', p => process.stdout.write(`\r进度 ${p}%`)); task.on('done', info => console.log('\n完成', info.bytes, '字节')); task.on('error', err => console.error('失败:', err.message)); task.on('aborted', url => console.log('已撤销', url)); task.start();
这个类展示了几条 Node 社区的惯例:事件名用无空格的小写英文;完成类事件(done)与失败类事件(error)成对出现;状态类事件(progress/aborted)描述过程而非结果。fs 的 ReadStream、http 的 Server、第 5 章数据库连接池,全都遵守同一套惯例——学会一个 EventEmitter,等于学会读所有核心模块的文档。
⚠️ 常见坑:在监听器里再做重活会让 emit 的调用栈越叠越深(emit 是同步的)。监听器应该短小,重活交给队列或下一节讲的流去消化。
超过十个会打印警告,这只是提醒而非限制。真正的问题是监听器积累往往是「忘了清理」的症状——尤其在中断重连、测试反复初始化的场景里,监听器越积越多,同一个事件触发时回调重复执行,出现「日志打了两遍」「请求发了两次」的诡异现象。用 listenerCount 在关键节点自查,是低成本的好习惯。
一条简单的判断线:这个信号会发生几次。只发生一次的「结果」(读文件完成、查询返回)用回调或 Promise 更直接;会发生多次或不确定的「通知」(数据到达、连接建立、进度变化)用事件。把一次性结果建模成事件会把代码搞复杂,反之把持续通知塞进回调则根本表达不出来。