3. 事件总线设计模式:发布-订阅模式详解、观察者模式对比、中介者模式的应用

事件驱动架构的核心,说白了就是「谁关心谁去拿,不关心就别打扰」。我见过太多团队把组件通信写成了蜘蛛网——A 调 B,B 调 C,C 又回头调 A。最后改一个需求,牵出一堆 bug。

这一章我们就把三种最常用的模式掰开揉碎。你理解了它们,UI 架构里的通信问题就能解决八成。

3.1 发布-订阅模式:最灵活的解耦方案

发布-订阅模式,我习惯叫它「喇叭模式」。一个组件喊一嗓子,所有感兴趣的组件都能听到。喊的人不需要知道谁在听,听的人也不需要知道谁在喊。

核心三要素:

  • 事件通道(Event Channel):消息的中转站,负责存储和分发
  • 发布者(Publisher):触发事件的组件,只负责「喊」
  • 订阅者(Subscriber):监听事件的组件,只负责「听」

我在项目中遇到过这样一个场景:一个复杂的表单页面,用户填写完信息后,需要同时更新导航栏的进度、侧边栏的摘要、底部的操作按钮状态。如果每个组件都直接调用对方的方法,那代码就乱成一锅粥了。

用发布-订阅模式就清爽多了:

// 一个简单的事件总线实现
class EventBus {
  constructor() {
    this.events = {};
  }

  // 订阅事件
  on(eventName, callback) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(callback);
    
    // 返回取消订阅函数,方便清理
    return () => {
      this.events[eventName] = this.events[eventName]
        .filter(cb => cb !== callback);
    };
  }

  // 发布事件
  emit(eventName, ...args) {
    const callbacks = this.events[eventName];
    if (!callbacks || callbacks.length === 0) return;
    
    callbacks.forEach(callback => {
      try {
        callback(...args);
      } catch (error) {
        console.error(`事件 ${eventName} 执行出错:`, error);
      }
    });
  }

  // 只订阅一次
  once(eventName, callback) {
    const wrapper = (...args) => {
      callback(...args);
      this.off(eventName, wrapper);
    };
    this.on(eventName, wrapper);
  }

  // 取消订阅
  off(eventName, callback) {
    if (!this.events[eventName]) return;
    if (!callback) {
      delete this.events[eventName];
      return;
    }
    this.events[eventName] = this.events[eventName]
      .filter(cb => cb !== callback);
  }
}

// 使用示例
const bus = new EventBus();

// 导航栏订阅表单变更
const unsubNav = bus.on('form:updated', (data) => {
  updateNavProgress(data.progress);
});

// 侧边栏订阅表单变更
bus.on('form:updated', (data) => {
  updateSidebarSummary(data.summary);
});

// 表单提交时发布事件
function handleFormSubmit(formData) {
  // ...处理表单逻辑
  bus.emit('form:updated', {
    progress: 80,
    summary: formData
  });
}

我的习惯:事件名用命名空间格式,比如 module:action。这样既避免冲突,又方便调试时快速定位来源。

3.2 观察者模式 vs 发布-订阅模式

很多人把这两个模式混为一谈。其实它们有本质区别。我刚开始学的时候也搞混过,后来在重构一个老项目时才真正分清楚。

对比维度 观察者模式 发布-订阅模式
通信方式 直接通信,观察者持有目标引用 通过事件通道间接通信
耦合度 低耦合,但观察者需要知道目标 完全解耦,双方互不知晓
消息分发 目标直接通知所有观察者 事件通道负责分发
适用场景 单一数据源,多个展示层 复杂系统,跨模块通信
调试难度 相对容易,调用链清晰 较难,需要追踪事件流

观察者模式就像你直接告诉同事「这个数据变了,你更新一下」。而发布-订阅模式就像你在群里发了一条消息,谁看到了谁处理。

举个例子,一个股票行情组件:

// 观察者模式实现
class StockSubject {
  constructor() {
    this.observers = [];
    this.price = 0;
  }

  addObserver(observer) {
    this.observers.push(observer);
  }

  removeObserver(observer) {
    this.observers = this.observers
      .filter(obs => obs !== observer);
  }

  setPrice(newPrice) {
    this.price = newPrice;
    this.notify();
  }

  notify() {
    this.observers.forEach(observer => {
      observer.update(this.price);
    });
  }
}

class StockDisplay {
  update(price) {
    console.log(`当前股价: ${price}`);
  }
}

// 使用
const stock = new StockSubject();
const display = new StockDisplay();
stock.addObserver(display);
stock.setPrice(100); // display 会收到通知

我曾经踩过的坑:在观察者模式里忘记移除观察者,导致内存泄漏。特别是单页应用里,组件销毁了但观察者还在,每次数据更新都会触发已销毁组件的回调。记得在组件卸载时调用 removeObserver。

3.3 中介者模式:集中管控通信

中介者模式,我更喜欢叫它「交通指挥模式」。所有组件之间的通信都经过一个中介者,由它来协调各方。这跟发布-订阅有点像,但中介者更「主动」——它知道谁该做什么。

