避免滥用“事件总线”

21 阅读8分钟

🔰 一、React 组件通信的本质

在 React 中,组件通信的核心是“数据驱动 UI”和“事件驱动状态变化”

所有通信方式都围绕两个原则展开:

  1. 单向数据流:状态 → UI,事件 → 修改状态
  2. 可预测性:代码行为应易于追踪、调试与测试

🧩 二、6 种通信方式概览

方式数据流向类型推荐度典型场景
1. Props / Callback父 ↔ 子声明式✅ 强烈推荐基础交互
2. useImperativeHandle + ref父 → 子命令式✅ 推荐调用方法(如 focus)
3. Context祖先 → 后代声明式✅ 推荐跨层级传主题/语言
4. 状态提升(Lifting State)兄弟 ↔ 兄弟声明式✅ 推荐表单同步等
5. 全局状态管理(Zustand/Jotai)任意 ↔ 任意声明式✅ 中大型项目推荐登录态、购物车
6. 事件总线(Event Bus)任意 → 任意解耦式⚠️ 慎用插件、微前端、埋点

✅ 三、什么时候用事件总线是合理的?——典型场景

虽然风险高,但在特定架构下,事件总线有其不可替代的价值。

✅ 场景 1:插件系统(扩展性强)

背景

开发一个支持第三方插件的编辑器(如 Figma、VSCode),主程序不预知有哪些插件。

实现方式

// 主程序广播事件
eventBus.emit('document:saved', doc);

// 插件自行监听
eventBus.on('document:saved', backupPlugin);
eventBus.on('document:saved', analyticsPlugin);

优势:新增插件无需修改主逻辑,实现真正“热插拔”。


✅ 场景 2:微前端跨框架通信

背景

多个团队使用不同技术栈(React/Vue/Angular)独立开发子应用,通过微前端集成。

实现方式

// 用户中心登出(React)
eventBus.emit('user:logout');

// 导航栏刷新(Vue)
eventBus.on('user:logout', () => updateHeader());

// 数据看板清空缓存(Angular)
eventBus.on('user:logout', clearCache);

优势:跨技术栈通信,团队解耦,发布独立。


✅ 场景 3:全局副作用处理(埋点、监控)

背景

需要收集用户点击、性能日志、错误上报等非核心业务行为。

实现方式

// 业务组件只通知发生了什么
eventBus.emit('ui:click', { button: 'submit' });

// 多个服务监听并响应
eventBus.on('ui:click', data => analytics.track(data));     // 埋点
eventBus.on('ui:click', data => perf.mark('interaction'));   // 性能
if (isDev) eventBus.on('ui:click', console.log);             // 开发调试

优势:业务逻辑纯净,未来可动态添加监听者。

💡 结论:这是典型的“观察者模式”应用场景。


❌ 四、什么时候不该用事件总线?——反例警示(滥用场景)

场景是否合理为什么
子组件通知父组件“提交表单”❌ 不合理应该用 onSubmit={handleSubmit} 回调
A 组件更新后 B 组件刷新列表❌ 不合理应该用 useState 提升到共同父级或 Zustand
点击按钮切换主题❌ 不合理应该用 ThemeContextuseStore
表格行点击传递数据给详情面板❌ 不合理直接用 props 或 URL 参数

📌 总结
这些本可以用更清晰、可追踪的方式解决的问题,却用了事件总线 → 就是滥用


🧠 五、快速判断口诀:“三问一原则”判断法

每次你想使用事件总线时,请先问自己三个问题:

❓ 1. 我是否不关心谁接收这个消息?

  • ✅ 是 → 可考虑使用事件总线
  • ❌ 否(我知道必须是 X 组件处理)→ 改用回调函数或状态管理

❓ 2. 是否有多个组件会对这件事做出反应?

  • ✅ 是 → 可考虑使用事件总线
  • ❌ 否(只有一个接收方)→ 直接调用函数或传 props

❓ 3. 是否涉及不同技术栈或独立模块?

  • ✅ 是(如 Vue 和 React 通信、微前端)→ 可考虑使用事件总线
  • ❌ 否(都在同一个 React 应用内)→ 优先使用 Context / Zustand / ref

