React中的兄弟通讯之发布订阅模式

今天看了一本书,发现书中的发布订阅代码存在问题,于是做了一个矫正,记录一下。

typescript 复制代码
class SubscriptionPublish {
	private eventMap: Record < string, ((params: any) => any)[] > ;
	constructor() {
		this.eventMap = {};
	}
	/** 
	 * 订阅函数
	 * @param key 订阅事件 Key 值
	 * @param handler 订阅事件
	 */
	on(key: string, handler: (params: any) => any) {
		if (!this.eventMap[key]) {
			this.eventMap[key] = [];
		}
		this.eventMap[key].push(handler);
	}
	/** 
	 * 发布函数
	 * @param key 订阅事件 Key 值
	 * @param params 要发布到订阅事件中的参数
	 */
	emit(key: string, params ? : any) {
		if (this.eventMap[key]) {
			this.eventMap[key].forEach((handler) => {
				handler(params);
			});
		}
	}
	/** 
	 * 销毁函数
	 * @param key 
	 * @param handler 
	 */
	remove(key: string, handler: (params: any) => any) {
		if (this.eventMap[key]) {
			const res = this.eventMap[key].indexOf(handler);
			res !== -1 && this.eventMap[key].splice(res, 1);
		}
	}
}
// 创建一个实例挂载到 window 中
window.subscriptionPublish = new SubscriptionPublish();

代码错误说明:defaultEvent 是未定义的变量

这是这段代码中一个非常典型的复制粘贴错误,不是什么特殊语法或 API。

一、错误根源与后果

  1. 错误分析

你在代码开头创建的发布订阅实例是:

ini 复制代码
window.subscriptionPublish = new SubscriptionPublish();

但在 BTable 组件的清理函数中,却错误地使用了一个从未定义过的变量 defaultEvent:

dart 复制代码
useEffect(() => {
  window.subscriptionPublish.on('loadTable', loadTable);
  return () => {
    // 错误:defaultEvent 没有定义
    defaultEvent.remove('loadTable', loadTable);
  };
}, []);
  1. 运行时后果

这段代码在浏览器中运行时会直接抛出致命错误:

csharp 复制代码
Uncaught ReferenceError: defaultEvent is not defined
  1. 更严重的隐藏问题

由于清理函数执行失败,组件卸载时不会取消订阅,会导致严重的内存泄漏:

组件卸载后,loadTable 函数仍然被发布订阅实例持有引用

垃圾回收器无法回收该组件实例

后续每次触发 loadTable 事件,都会执行已经卸载组件的 loadTable 方法

多次挂载卸载后,会有多个僵尸回调函数同时执行

二、正确的修正代码

将清理函数中的 defaultEvent 改为你实际创建的实例 window.subscriptionPublish:

javascript 复制代码
const BTable = () => {
  const loadTable = () => {
    table.reload();
  };

  useEffect(() => {
    // 订阅
    window.subscriptionPublish.on('loadTable', loadTable);
    
    // 正确:使用同一个实例取消订阅
    return () => {
      window.subscriptionPublish.remove('loadTable', loadTable);
    };
  }, []);

  return <>{/* ... */}</>;
};

三、这段发布订阅实现的其他问题

除了这个明显的变量错误,这个 SubscriptionPublish 类本身还有几个企业级开发中常见的缺陷:

  1. 无法正确移除匿名函数订阅
dart 复制代码
// 错误:匿名函数无法被移除
useEffect(() => {
  window.subscriptionPublish.on('loadTable', () => {
    table.reload();
  });
  
  // 这里的匿名函数和上面的不是同一个引用,remove 无效
  return () => {
    window.subscriptionPublish.remove('loadTable', () => {
      table.reload();
    });
  };
}, []);
  1. 没有一次性订阅方法

无法实现 "只监听一次事件" 的需求。

  1. 没有清空所有订阅的方法

无法在页面卸载时一次性清空某个事件的所有订阅者。

  1. 类型不安全

所有参数都是 any 类型,没有类型检查,容易出现参数不匹配的错误。

  1. 挂载到 window 上的弊端

污染全局命名空间

多个微前端应用共存时会产生冲突

服务端渲染(SSR)时会报错(window 不存在)

四、企业级完善版发布订阅实现

typescript 复制代码
type EventCallback = (...args: any[]) => void;

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

  /**
   * 订阅事件
   * @param event 事件名称
   * @param callback 回调函数
   * @returns 取消订阅函数
   */
  on(event: string, callback: EventCallback): () => void {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    this.events.get(event)!.push(callback);

    // 返回取消订阅函数,避免手动调用 remove 时的引用问题
    return () => this.off(event, callback);
  }

  /**
   * 一次性订阅事件
   * @param event 事件名称
   * @param callback 回调函数
   * @returns 取消订阅函数
   */
  once(event: string, callback: EventCallback): () => void {
    const wrapper = (...args: any[]) => {
      callback(...args);
      this.off(event, wrapper);
    };
    return this.on(event, wrapper);
  }

  /**
   * 发布事件
   * @param event 事件名称
   * @param args 事件参数
   */
  emit(event: string, ...args: any[]): void {
    if (!this.events.has(event)) return;
    // 创建回调数组的副本,防止在回调中取消订阅导致遍历异常
    [...this.events.get(event)!].forEach((callback) => {
      callback(...args);
    });
  }

  /**
   * 取消订阅
   * @param event 事件名称
   * @param callback 回调函数
   */
  off(event: string, callback: EventCallback): void {
    if (!this.events.has(event)) return;
    const callbacks = this.events.get(event)!;
    const index = callbacks.indexOf(callback);
    if (index > -1) {
      callbacks.splice(index, 1);
    }
    // 如果该事件没有订阅者了,删除对应的 key
    if (callbacks.length === 0) {
      this.events.delete(event);
    }
  }

  /**
   * 清空某个事件的所有订阅
   * @param event 事件名称
   */
  clear(event: string): void {
    this.events.delete(event);
  }

  /**
   * 清空所有事件的所有订阅
   */
  clearAll(): void {
    this.events.clear();
  }
}

// 导出单例,不要挂载到 window 上
export const eventBus = new EventBus();

使用方式

ini 复制代码
import { eventBus } from './eventBus';

const BTable = () => {
  const loadTable = () => {
    table.reload();
  };

  useEffect(() => {
    // 最佳实践:直接使用返回的取消订阅函数
    const unsubscribe = eventBus.on('loadTable', loadTable);
    return unsubscribe;
  }, []);

  return <>{/* ... */}</>;
};

const ATable = () => {
  return (
    <>
      <span onClick={() => eventBus.emit('loadTable')}>更新 B 表格</span>
    </>
  );
};

五、最后提醒

正如我们之前讨论的,发布订阅模式(事件总线)只适用于极少数特殊场景。在大多数情况下,使用状态管理库(如 Zustand)会比事件总线更可维护、更不容易出问题。

相关推荐
子兮曰4 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰4 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万4 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝4 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋4 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁4 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
汉堡大王95274 天前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端
梦想很大很大4 天前
从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践
前端·人工智能·后端
计算机魔术师4 天前
Meta Muse agent 接入 Shopify 的 Shop Pay 实现代理式购物
前端
沙蒿同学4 天前
我用 Go 搭了一条 AI Agent 流水线:从 1 张商品图到一整套淘宝详情页
前端·javascript·后端