# 事件机制 events
30 秒速记
EventEmitter就是一个发布订阅:on订阅、emit发布、off取消emit是同步的:按注册顺序一个个调监听器,全部跑完才返回error事件特殊:没人监听时emit('error')会直接抛异常,可能让进程退出- 同一事件超过
10个监听器会打MaxListenersExceededWarning,多半是监听器泄漏 - 监听器用普通函数时
this是emitter本身,箭头函数则不是
events 模块的核心是 EventEmitter,说白了就是 Node.js 内置的发布订阅实现。 一边用 on 挂监听器,另一边 emit 触发,http、stream、process 都是在它上面搭的。理解它要抓住几个点:emit 是同步调用,不会扔到下一轮事件循环;监听器按注册顺序执行;error 事件没人接会直接抛出来。线上我见得最多的问题是监听器泄漏,每次请求都往一个全局 emitter 上 on 一次又不 off,控制台出现超过 10 个监听器的警告就该查了。
理解「事件机制 events」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 event-loop 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
EventEmitter 的内部可以简化理解成一个「事件名 → 监听器数组」的表:
const EventEmitter = require('events');
const e = new EventEmitter();
e.on('data', (x) => console.log('A', x));
e.on('data', (x) => console.log('B', x));
console.log('before');
const hasListener = e.emit('data', 1); // 同步依次调用 A、B
console.log('after', hasListener);
// before
// A 1
// B 1
// after true
几条规则都能从这个结构推出来:on 往数组末尾追加,prependListener 插到最前面;emit 遍历数组同步调用,返回值表示有没有监听器;once 包了一层,调用前先把自己从数组里删掉;off / removeListener 按函数引用查找,所以传匿名函数是删不掉的。
error 事件要单独记:
const e2 = new EventEmitter();
e2.on('error', (err) => console.error('caught:', err.message));
e2.emit('error', new Error('boom')); // caught: boom
// 如果没有上面那行 on('error'),这里会直接 throw,进程退出
另外 emit 时会先拷贝一份当前的监听器数组,所以在监听器执行过程中新增或移除监听器,不影响这一次 emit,只影响下一次。
面试官追问
追问 1emitter.emit('error', new Error('x')),但没有任何 error 监听器,会怎样?
直接把这个错误抛出来,没被 try/catch 包住的话进程就崩了。所以自己写的 EventEmitter 子类、拿到的 stream,都要挂一个 error 监听器。
追问 2控制台出现 MaxListenersExceededWarning: 11 listeners added,怎么处理?
先别急着 setMaxListeners(0) 把警告关掉,它多半是在提醒你泄漏。看是哪个事件,一般是每次请求或每次组件挂载都 on 了一次却没 off,把注册挪到初始化阶段,或者在结束时成对移除。
追问 3监听器是 async 函数,里面抛错了会怎样?
emit 不会等它,也接不到这个 rejection,最后变成 unhandledRejection,新版本 Node.js 默认会让进程退出。可以 new EventEmitter({ captureRejections: true }),让 rejection 转成 error 事件,或者监听器里自己 try/catch。
追问 4监听器里用箭头函数,this 指向什么?
箭头函数没有自己的 this,指向定义时外层的 this,不是 emitter。要用 this 拿 emitter 就写普通 function,不过更推荐直接用闭包里的变量,别依赖 this。
追问 5EventEmitter 和浏览器的 EventTarget 有什么区别?
EventTarget 有事件对象、冒泡、preventDefault;EventEmitter 的 emit 能传任意多个参数,没有冒泡。另外 Node.js 里也有 EventTarget,主要给 AbortSignal 这类 Web 标准 API 用,业务代码里还是 EventEmitter 为主。
# 模块概览
30 秒速记
- 几乎所有核心模块都基于它:
stream、http.Server、net.Socket、process、child_process - 自定义:
class Foo extends EventEmitter {},记得在构造函数里调super() - 常用
API就几个:on/once/emit/off,再加listenerCount、prependListener - 读文档时看每个类列出的事件,比如
stream的data/end/error/close - 适合「一对多、不关心谁在听」的通知;需要返回值或等结果的场景用函数调用或
Promise
events 是 Node.js 里最基础的模块之一,http、stream、process 这些常用对象都是 EventEmitter 的实例。 所以学会它,等于学会了一半 Node.js 核心 API 的用法:server.on('request')、stream.on('data')、process.on('exit') 用的都是同一套机制。自己写模块时,继承 EventEmitter 就能对外暴露事件,适合「状态变了通知一下,谁关心谁来听」的场景。但它不适合需要拿返回值的调用,emit 只告诉你有没有人听,拿不到监听器的结果。
events模块是node的核心模块之一,几乎所有常用的node模块都继承了events模块,比如http、fs等。
模块本身非常简单,API虽然也不少,但常用的就那么几个,这里举几个简单例子。
面试官追问
追问 1process.on('exit') 里能做异步操作吗?比如上报日志?
不能,exit 事件里只能做同步操作,异步的回调根本没机会执行,进程就退了。要优雅退出,监听 SIGTERM / beforeExit,在那里做完异步清理再主动 process.exit()。
追问 2自己写一个继承 EventEmitter 的类,构造函数里忘了 super(),会怎样?
class 语法下,派生类构造函数里不调 super() 就访问 this 会直接抛 ReferenceError。老的 util.inherits 写法则要手动 EventEmitter.call(this),漏了会导致内部的监听器表没初始化。
追问 3模块 A 状态变了要通知 B、C、D,用事件还是直接调函数?
B、C、D 是固定的、而且 A 要关心它们的处理结果,就直接调函数,调用关系一眼能看清。如果订阅方不固定,A 不想依赖它们,用事件解耦更合适。事件用多了最大的问题是追踪困难,搜不到谁触发了谁。
追问 4emit 的返回值是什么?
布尔值,表示这个事件有没有监听器,true 代表至少调到了一个。它不是监听器的返回值,想拿结果得换成回调或 Promise。
追问 5怎么查一个 emitter 上某个事件挂了多少监听器?
emitter.listenerCount('data') 拿数量,emitter.listeners('data') 拿函数数组,emitter.eventNames() 列出所有有监听器的事件名。排查泄漏时把它们定时打出来,看数量是不是一直在涨。
# 基础例子
30 秒速记
- 六个例子覆盖了日常全部用法:单个监听、多个监听、
once、先emit后on、同步执行、移除监听 - 多个监听器按注册顺序依次执行,想插队用
prependListener once只触发一次,触发前就会被移除emit时没人听,这次事件直接丢掉,不会补发- 最容易记错的是例子 5:
emit同步执行完所有监听器才往下走
这组例子其实就讲了 EventEmitter 的五条规则,记住它们基本就不会用错。 一是监听器按注册顺序执行;二是 once 注册的只执行一次;三是事件不会缓存,emit 时还没注册的监听器收不到;四是 emit 是同步的,所有监听器跑完才会执行 emit 后面那行代码;五是移除监听器要传同一个函数引用。面试最爱问的是第四条,很多人以为事件天然是异步的,其实异步的是「什么时候
