模块间通信模式:事件总线与发布订阅

做前端项目,尤其是中大型应用,模块间的通信总是一个绕不开的话题。我记得刚带团队那会儿,经常看到不同模块之间相互直接调用对方的方法,形成了一团乱麻的依赖关系。改一个模块,其他三个跟着报错,那滋味,你想想看。

解决这类问题,业界常用的两套思路就是事件总线和发布订阅。说白了,它们都是在模块之间加一层“中间人”,让模块不再面对面喊话,而是通过这个中间人传话。今天我就结合自己的实战经验,把这两种模式掰开了讲清楚。

核心思想:通过解耦发送者和接收者,让模块间的依赖关系从“网状”变成“星状”或“通道状”。

事件总线模式

事件总线,你可以把它想象成一个全局的邮局。任何一个模块都可以往这个邮局寄信(触发事件),也可以在这个邮局订阅信件(监听事件)。所有信件都经过这个中央邮局中转。

我在项目中最早接触的就是这种模式。当时我们做了一个数据看板,十几个图表组件需要联动。用事件总线来管理,确实清爽了很多。它的典型实现长这样:

class EventBus {
  constructor() {
    this._handlers = Object.create(null);
  }

  on(event, handler) {
    (this._handlers[event] ||= []).push(handler);
    return this;
  }

  off(event, handler) {
    const list = this._handlers[event];
    if (list) this._handlers[event] = list.filter(h => h !== handler);
    return this;
  }

  emit(event, ...args) {
    const list = this._handlers[event];
    if (list) list.forEach(h => h(...args));
    return this;
  }
}

const bus = new EventBus();
// 模块A
bus.on('data:update', (payload) => { /* 更新视图 */ });
// 模块B
bus.emit('data:update', { id: 1, value: 42 });

这套 API 看起来很简单,对吧?它的好处是上手快,任何地方都能用。但我要说一句,简单也意味着风险。全局可访问的特性,很容易被滥用。我曾经接手过一个项目,整个代码库里到处是 bus.emit 和 bus.on,事件名全靠字符串硬编码,改一个事件名要全局搜索替换,相当痛苦。

我的习惯:给所有事件名定义一个常量枚举文件,避免魔法字符串。这样重构时能少踩很多坑。

发布订阅模式

发布订阅比事件总线更进了一步。它引入了事件通道(Channel)的概念。发布者不直接把消息发给订阅者,而是发送到指定通道;订阅者也只监听自己感兴趣的通道。双方完全不知道对方的存在。

你可能会问:“这和事件总线有啥区别?” 说实话,在代码实现上它们非常相似,甚至可以用同一套代码。但架构理念不同:事件总线是一个全局的单一实例,而发布订阅强调“通道”的隔离性——你可以有多个通道,每个通道独立管理自己的事件和订阅者。

class PubSubChannel {
  constructor() {
    this._subscribers = [];
  }

  subscribe(handler) {
    this._subscribers.push(handler);
    return () => this._subscribers = this._subscribers.filter(h => h !== handler);
  }

  publish(data) {
    this._subscribers.forEach(h => h(data));
  }
}

// 创建独立通道
const orderChannel = new PubSubChannel();
const logChannel = new PubSubChannel();

// 订单模块订阅
const unsub = orderChannel.subscribe(order => { /* 处理订单 */ });
// 日志模块订阅
logChannel.subscribe(log => { /* 写日志 */ });

// 支付模块发布
orderChannel.publish({ id: 1001, total: 299 });

看到区别了吗?每个通道是独立实例,你可以在不同业务域中使用不同的通道,互不干扰。我个人在大型项目中更偏向这种方式,因为它天然形成了命名空间,不容易出现事件名冲突。

两种模式对比

我把它们的核心差异整理成了一个表格。这样对比着看,会更清楚:

维度 事件总线 发布订阅
通信方式 全局集中式 通道隔离式
耦合度 模块与总线耦合 模块与通道解耦
事件管理 单一命名空间,易冲突 多通道,天然隔离
扩展性 中等,全局易膨胀 好,支持按域拆分
适用场景 中小型项目、快速原型 大型项目、多团队协作
调试难度 中等,全局追踪 较低,通道独立定位

从表格能看出来,发布订阅在大型项目中的优势更明显。但这不是说事件总线就不好。我个人觉得,没有银弹,关键看场景。如果你做的是一个工具类库或者小型应用,事件总线的简洁反而更高效。

模块间通信模式对比 事件总线模式 事件总线 模块 A 模块 B 模块 C 模块通过总线发布 / 监听事件 所有通信经过中央节点 发布订阅模式 事件通道 发布者 订阅者 A 订阅者 B 订阅者 C 发布者 / 订阅者通过通道解耦 双方互不知晓对方存在 解耦程度:事件总线 < 发布订阅

实战:封装一个可靠的事件总线

理论说完了,咱们来点实际的。我把自己在项目中经常用的增强版事件总线分享出来。它比基础版多了类型提示和一次性监听的支持:

class SafeEventBus {
  #events = new Map();

  on(name, fn, once = false) {
    if (!this.#events.has(name)) this.#events.set(name, []);
    this.#events.get(name).push({ fn, once });
    return () => this.off(name, fn);
  }

  once(name, fn) {
    return this.on(name, fn, true);
  }

  off(name, fn) {
    const list = this.#events.get(name);
    if (list) {
      this.#events.set(name, list.filter(item => item.fn !== fn));
    }
  }

  emit(name, ...args) {
    const list = this.#events.get(name);
    if (!list) return;
    const remaining = list.filter(item => {
      item.fn(...args);
      return !item.once;
    });
    if (remaining.length === 0) this.#events.delete(name);
    else this.#events.set(name, remaining);
  }

  clear() {
    this.#events.clear();
  }
}

// 使用示例
const bus = new SafeEventBus();
const cleanup = bus.on('user:login', (user) => console.log('欢迎', user.name));
bus.once('init:done', () => console.log('仅执行一次'));
bus.emit('user:login', { name: '张三' });

这里用 #events 私有字段防止外部直接篡改事件列表,once 方法支持一次性监听,返回值 cleanup 函数方便取消订阅。这些都是我在实际项目中反复踩坑后总结出来的。

我曾经踩过的坑:
  • 内存泄漏:模块销毁时忘记调用 off,导致监听器遗留在总线上。我后来强制要求每个组件在 unmount 时执行清理函数。
  • 事件名冲突:团队多人协作时,不同模块用了相同的事件名。解决方案是用命名空间前缀,比如 order:paid、user:logout。
  • 循环触发:模块A监听事件X,模块B也监听事件X,A emit 事件Y导致B emit 事件X,又回到A。我的做法是加入触发堆栈检测,超过3层就告警。

如何选择?我的判断标准

嗯,这个问题经常有同事问我。我一般会看三个维度:

  1. 项目规模:3~5个模块的小项目,事件总线完全够用。超过10个模块,我建议上发布订阅。
  2. 团队人数:单人开发随便选。多人协作时,发布订阅的通道隔离能减少相互干扰。
  3. 未来变化:如果预期业务会快速增长,一开始就按发布订阅来组织,后续重构成本更低。

我个人现在的习惯是:优先用发布订阅的思想来设计通信层,但实现上可以复用事件总线的代码。说白了,把“总线”拆成多个命名空间,本质上就是发布订阅了。你想想看,是不是这个道理?

最后说一句,不管选哪种模式,一定要控制事件的数量和粒度。我曾经见过一个项目,一屏有40多个事件来回飞,调试时根本不知道谁触发了谁。适当合并同类事件、减少不必要的数据传递,比纠结用哪种模式更重要。