4. 事件总线设计:核心职责与发布/订阅实现

事件总线,说白了就是一个中央调度器。它不生产事件,也不消费事件,它只负责一件事:把事件从发布者手里,准确送到订阅者手里。

我早期做的一个项目,模块之间直接调用方法,耦合得像一团麻花。后来重构时引入了事件总线,整个代码结构瞬间清爽了。嗯,今天我们就来聊聊这个东西到底该怎么设计。

4.1 事件总线的核心职责

事件总线不是万能的,但它有三个必须做好的事:

  • 事件注册:订阅者告诉总线“我对某类事件感兴趣”
  • 事件分发:发布者触发事件时,总线找到所有匹配的订阅者并通知
  • 生命周期管理:订阅者可以随时取消订阅,总线要能清理失效的订阅

核心原则:发布者和订阅者互不知道对方存在。总线是它们之间唯一的桥梁。

你想想看,如果没有总线,A模块要通知B模块,就得直接调用B的方法。如果哪天B改了个接口名,A也得跟着改。有了总线,A只管发事件,B只管收事件,谁都不依赖谁。

4.2 发布/订阅模式实现

实现一个事件总线,其实不复杂。我习惯用 TypeScript 来写,类型安全在大型项目中太重要了。

先看一个基础实现:

type EventHandler = (...args: any[]) => void;

class EventBus {
  private handlers: Map<string, EventHandler[]> = new Map();

  // 订阅事件
  on(event: string, handler: EventHandler): void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, []);
    }
    this.handlers.get(event)!.push(handler);
  }

  // 取消订阅
  off(event: string, handler: EventHandler): void {
    const handlers = this.handlers.get(event);
    if (handlers) {
      this.handlers.set(
        event,
        handlers.filter(h => h !== handler)
      );
    }
  }

  // 触发事件
  emit(event: string, ...args: any[]): void {
    const handlers = this.handlers.get(event);
    if (handlers) {
      handlers.forEach(handler => {
        try {
          handler(...args);
        } catch (error) {
          console.error(`事件 ${event} 处理出错:`, error);
        }
      });
    }
  }
}

这段代码看起来简单,但有几个细节要注意:

  • on 方法用 Map 存储事件名到处理函数的映射
  • off 方法通过引用比较移除特定处理函数
  • emit 方法遍历执行所有订阅者,并用 try-catch 包裹

我曾经踩过的坑:emit 里没有 try-catch,结果某个订阅者抛异常,后面的订阅者全被阻塞了。事件总线必须保证一个订阅者出错不影响其他人。

4.3 事件路由与分发策略

基础实现只能处理精确匹配的事件名。但在真实项目中,我们往往需要更灵活的路由策略。

我个人常用三种分发策略:

策略 匹配规则 适用场景
精确匹配 事件名完全一致 简单场景,如 user:login
通配符匹配 支持 * 和 ** 模块级监听,如 user:*
正则匹配 支持正则表达式 复杂路由,如 /^order\..+\.created$/

来看一个支持通配符的增强版:

class WildcardEventBus {
  private handlers: Map<string, EventHandler[]> = new Map();
  private wildcardHandlers: Map<RegExp, EventHandler[]> = new Map();

  on(pattern: string, handler: EventHandler): void {
    if (pattern.includes('*')) {
      const regex = new RegExp(
        '^' + pattern.replace(/\*\*/g, '.*').replace(/\*/g, '[^:]+') + '$'
      );
      if (!this.wildcardHandlers.has(regex)) {
        this.wildcardHandlers.set(regex, []);
      }
      this.wildcardHandlers.get(regex)!.push(handler);
    } else {
      // 精确匹配逻辑
      if (!this.handlers.has(pattern)) {
        this.handlers.set(pattern, []);
      }
      this.handlers.get(pattern)!.push(handler);
    }
  }

