发布订阅模式是JavaScript中最常用的设计模式之一,而EventEmitter则是这个模式最具代表性的实现。无论是Node.js中的Event模块,还是浏览器端各种事件总线库,它们的核心原理都与EventEmitter一致。如果能够理解并手写一个EventEmitter,那么你会对事件机制有更深刻的认识。

在正式开始之前,需要先理清一个概念:发布订阅模式与观察者模式经常被混为一谈,两者其实有本质区别。观察者模式中,观察者直接订阅被观察者,两者之间存在依赖关系;而发布订阅模式中,发布者与订阅者不直接通信,而是通过一个事件调度中心进行解耦。EventEmitter正是这个事件调度中心的典型代表,它内部维护着一个事件名到回调函数列表的映射表。
EventEmitter的构造函数与事件存储结构
任何EventEmitter实现的核心都是一个用于存储事件的对象。构造函数中,我们可以使用Object.create(null)来创建一个无原型链的空对象,这样做可以避免与Object原型上的属性发生冲突,例如当事件名恰好是"hasOwnProperty"时不会被覆盖。当然,使用普通的{}也可以,但空对象更安全。
在每个事件名对应的数组里,存放着该事件的所有监听函数。当调用emit触发事件时,只需要从数组中取出对应的回调函数,逐个执行即可。这里有一个关键的设计细节:数组中的每一项都是最初注册时传入的listener函数。如果注册的是once包装函数,那么数组中存储的其实是这个包装函数,而原始的listener会被保存在包装函数的属性上,方便后续移除操作。
下面是一个最基础的构造函数实现:
class EventEmitter {
constructor() {
this.events = Object.create(null);
}
}这个events对象就是整个事件系统的存储核心。我们可以通过console.log看到,当注册不同事件时,events对象会动态添加属性。实际上,每次调用on方法时,都会检查events[type]是否存在,如果不存在则初始化为空数组,然后push进新的监听器。
on方法与emit方法的核心逻辑
on方法用于注册事件监听器。它的实现非常简单,但有一个细节值得注意:返回this。返回this可以让调用者进行链式调用,例如emitter.on('a', fn1).on('b', fn2),这与许多库的API设计保持一致。另外,on方法允许同一个监听函数被多次注册,每次注册都会在数组中增加一项,emit时也会执行多次。
emit方法负责触发事件。它接收事件类型和若干参数,找到对应的事件数组并遍历执行。这里有一个常见的陷阱:如果监听器内部又调用了emit或者off方法,直接遍历原数组可能会跳过某些元素。因此,很多实现中会先调用listeners.slice()复制一份数组,再对副本进行遍历。这样可以保证在emit过程中修改原数组不会影响当前批次的回调执行。
以下是on与emit的完整代码:
class EventEmitter {
constructor() {
this.events = Object.create(null);
}
on(type, listener) {
if (!this.events[type]) {
this.events[type] = [];
}
this.events[type].push(listener);
return this;
}
emit(type, ...args) {
const listeners = this.events[type];
if (listeners) {
listeners.slice().forEach(listener => {
listener.apply(this, args);
});
}
return this;
}
}这里使用listeners.slice()是出于安全性考虑。假设某个监听器内部调用了emitter.on(type, anotherFn),那么原数组会新增元素,但复制出来的副本并没有这个新元素,所以当前emit不会执行新加入的监听器。同样,如果监听器内部调用off移除了某个监听器,该监听器已经存在于副本中,所以它在本次emit中仍会被执行,但从下一个emit开始就会消失。这种“快照”机制是EventEmitter实现中的一个重要设计。
为什么需要返回this呢?除了链式调用之外,还可以让API风格更统一。例如Node.js中的EventEmitter,其on和emit都会返回this,方便在初始化时一口气绑定多个事件。在实现过程中,返回值的一致性也能避免一些因为无意中忘记return而导致的undefined错误。
off方法与once方法的边界处理
off方法用于移除事件监听器。它有两种调用形式:传入type和listener,则只移除指定的监听函数;只传入type,则移除该事件下的所有监听器。实现off方法时,需要先判断listener参数是否存在,若不存在则直接删除整个事件数组;若存在,则通过indexOf找到该监听器在数组中的位置,然后使用splice将其移除。
once方法则是注册一个一次性监听器,它会在触发一次之后自动移除。实现思路是:在on之前包一层wrapper函数,wrapper内部先调用off移除自己,然后再执行原始listener。由于wrapper中需要引用自身,因此不能使用匿名箭头函数,必须使用具名函数或const变量。同时,为了让外部能够通过off移除这个一次性监听器,我们可以将原始listener挂载到wrapper的listener属性上,并在off方法中做兼容处理。
下面是off和once的实现,其中off方法额外增加了对wrapper.listener的匹配支持:
class EventEmitter {
constructor() {
this.events = Object.create(null);
}
on(type, listener) {
if (!this.events[type]) {
this.events[type] = [];
}
this.events[type].push(listener);
return this;
}
emit(type, ...args) {
const listeners = this.events[type];
if (listeners) {
listeners.slice().forEach(listener => {
listener.apply(this, args);
});
}
return this;
}
off(type, listener) {
if (!listener) {
delete this.events[type];
return this;
}
const listeners = this.events[type];
if (listeners) {
const index = listeners.findIndex(item => item === listener || item.listener === listener);
if (index > -1) {
listeners.splice(index, 1);
}
}
return this;
}
once(type, listener) {
const wrapper = (...args) => {
this.off(type, wrapper);
listener.apply(this, args);
};
wrapper.listener = listener;
this.on(type, wrapper);
return this;
}
}这里使用了findIndex替代indexOf,是为了同时匹配直接传入的listener和包装后的wrapper。如果直接调用emitter.on('event', fn),那么数组存储的就是fn,off时匹配fn即可;如果调用的是emitter.once('event', fn),数组存储的是wrapper,off时外部传入的是fn,此时需要通过wrapper.listener来找到对应的缓存项。这样就能保证无论通过on还是once注册的监听器,都可以被正确移除。
还有一个需要特别注意的边界场景:once注册的监听器被触发时,如果执行listener的过程中抛出了异常,那么off调用还没执行?实际上,wrapper中先调用off再执行listener,即使listener抛出异常,off也已经完成了,所以监听器会被移除。这种顺序是经过慎重设计的。如果反过来先执行listener再移除,那么监听器抛错会导致off永远不执行,监听器就会一直留在数组中,造成内存泄漏。
发布订阅模式的实际应用与注意事项
EventEmitter在真实项目中应用非常广泛。例如在一个前端组件库中,可以通过EventEmitter实现组件间的松耦合通信。组件A只需要向外暴露一个事件总线,组件B和组件C分别订阅这个总线上的不同事件,当组件A的某个状态变化时,通过emit广播事件,所有订阅者就会自动收到通知,而不需要组件A直接调用B和C的方法。这种方式大大降低了组件之间的耦合度。
在Node.js环境中,EventEmitter更是无处不在。HTTP请求、流操作、进程通信等都依赖于事件驱动。Node.js的EventEmitter还提供了很多扩展方法,比如prependListener用于将监听器插入到数组头部,listenerCount用于统计监听器数量,removeAllListeners用于移除全部监听器。这些扩展方法本质上都是在基础实现上增加便利性,核心原理依然是on/emit/off/once。
使用发布订阅模式时,有几个常见的坑需要特别注意。第一,如果订阅者在服务端环境中频繁注册事件却不移除,会造成监听器数量不断增加,最终导致内存泄漏。在开发过程中,可以在事件回调中打印监听器数量,以便及时发现异常。第二,emit是同步执行的,如果某个监听器执行了耗时任务,会阻塞后续监听器的执行。如果需要异步执行,可以将回调包装在process.nextTick或setTimeout中,但这会改变顺序语义。第三,事件名需要统一管理,建议将事件名定义为常量,避免魔法字符串。
从设计层面看,EventEmitter并不是万能的。对于复杂的状态管理场景,单向数据流框架如Redux往往会比事件总线更容易调试。因为事件总线是多个发布者对应多个订阅者,事件一旦多起来,整个系统的执行顺序会变得难以追踪。因此,在使用发布订阅模式时,应当控制事件的粒度,建议将订阅关系显式建立起来,并在组件销毁时主动取消订阅。
最后再回到原理层面。理解EventEmitter实现,其实就是理解如何用一个对象维护回调函数集合,并通过函数的参数传递实现动态调用。这种模式之所以强大,是因为它把“谁发出这个事件”和“谁处理这个事件”彻底分开。只要遵守on/off/emit这三个基本API,就可以在任意规模的JavaScript应用中构建出灵活的事件机制。
发布订阅EventEmitterJavaScript修改时间:2026-08-23 12:19:05