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 事件总线的架构图
下面这张图展示了事件总线的完整工作流程:
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 避坑指南
做了这么多年事件总线,我总结了几条血泪教训:
- 内存泄漏:订阅者忘记取消订阅,导致对象无法被垃圾回收。解决方案是提供
once方法,或者让组件在销毁时自动取消所有订阅。 - 事件名冲突:多人协作时,两个人可能定义了相同的事件名。我习惯用命名空间,比如
user:login、order:created。 - 死循环:A 事件触发 B 事件,B 事件又触发 A 事件。可以在 emit 里加一个深度计数器,超过阈值就报错。
- 性能瓶颈:如果某个事件有上千个订阅者,emit 会变得很慢。考虑用节流或批量处理。
我曾经遇到的一个线上事故:某个组件在 mount 时订阅了事件,但 unmount 时忘了取消。结果组件被销毁后,事件触发时还在执行它的回调,导致内存泄漏和奇怪的 bug。从那以后,我强制要求所有订阅必须返回一个取消函数。
4.7 总结
事件总线的设计,核心就是三个字:解耦合。发布者不用关心谁在听,订阅者不用关心谁在说。
实现上,从简单的 Map 存储,到支持通配符、优先级、异步分发,每一步都是根据实际需求演进的。不要一开始就搞得太复杂,够用就好。
嗯,这一章的内容就到这里。记住:事件总线是工具,不是银弹。用得好,代码清爽;用不好,事件满天飞,调试起来想哭。