  emit(event: string, ...args: any[]): void {
    // 先处理精确匹配
    const exactHandlers = this.handlers.get(event);
    if (exactHandlers) {
      exactHandlers.forEach(h => h(...args));
    }

    // 再处理通配符匹配
    this.wildcardHandlers.forEach((handlers, regex) => {
      if (regex.test(event)) {
        handlers.forEach(h => h(...args));
      }
    });
  }
}

这里有个设计取舍:通配符匹配的性能比精确匹配差。如果事件频率很高,建议把通配符订阅放在一个单独的 Map 里,只在必要时才遍历。

我的建议:80% 的场景用精确匹配就够了。通配符虽然灵活,但滥用会导致事件路由难以追踪。我一般只在框架层面用通配符,业务代码里尽量用精确事件名。

4.4 事件总线的架构图

下面这张图展示了事件总线的完整工作流程:

事件总线架构图 发布者 A 发布者 B 事件总线 事件注册表 路由引擎 分发队列 错误处理 订阅者 X 订阅者 Y 订阅者 Z 工作流程: 1. 订阅者 X/Y/Z 向总线注册感兴趣的事件 2. 发布者 A/B 触发事件,总线查询注册表 3. 路由引擎匹配订阅者,分发队列依次通知

4.5 高级特性:优先级与异步分发

基础的事件总线只能按注册顺序执行。但在某些场景下,我们需要控制执行顺序。

举个例子:用户登录后,要先更新状态,再刷新UI,最后上报日志。如果日志订阅者先执行,状态还没更新,拿到的数据就是错的。

我习惯给每个订阅者加一个优先级参数:

interface Subscriber {
  handler: EventHandler;
  priority: number; // 数值越小优先级越高
}

class PriorityEventBus {
  private subscribers: Map<string, Subscriber[]> = new Map();

  on(event: string, handler: EventHandler, priority: number = 0): void {
    if (!this.subscribers.has(event)) {
      this.subscribers.set(event, []);
    }
    this.subscribers.get(event)!.push({ handler, priority });
    // 按优先级排序
    this.subscribers.get(event)!.sort((a, b) => a.priority - b.priority);
  }

  emit(event: string, ...args: any[]): void {
    const subs = this.subscribers.get(event);
    if (subs) {
      subs.forEach(({ handler }) => handler(...args));
    }
  }
}

异步分发又是另一个话题。如果订阅者里有异步操作,emit 方法不能简单用 forEach。我一般提供两种模式:

  • 同步模式:按顺序执行,前一个完成后再执行下一个
  • 并发模式:所有订阅者同时触发,用 Promise.all 等待全部完成

选型建议:UI 相关的事件用同步模式,保证渲染顺序。数据上报、日志记录等非关键操作用并发模式,提升吞吐量。

4.6 避坑指南

做了这么多年事件总线,我总结了几条血泪教训:

  1. 内存泄漏:订阅者忘记取消订阅,导致对象无法被垃圾回收。解决方案是提供 once 方法,或者让组件在销毁时自动取消所有订阅。
  2. 事件名冲突:多人协作时,两个人可能定义了相同的事件名。我习惯用命名空间,比如 user:login、order:created。
  3. 死循环:A 事件触发 B 事件,B 事件又触发 A 事件。可以在 emit 里加一个深度计数器,超过阈值就报错。
  4. 性能瓶颈:如果某个事件有上千个订阅者,emit 会变得很慢。考虑用节流或批量处理。

我曾经遇到的一个线上事故:某个组件在 mount 时订阅了事件,但 unmount 时忘了取消。结果组件被销毁后,事件触发时还在执行它的回调,导致内存泄漏和奇怪的 bug。从那以后,我强制要求所有订阅必须返回一个取消函数。

4.7 总结

事件总线的设计,核心就是三个字:解耦合。发布者不用关心谁在听,订阅者不用关心谁在说。

实现上,从简单的 Map 存储,到支持通配符、优先级、异步分发,每一步都是根据实际需求演进的。不要一开始就搞得太复杂,够用就好。

嗯,这一章的内容就到这里。记住:事件总线是工具,不是银弹。用得好,代码清爽;用不好,事件满天飞,调试起来想哭。