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 派发,最终被数据采集模块接收并上报。整个过程就像一条流水线:

  1. 业务代码:调用 emit('track', { event: 'click', page: 'home' })
  2. EventBus:将事件分发给所有订阅了 track 的监听器
  3. 采集模块:收到事件后,进行数据清洗、格式化、批量打包
  4. 上报模块:将打包后的数据发送到服务端

下面这张图展示了全局事件流的完整链路:

全局事件流链路 业务模块 A 业务模块 B 业务模块 C EventBus 发布-订阅调度 单例全局唯一 数据采集模块 清洗 · 格式化 · 打包 上报模块 → 服务端 💡 业务模块只需 emit,无需关心下游如何处理。 采集与上报完全解耦,可独立替换或升级。

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。别看简单,用好它们,整个埋点系统的架构会清晰很多。