观察者模式是设计模式中最经典的一种,而事件总线正是它在工程实践里最常见的落地形式。无论是组件间通信、模块解耦,还是插件系统的钩子机制,背后几乎都有一条事件总线在默默工作。不过传统的JavaScript实现里,事件名是一个字符串,回调参数是any,编译器帮不上任何忙,事件名拼错一个字母,代码照样编译通过,直到运行时才发现监听器根本没被触发。TypeScript给了我们改变这一局面的机会:通过泛型、映射类型和函数重载,可以让事件名和参数类型在编译期就被严格约束。

一、先搭一个基础版的事件总线
在追求类型安全之前,先把骨架搭好。事件总线的核心是三个方法:on注册监听器,off移除监听器,emit触发事件。内部用一个Map来保存事件名到回调函数数组的映射。这个结构虽然简单,但它就是观察者模式的全部:被观察者(事件总线)维护一份观察者(回调函数)名单,状态变化时逐一通知。
class EventBus {
private listeners = new Map<string, Function[]>();
on(event: string, fn: Function) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(fn);
}
off(event: string, fn: Function) {
const arr = this.listeners.get(event);
if (arr) {
const idx = arr.indexOf(fn);
if (idx > -1) arr.splice(idx, 1);
}
}
emit(event: string, ...args: unknown[]) {
this.listeners.get(event)?.forEach(fn => fn(...args));
}
}这个版本能跑,但类型层面完全是裸奔的。on('userLgoin', ...)拼错了事件名,emit时多传一个参数,编译器都不会报任何错。问题的根源在于事件名和参数类型之间没有任何关联,编译器看不到"事件A对应参数结构X"这层映射关系。要建立这层关系,就需要引入泛型接口来描述事件契约。
二、用映射类型建立事件契约
类型安全的第一步,是定义一个Events接口,把所有事件名和对应的参数结构声明出来。这个接口就是整条总线的"宪法",所有方法签名都从它推导。写法上推荐用对象形式而不是函数形式,因为对象形式的类型推导在泛型场景下更稳定,也更容易表达联合类型的参数。
// 事件契约:事件名 -> 事件负载
interface Events {
'user:login': { userId: string; time: number };
'user:logout': { reason?: string };
'cart:update': { itemId: number; quantity: number };
}有了契约,就可以改造总线的类型签名。关键技巧是三个:第一,on方法的event参数用keyof Events约束,这样事件名只能是契约中声明过的字符串字面量;第二,回调参数通过Events[E]索引访问拿到,事件名和参数类型被绑定在一起;第三,emit用同样的约束,保证发布方的负载结构必须与订阅方一致。下面是完整的类型安全实现。
type Handler<K extends keyof Events> = (payload: Events[K]) => void;
class TypedEventBus {
private listeners = new Map<keyof Events, Handler<keyof Events>[]>();
on<K extends keyof Events>(event: K, handler: Handler<K>) {
const arr = this.listeners.get(event) ?? [];
arr.push(handler as Handler<keyof Events>);
this.listeners.set(event, arr);
}
off<K extends keyof Events>(event: K, handler: Handler<K>) {
const arr = this.listeners.get(event);
if (arr) {
const idx = arr.indexOf(handler as Handler<keyof Events>);
if (idx > -1) arr.splice(idx, 1);
}
}
emit<K extends keyof Events>(event: K, ...args: Events[K] extends void ? [] : [Events[K]]) {
this.listeners.get(event)?.forEach(fn => (fn as Handler<K>)(args[0]));
}
}这里的emit签名用了一个条件类型技巧:如果事件的负载是void(即事件不需要参数),则emit不接受任何参数;否则必须恰好传一个对应结构的参数。这样调用bus.emit('user:login', { userId: '1', time: 1 })时,负载里少一个字段或类型不对,都会直接在编译期报红。值得注意的是Handler<K>内部那几处as断言,因为Map的值类型在联合事件下无法精确到具体K,这是类型擦除的固有限制,断言被安全地限制在类的私有实现内部,对外暴露的公开API依然是完全类型安全的。
三、补齐once、offAll与返回值取消订阅
实际项目中只有on/off/emit还不够。常见的扩展需求有三个:once注册只触发一次的监听器,offAll清空某个事件的全部监听器,以及让on返回一个取消函数,避免调用方自己保存回调引用。第三个尤其实用——匿名函数注册后,如果没保存引用就没办法off,返回取消函数可以彻底解决这个问题。
on<K extends keyof Events>(event: K, handler: Handler<K>): () => void {
const arr = this.listeners.get(event) ?? [];
arr.push(handler as Handler<keyof Events>);
this.listeners.set(event, arr);
// 返回取消订阅函数,调用方无需保存handler引用
return () => this.off(event, handler);
}
once<K extends keyof Events>(event: K, handler: Handler<K>) {
const wrapped: Handler<K> = (payload) => {
this.off(event, wrapped);
handler(payload);
};
this.on(event, wrapped);
}once的实现思路是用一个包装函数代理原始回调,触发时先把自己移除再执行原逻辑。要注意包装函数必须声明为独立变量,这样this.off(event, wrapped)才能通过引用相等找到自己。如果直接在on里写匿名包装,移除时引用对不上,once就会失效变成永久监听,这是初学者最容易踩的坑。
四、泛型类与异步场景的进阶处理
上面的事件契约是写死的接口,更好的做法是把总线做成泛型类EventBus<E extends Record<string, unknown>>,让每个模块传自己的事件定义,一条总线类就能到处复用。同时可以搭配wait方法返回Promise,把事件监听转成await风格,这在异步流程控制里非常顺手。
class GenericBus<E extends Record<string, unknown>> {
// ...同上的on/emit实现
wait<K extends keyof E>(event: K, timeout = 5000): Promise<E[K]> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.off(event, handler);
reject(new Error(`等待事件超时: ${String(event)}`));
}, timeout);
const handler: Handler<K> = (payload) => {
clearTimeout(timer);
this.off(event, handler);
resolve(payload);
};
this.on(event, handler);
});
}
}wait方法把回调式API包装成Promise,配合async/await可以让"等待用户登录完成后再拉取购物车"这类流程写起来像同步代码一样线性。超时保护也很重要,否则事件永远不触发时Promise会悬挂,拖垮整个异步链路。另外提醒一点:如果项目使用Vue或React,跨组件传总线时要注意tree-shaking和单例导出方式,推荐用export const bus = new GenericBus<AppEvents>()的形式集中管理,避免多个总线实例导致事件互相收不到。
整体来看,类型安全事件总线的核心思路只有一句话:用keyof锁住事件名,用索引类型把事件名和负载绑定。掌握了这个模式后,还可以进一步扩展出命名空间事件、通配符订阅(配合模板字面量类型)等玩法,这些都是在同一套类型骨架上做增量演进。
TypeScript观察者模式事件总线修改时间:2026-09-14 05:22:40