3、事件总线(EventBus)实现:发布-订阅模式、EventBus核心API设计、单例模式与全局事件流
事件总线,说白了就是埋点系统的「中央邮局」。
各个模块不需要知道谁在监听,只要把事件丢进总线,总线负责派发给所有订阅者。这种解耦方式,在埋点场景下特别实用——你想想看,埋点代码往往散落在业务逻辑的各个角落,如果每个埋点都直接调用某个全局函数,耦合度会高得吓人。
我个人习惯把 EventBus 作为埋点 SDK 的底层基础设施。它不关心业务,只负责一件事:把事件从生产者送到消费者手里。
3.1 发布-订阅模式:核心思想
发布-订阅模式,本质上是一个「事件调度中心」。
它有三个角色:
- 发布者(Publisher):触发事件的一方,不关心谁在听。
- 订阅者(Subscriber):监听事件的一方,不关心谁发的。
- 事件总线(EventBus):中间的调度器,维护事件名与回调函数的映射关系。
我在项目中遇到过一种情况:业务方在页面卸载时忘记取消订阅,导致回调函数仍然持有对已销毁组件的引用,造成内存泄漏。嗯,这里要特别注意——订阅和取消订阅必须成对出现。
核心原则:发布者与订阅者完全解耦,双方只通过事件名通信。
3.2 EventBus 核心 API 设计
一个最小可用的 EventBus,只需要四个方法:
| API | 作用 | 参数说明 |
|---|---|---|
on(event, callback) |
订阅事件 | 事件名(字符串),回调函数 |
off(event, callback) |
取消订阅 | 事件名,回调函数(可选,不传则移除该事件所有监听) |
emit(event, ...args) |
触发事件 | 事件名,可变参数列表 |
once(event, callback) |
一次性订阅 | 事件名,回调函数(触发一次后自动取消) |
来看一个具体的实现:
class EventBus {
constructor() {
this._events = {}; // 存储事件名 -> 回调数组
}
on(event, callback) {
if (!this._events[event]) {
this._events[event] = [];
}
this._events[event].push(callback);
return this; // 支持链式调用
}
off(event, callback) {
if (!this._events[event]) return this;
if (!callback) {
delete this._events[event];
return this;
}
this._events[event] = this._events[event].filter(cb => cb !== callback);
return this;
}
emit(event, ...args) {
const cbs = this._events[event];
if (!cbs || cbs.length === 0) return false;
cbs.forEach(cb => {
try {
cb(...args);
} catch (e) {
console.error(`[EventBus] 事件 ${event} 的回调执行出错:`, e);
}
});
return true;
}
once(event, callback) {
const wrapper = (...args) => {
callback(...args);
this.off(event, wrapper);
};
this.on(event, wrapper);
return this;
}
}
小技巧:emit 方法里用 try...catch 包裹每个回调,可以防止一个回调报错导致后续回调全部中断。我曾经因为这个疏忽,排查了整整一个下午。
3.3 单例模式:确保全局唯一事件流
在埋点系统中,事件总线必须是全局唯一的。如果多个模块各自创建自己的 EventBus 实例,事件就没办法跨模块通信了。
单例模式解决的就是这个问题——保证一个类只有一个实例。
实现方式有很多种,我个人最推荐用模块级变量 + 惰性初始化:
// eventBus.js
class EventBus {
// ... 同上
}
let instance = null;
export function getEventBus() {
if (!instance) {
instance = new EventBus();
}
return instance;
}
// 或者直接导出单例
export default new EventBus();
为什么不用 static getInstance()?
- 模块级变量更简洁,不需要额外的方法调用。
- ES Module 本身就是单例的,天然适合做全局状态管理。
注意:单例模式虽然方便,但也要小心「全局污染」。如果测试用例之间没有重置 EventBus,上一个用例注册的事件可能会影响下一个用例。我的做法是在测试环境的 beforeEach 中重新创建实例。
3.4 全局事件流:从埋点到数据管道
有了单例的 EventBus,我们就可以构建全局事件流了。
埋点数据从业务代码中发出,经过 EventBus 派发,最终被数据采集模块接收并上报。整个过程就像一条流水线:
- 业务代码:调用
emit('track', { event: 'click', page: 'home' }) - EventBus:将事件分发给所有订阅了
track的监听器 - 采集模块:收到事件后,进行数据清洗、格式化、批量打包
- 上报模块:将打包后的数据发送到服务端
下面这张图展示了全局事件流的完整链路:
3.5 避坑指南:我踩过的几个坑
- 事件名冲突:不同模块用了相同的事件名,导致互相干扰。我建议用命名空间,比如
track:click、track:pageview。 - 回调执行顺序:EventBus 默认按注册顺序执行回调。如果某个回调需要优先执行,可以给
on方法增加优先级参数。 - 异步回调中的错误:如果回调是异步的,
try...catch捕获不到Promise.reject。我习惯在emit中统一处理同步错误,异步错误由调用方自己 catch。 - 内存泄漏:页面跳转时忘记
off,导致旧页面的回调仍然被持有。我曾经在单页应用里遇到过,切换了十几个页面后,内存直接飙到 200MB+。
我的建议:在组件销毁的生命周期中,统一调用 eventBus.off(eventName),或者使用 once 代替 on 来避免遗忘取消订阅。
3.6 小结
EventBus 是埋点系统的「血管」。它用发布-订阅模式解耦了业务代码和数据采集,用单例模式保证了全局事件流的唯一性。
核心 API 就四个:on、off、emit、once。别看简单,用好它们,整个埋点系统的架构会清晰很多。