发布订阅模式的核心价值在于把事件的产生方和事件的消费方彻底拆开。比如用户登录模块只需要向外发出 login 事件,而购物车、通知中心、统计埋点各自订阅这个事件,它们完全不需要知道是谁触发了登录,也不用共享同一个对象引用。要实现这一点,关键是维护一个中央调度列表,并在事件触发时按照注册顺序调用回调。这样一个调度列表通常被称为事件中心。

从零实现一个基础事件总线
先定义最基础的接口。一个事件总线至少要提供三个方法:on 负责注册监听函数,emit 负责触发事件,off 负责移除监听。为了避免同名事件互相覆盖,通常会用一个对象保存所有事件类型,每个类型对应一个回调数组。下面是一个完整的基础实现,其中也包含了 once 方法。
class EventBus {
constructor() {
this.events = Object.create(null);
}
on(type, handler) {
if (typeof handler !== 'function') {
return this;
}
const list = this.events[type] || (this.events[type] = []);
list.push(handler);
return this;
}
emit(type, payload) {
const list = this.events[type];
if (!list || list.length === 0) {
return false;
}
// 使用副本避免在回调中修改原数组导致遍历错乱
const queue = list.slice();
for (let i = 0; i < queue.length; i++) {
try {
queue[i](payload);
} catch (error) {
console.error('事件处理函数执行出错', error);
}
}
return true;
}
off(type, handler) {
const list = this.events[type];
if (!list) {
return this;
}
if (!handler) {
delete this.events[type];
return this;
}
this.events[type] = list.filter(function(item) {
return item !== handler;
});
return this;
}
once(type, handler) {
const self = this;
function onceWrapper(payload) {
handler(payload);
self.off(type, onceWrapper);
}
onceWrapper.origin = handler;
this.on(type, onceWrapper);
return this;
}
}
这个基础实现有几处细节值得注意。第一,emit 使用 list.slice() 复制了一份数组,这样即使某个回调在内部调用了 off,也不会影响当前正在进行的遍历。第二,每个回调都用 try...catch 包裹,单个回调抛出异常不会阻塞后续回调,也不会让整个分发流程中断。第三,once 包装函数在执行后自动解除绑定,同时通过 origin 属性保留原始函数引用,使外部仍然可以按原函数取消一次性监听。
基础事件总线已经具备生产环境的最小可用能力。接下来分析另一个容易混淆的问题:它和观察者模式到底是不是一回事。
发布订阅与观察者模式的核心差异
观察者模式通常由两个角色组成:目标对象 Subject 和观察者 Observer。目标对象内部维护一个观察者列表,观察者需要事先注册到目标对象上。当目标状态改变时,它会主动调用所有观察者的 update 方法。发布订阅模式则增加了一个中间调度层,发布者只和事件中心通信,订阅者也只和事件中心通信,发布者和订阅者之间没有直接引用。
用一段观察者模式的代码来对比会更直观。
class Subject {
constructor() {
this.observers = [];
}
addObserver(observer) {
if (!this.observers.includes(observer)) {
this.observers.push(observer);
}
}
removeObserver(observer) {
this.observers = this.observers.filter(function(item) {
return item !== observer;
});
}
notify(data) {
for (let i = 0; i < this.observers.length; i++) {
this.observers[i].update(data);
}
}
}
class Observer {
constructor(name) {
this.name = name;
}
update(data) {
console.log(this.name + ' received ' + data);
}
}
const subject = new Subject();
const observerA = new Observer('A');
const observerB = new Observer('B');
subject.addObserver(observerA);
subject.addObserver(observerB);
subject.notify('hello');
从代码可以看出,Observer 必须实现约定的 update 方法,Subject 必须知道观察者对象本身。如果一个观察者还想同时监听多个目标,就需要实现多个不同的回调接口,扩展起来比较麻烦。发布订阅模式把这种强约定转变为字符串或符号事件名,任何函数只要满足参数签名就可以订阅,订阅者不需要暴露完整的对象。
用一个简单的表格梳理两者的差别。
| 对比维度 | 观察者模式 | 发布订阅模式 |
|---|---|---|
| 耦合关系 | 目标直接持有观察者引用 | 发布者和订阅者互不感知 |
| 通信方式 | 调用固定接口 | 通过事件名或消息队列 |
| 扩展性 | 观察者需实现统一接口 | 任意函数均可订阅 |
| 适用场景 | 状态变化驱动、一对多行为联动 | 跨模块解耦、多人协作的大型事件流 |
许多人把观察者模式和发布订阅模式混为一谈,主要原因是在早期的 JavaScript 实现中,事件绑定和观察者模式确实边界模糊。例如 DOM 事件模型中,按钮对象本身既是目标又是事件来源,监听器通过 addEventListener 注册,而触发时又由浏览器统一调度,这其实更接近发布订阅。Vue 2 的实例事件总线 vm.$on 和 vm.$emit 就是典型的发布订阅接口,而 Vue 的响应式系统则更接近观察者模式,因为依赖收集时会将当前的渲染函数作为订阅者精确地添加到对应属性的依赖列表中。
增强实现:命名空间、异常隔离与内存管理
在实际项目中,事件总线经常需要支持命名空间。例如一个页面同时有商品模块和订单模块,它们都可能用到 update 事件,如果直接用字符串事件名很容易冲突。可以在事件名中加入命名空间分隔符,或者把事件类型设计成路径式结构,如 cart:update 和 order:update。更高级的总线还会支持通配符订阅,例如订阅 cart:* 来监听购物车模块下的所有事件。
另一个必须面对的问题是异常隔离。基础实现虽然在 emit 中加入了 try...catch,但项目里如果把日志上报、性能埋点等异步任务也直接放入回调,就需要更细粒度的控制。增强版实现可以为每个回调增加优先级和一次性标记,并在触发前处理 once 标记,避免在回调执行后再删除时因为回调内部再次触发同一事件而产生偏差。
class EnhancedEventBus {
constructor() {
this.events = Object.create(null);
}
on(type, handler, options) {
options = options || {};
const wrapper = {
handler: handler,
priority: options.priority || 0,
once: Boolean(options.once)
};
const list = this.events[type] || (this.events[type] = []);
list.push(wrapper);
list.sort(function(a, b) {
return b.priority - a.priority;
});
return this;
}
emit(type, payload) {
const list = this.events[type];
if (!list || list.length === 0) {
return false;
}
const queue = list.slice();
for (let i = 0; i < queue.length; i++) {
const item = queue[i];
if (item.once) {
this.off(type, item.handler);
}
try {
item.handler(payload);
} catch (error) {
console.error('handler error', error);
}
}
return true;
}
off(type, handler) {
const list = this.events[type];
if (!list) {
return this;
}
if (!handler) {
delete this.events[type];
return this;
}
this.events[type] = list.filter(function(item) {
return item.handler !== handler;
});
return this;
}
}
这里使用 handler 来查找并移除一次性监听,可以保证多次注册同一个函数时仍能准确删除对应的包装对象。如果希望更严谨,可以给每个包装对象生成唯一 ID,并通过 ID 来取消,避免外部修改函数引用后导致删除失败。
内存泄漏是发布订阅模式最常见的隐性成本。在一个长生命周期的总线上,如果某个组件订阅了事件,却没有在销毁阶段调用 off,那么总线仍然持有该组件的回调函数,回调函数又可能通过闭包持有组件的 DOM 节点或其他数据,最终导致整个组件无法被垃圾回收。解决思路有两种:一是约定统一的销毁钩子,在组件卸载时集中调用 off;二是使用 WeakMap 存储事件与回调的关系,但需要注意的是,WeakMap 的键必须是对象,因此更适合按组件实例管理订阅,而不能直接用来替代字符串事件表。
Node.js 内置的 EventEmitter 也提供了类似能力,但它不是所有环境都可用,浏览器端通常需要自己封装。它的实现思路同样值得参考:将每个事件类型的监听器包装成 Object,支持 prependListener、removeAllListeners 等方法,并会在监听器数量超过默认阈值时给出警告。这个警告本身是一种保护,提醒开发者检查是否因为不断注册而没有释放导致潜在内存问题。理解了这些细节,再回头看项目中那些不起眼的 on 和 emit,就会发现它并不是简单地把函数推进数组,而是涉及调度顺序、错误隔离和资源释放的完整设计。
发布订阅模式观察者模式JavaScript事件机制修改时间:2026-09-21 09:26:53