最终决策规则
如果以上 至少两个回答是“是” ,才考虑使用事件总线。
否则,请回归 React 推荐的通信方式。


✅ 六、如果必须用事件总线,如何安全使用?

1. 封装统一入口,避免魔法字符串

// event-bus.ts
export const AppEvents = {
  USER_LOGIN: 'user:login',
  DOCUMENT_SAVED: 'document:saved',
  ROUTE_CHANGE: 'route:change'
} as const;

class EventBus {
  private events = new Map();

  emit(type, payload) { /* 实现 */ }
  on(type, handler) { /* 实现 */ }
  off(type, handler) { /* 实现 */ }
}

2. 使用 TypeScript 强类型(建议)

type EventMap = {
  'user:login': (user: User) => void;
  'document:saved': (doc: Document) => void;
};

eventBus.on<'user:login'>('user:login', (user) => { ... });

3. 自动清理监听器(尤其在 React 中)

useEffect(() => {
  const handler = () => {...};
  eventBus.on('x', handler);
  return () => eventBus.off('x', handler); // 必须!防止内存泄漏
}, []);

4. 添加开发期调试能力

// 开发环境打印所有事件
if (process.env.NODE_ENV === 'development') {
  eventBus.on('*', (type, payload) => {
    console.log(`[EventBus] ${type}`, payload);
  });
}

🎯 七、终极建议总结

项目规模是否推荐使用事件总线建议
小型项目(< 10 个组件)❌ 不推荐所有通信都能用 props/context 解决
中型项目(团队协作)⚠️ 有限使用仅用于插件、埋点等特定场景
大型架构(平台/微前端)✅ 可以使用在明确解耦需求下谨慎引入

💬 八、一句话心法(必背)

“我是想通信,还是想解耦?”

  • 如果是 通信 → 用 props, context, ref, store
  • 如果是 解耦 → 再考虑 eventBus

🔥 事件总线不是日常工具,而是“消防栓”——平时看不见,着火时才需要。


📎 附录:决策树图(文字版)

                    ┌──────────────┐
                    │ 需要通信吗? │
                    └──────┬───────┘
                           ↓ 是
          ┌────────────────────────────────────┐
          │ 是执行动作还是同步状态?           │
          └─────┬──────────────────────┬───────┘
                ↓ 动作                  ↓ 状态
     ┌───────────────────┐    ┌────────────────────────────┐
     │ 谁来执行这个动作?   │    │ 谁需要这个状态?             │
     └─────┬─────────────┘    └──────┬─────────────────────┘
           ↓ 明确                 ↓ 广泛/未知
    用 ref.current.method()     用事件总线(且满足三问)
           │                         │
           ↓ 否                      ↓ 是
    改用回调函数               检查是否真需解耦

结束语

掌握“不用什么”,比学会“用什么”更重要。

发布订阅式实现

// event-bus.ts
// 事件总线(Event Bus)实现:用于组件间解耦通信
// 支持订阅、发布、一次性监听、取消订阅等功能

// 定义监听器函数类型:接收任意数量的参数,无返回值
type Listener = (...args: any[]) => void;

/**
 * EventBus 类
 * 提供基于“发布-订阅”模式的全局事件通信机制
 * 特点:
 * - 使用 Map + Set 存储事件,避免重复监听
 * - 支持链式调用(每个方法返回 this)
 * - 提供 once、off、emit 等常用功能
 */
export default class EventBus {
  // 私有属性:存储事件名 → 监听器集合 的映射
  // 使用 Set 防止同一个事件注册多个相同的监听器
  private events: Map<string, Set<Listener>> = new Map();

  /**
   * 订阅事件(监听某个事件)
   * @param eventName - 事件名称(字符串标识)
   * @param listener - 回调函数,当事件被触发时执行
   * @returns 返回当前实例,支持链式调用
   */
  on(eventName: string, listener: Listener): this {
    // 如果该事件尚无监听器集合,则初始化一个空 Set
    if (!this.events.has(eventName)) {
      this.events.set(eventName, new Set());
    }
    // 获取该事件对应的监听器集合,并将新监听器加入
    this.events.get(eventName)!.add(listener);
    // 返回 this,支持链式写法,如 .on().on().emit()
    return this;
  }

