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

35 阅读4分钟

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

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. 错误分析

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

window.subscriptionPublish = new SubscriptionPublish();

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

useEffect(() => {
  window.subscriptionPublish.on('loadTable', loadTable);
  return () => {
    // 错误:defaultEvent 没有定义
    defaultEvent.remove('loadTable', loadTable);
  };
}, []);

 2. 运行时后果

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

Uncaught ReferenceError: defaultEvent is not defined

 3. 更严重的隐藏问题

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

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

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

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

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

二、正确的修正代码

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

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

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

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

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

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

1. 无法正确移除匿名函数订阅

// 错误:匿名函数无法被移除
useEffect(() => {
  window.subscriptionPublish.on('loadTable', () => {
    table.reload();
  });
  
  // 这里的匿名函数和上面的不是同一个引用,remove 无效
  return () => {
    window.subscriptionPublish.remove('loadTable', () => {
      table.reload();
    });
  };
}, []);

 2. 没有一次性订阅方法

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

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

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

4. 类型不安全

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

5. 挂载到 window 上的弊端

污染全局命名空间

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

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

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

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();

使用方式

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)会比事件总线更可维护、更不容易出问题。