今天看了一本书,发现书中的发布订阅代码存在问题,于是做了一个矫正,记录一下。
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。
一、错误根源与后果
- 错误分析
你在代码开头创建的发布订阅实例是:
ini
window.subscriptionPublish = new SubscriptionPublish();
但在 BTable 组件的清理函数中,却错误地使用了一个从未定义过的变量 defaultEvent:
dart
useEffect(() => {
window.subscriptionPublish.on('loadTable', loadTable);
return () => {
// 错误:defaultEvent 没有定义
defaultEvent.remove('loadTable', loadTable);
};
}, []);
- 运行时后果
这段代码在浏览器中运行时会直接抛出致命错误:
csharp
Uncaught ReferenceError: defaultEvent is not defined
- 更严重的隐藏问题
由于清理函数执行失败,组件卸载时不会取消订阅,会导致严重的内存泄漏:
组件卸载后,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 类本身还有几个企业级开发中常见的缺陷:
- 无法正确移除匿名函数订阅
dart
// 错误:匿名函数无法被移除
useEffect(() => {
window.subscriptionPublish.on('loadTable', () => {
table.reload();
});
// 这里的匿名函数和上面的不是同一个引用,remove 无效
return () => {
window.subscriptionPublish.remove('loadTable', () => {
table.reload();
});
};
}, []);
- 没有一次性订阅方法
无法实现 "只监听一次事件" 的需求。
- 没有清空所有订阅的方法
无法在页面卸载时一次性清空某个事件的所有订阅者。
- 类型不安全
所有参数都是 any 类型,没有类型检查,容易出现参数不匹配的错误。
- 挂载到 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)会比事件总线更可维护、更不容易出问题。