  /**
   * 一次性订阅事件(只响应一次后自动取消)
   * @param eventName - 事件名称
   * @param listener - 回调函数,在事件首次触发时执行
   * @returns 返回当前实例,支持链式调用
   */
  once(eventName: string, listener: Listener): this {
    // 创建一个包装函数,内部调用原 listener 并在执行后自动解绑
    const onceWrapper = (...args: any[]) => {
      listener(...args); // 执行原始回调
      this.off(eventName, onceWrapper); // 解除对该包装函数的监听
    };
    // 将包装后的函数注册为普通监听器
    return this.on(eventName, onceWrapper);
  }

  /**
   * 取消订阅某个事件的指定监听器
   * @param eventName - 事件名称
   * @param listener - 要移除的监听函数(必须是同一引用)
   * @returns 返回当前实例,支持链式调用
   */
  off(eventName: string, listener: Listener): this {
    const listeners = this.events.get(eventName);
    // 如果该事件存在监听器集合
    if (listeners) {
      // 从集合中删除指定监听器
      listeners.delete(listener);
      // 如果删除后监听器为空,则清理整个事件键,释放内存
      if (listeners.size === 0) {
        this.events.delete(eventName);
      }
    }
    return this;
  }

  /**
   * 触发(广播)某个事件,通知所有监听者
   * @param eventName - 事件名称
   * @param args - 传递给监听器的参数列表
   * @returns 是否成功触发了至少一个监听器(true 表示有监听者)
   */
  emit(eventName: string, ...args: any[]): boolean {
    const listeners = this.events.get(eventName);
    // 如果存在对该事件的监听者
    if (listeners) {
      // 遍历所有监听器并同步执行,传入参数
      for (const listener of listeners) {
        listener(...args);
      }
      return true; // 成功触发
    }
    return false; // 没有监听者
  }

  /**
   * 移除某个事件的所有监听器,或清空所有事件
   * @param eventName - 可选,要移除的事件名;不传则清空所有事件
   * @returns 返回当前实例,支持链式调用
   */
  removeEvent(eventName?: string): this {
    if (typeof eventName === 'string') {
      // 删除指定事件下的所有监听器
      this.events.delete(eventName);
    } else {
      // 无参数时清空所有事件
      this.events.clear();
    }
    return this;
  }

  /**
   * (可选)检查某个事件是否有活跃的监听器
   * 常用于调试或状态判断
   * @param eventName - 事件名称
   * @returns 是否存在且至少有一个监听器
   */
  has(eventName: string): boolean {
    return this.events.has(eventName) && this.events.get(eventName)!.size > 0;
  }
}

📌 使用建议与说明

✅ 推荐使用方式(单例模式)

// libs/event-bus-instance.ts
import EventBus from '@/utils/event-bus';

// 全局唯一实例,避免多个 EventBus 导致通信失效
const eventBus = new EventBus();
export default eventBus;

然后在项目中统一导入这个实例:

import eventBus from '@/libs/event-bus-instance';

// 组件 A:发送事件
button.onclick = () => {
  eventBus.emit('user:login', { id: 1, name: 'Alice' });
};

// 组件 B:监听事件
eventBus.on('user:login', (user) => {
  console.log('欢迎登录:', user.name);
});

🔒 注意事项

项目说明
❗ 监听器必须是同一引用offonce 依赖函数引用相等,不要传匿名函数
⚠️ 不支持异步等待emit 是同步执行,若需异步处理请自行封装 Promise
🧹 及时解绑在 React 中使用 useEffect 时记得返回 off 清理
🧪 开发期可用 has 调试判断事件是否被正确注册/清除

💡 示例:React 中安全使用

import { useEffect } from 'react';
import eventBus from '@/libs/event-bus-instance';

function MyComponent() {
  const handleLogin = (user) => {
    console.log('收到登录事件:', user);
  };

  useEffect(() => {
    // 注册监听
    eventBus.on('user:login', handleLogin);

    // 组件卸载时取消监听,防止内存泄漏
    return () => {
      eventBus.off('user:login', handleLogin);
    };
  }, []);

  return <div>监听用户登录事件</div>;
}

🎯 记住口诀

能不用就不用,要用就注释清楚,要解绑就别忘记。