你想想看,一个聊天室应用:用户 A 发消息,需要同时更新聊天列表、未读计数、用户状态、通知栏。如果每个组件都直接互相调用,那代码就成了一团乱麻。

// 中介者模式实现
class ChatMediator {
  constructor() {
    this.users = new Map();
    this.rooms = new Map();
  }

  // 注册用户
  registerUser(user) {
    this.users.set(user.id, user);
    user.setMediator(this);
  }

  // 注册房间
  registerRoom(room) {
    this.rooms.set(room.id, room);
    room.setMediator(this);
  }

  // 发送消息
  sendMessage(senderId, roomId, message) {
    const room = this.rooms.get(roomId);
    if (!room) {
      console.error('房间不存在');
      return;
    }

    // 中介者协调多个组件更新
    this.updateChatList(senderId, roomId, message);
    this.updateUnreadCount(roomId, senderId);
    this.updateUserStatus(senderId);
    this.notifyRoomMembers(roomId, message);
  }

  updateChatList(senderId, roomId, message) {
    // 更新聊天列表
    console.log(`[中介者] 更新聊天列表: ${senderId} 在 ${roomId} 发送了消息`);
  }

  updateUnreadCount(roomId, excludeUserId) {
    // 更新未读计数,排除发送者
    console.log(`[中介者] 更新 ${roomId} 的未读计数`);
  }

  updateUserStatus(userId) {
    // 更新用户在线状态
    console.log(`[中介者] 更新用户 ${userId} 的状态`);
  }

  notifyRoomMembers(roomId, message) {
    // 通知房间内其他成员
    console.log(`[中介者] 通知 ${roomId} 的成员: ${message}`);
  }
}

// 用户类
class User {
  constructor(id, name) {
    this.id = id;
    this.name = name;
    this.mediator = null;
  }

  setMediator(mediator) {
    this.mediator = mediator;
  }

  sendMessage(roomId, message) {
    this.mediator.sendMessage(this.id, roomId, message);
  }
}

// 使用
const mediator = new ChatMediator();
const alice = new User('1', 'Alice');
const bob = new User('2', 'Bob');

mediator.registerUser(alice);
mediator.registerUser(bob);

alice.sendMessage('room1', '大家好!');
// 中介者会协调所有相关组件更新

三种模式的选择建议:

  • 简单的一对多通知 → 观察者模式
  • 复杂的多对多通信 → 发布-订阅模式
  • 需要集中控制业务逻辑 → 中介者模式

3.4 三种模式的对比与实战选择

我整理了一张对比图,方便你快速决策:

三种事件模式对比 观察者模式 目标(Subject) 观察者A 观察者B 直接通知,观察者持有目标引用 发布-订阅模式 发布者 事件通道 订阅者A 订阅者B 通过事件通道间接通信,完全解耦 中介者模式 组件A 中介者 组件B 组件C 中介者集中管控,协调多方通信 实战选择建议 1. 观察者模式:适合单一数据源、多个展示层的场景 比如:股票行情、传感器数据、状态管理 2. 发布-订阅模式:适合跨模块、跨组件的复杂通信 比如:全局事件总线、消息队列、插件系统 3. 中介者模式:适合需要集中控制业务逻辑的场景 比如:聊天室、工作流引擎、表单联动

3.5 实战中的避坑指南

模式选对了,但实现细节没处理好,照样会出问题。我把自己踩过的坑分享给你:

我曾经犯过的错误:

  • 事件名冲突:两个模块用了同一个事件名,结果互相干扰。后来我强制要求事件名必须带模块前缀,比如 user:login、cart:add。
  • 忘记取消订阅:组件销毁了,但订阅还在。每次事件触发都执行已销毁组件的回调,轻则报错,重则内存泄漏。我习惯在组件卸载时统一清理所有订阅。
  • 事件风暴:一个事件触发后,连锁触发多个事件,最后形成循环。我加了一个最大递归深度限制,超过 10 层就报错中断。
  • 调试困难:事件流难以追踪。后来我写了一个调试中间件,每次事件触发都打印调用栈和参数。

我的调试小技巧:在事件总线上加一个 _debug 模式,开启后所有事件都会打印到控制台。线上环境关闭,开发环境开启,排查问题特别方便。

// 带调试功能的事件总线
class DebugEventBus extends EventBus {
  constructor(debug = false) {
    super();
    this.debug = debug;
  }

  emit(eventName, ...args) {
    if (this.debug) {
      console.group(`[EventBus] ${eventName}`);
      console.log('参数:', args);
      console.log('订阅者数量:', this.events[eventName]?.length || 0);
      console.groupEnd();
    }
    super.emit(eventName, ...args);
  }
}

// 开发环境开启调试
const bus = new DebugEventBus(
  process.env.NODE_ENV === 'development'
);

嗯,这三种模式你掌握了,UI 事件驱动架构的通信层就稳了。记住一个原则:能用发布-订阅就别用观察者,需要集中控制就用中介者。别为了用模式而用模式,合适才是最好的。