EventBus 类型定义

这几天我在想,EventBus 这种事件总线机制我经常用到,因为他的写法形式很固定,我让 copilot、AmazonQ、千问给我补全,但大模型补全出来的类型都是 any,要么就是 Function,比如这样:
export class EventBus {
private listeners: Map<string, Function[]> = new Map();
on(event: string, listener: Function) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)?.push(listener);
}
off(event: string, listener: Function) {
if (!this.listeners.has(event)) {
return;
}
const fnList = this.listeners.get(event) || []
const index = fnList.indexOf(listener);
if (index !== -1) {
this.listeners.set(event, fnList.slice(index, 1));
}
}
clear(event: string) {
this.listeners.delete(event);
}
emit(event: string, ...args: any[]) {
if (!this.listeners.has(event)) {
return;
}
const fnList = this.listeners.get(event) || []
fnList.forEach((fn) => {
fn.apply(this, args);
});
}
}
你说他不对吧,他 EventBus 该有的都有,做法也是没错的,但你说他正确吧,他这写的和 JavaScript 有啥区别呢?丢了类型补全,还存在类型安全隐患。
于是我学习了一下别人的做法:
/* 基础事件 */
export interface IBaseEvent {
id: string;
type: EventType;
timestamp: Date;
}
export interface IMenusLoad extends IBaseEvent {
menu: Menu;
}
export interface IThemeChange extends IBaseEvent {
theme: "light" | "dark";
}
export interface Events {
"menus-load": IMenusLoad;
"theme-change": IThemeChange;
}
首先定义基础事件数据接口 IBaseEvent,描述 emit 传递和 listener 接收的事件数据,里面的属性是一个事件数据必须有的。
IMenusLoad 和 IThemeChange 作为更具体的事件数据接口,他会带有自己事件相关的属性。
Events 是事件类型到事件数据的映射,也就是触发某个类型的事件时,所携带的数据的类型。
export type T = keyof Events;
export type Listener<EventType extends T> = (e: Events[EventType]) => void;
T 是 Events 键组成的联合类型,通过 T 可以描述出 Events 所有事件类型对应的 listener:(e: Events[EventType]) => void。
接着他做了一个观察者,为每个事件单独做观察监听,也就是说一个事件类型,对应一个Observer 实例:
export interface IObserver<EventType extends T> {
subscribe: (listener: Listener<EventType>) => () => void;
publish: (event: Events[EventType]) => void;
}
export class Observer<EventType extends T> implements IObserver<EventType> {
private listeners: Listener<EventType>[] = [];
subscribe(listener: Listener<EventType>): () => void {
this.listeners = [...this.listeners, listener];
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
publish(event: Events[EventType]): void {
this.listeners.forEach((listener) => listener(event));
}
}
以往我的习惯是一个对象来保存 type 到 listeners 的关系。
接着是 EventBus 的实现:
type T = keyof Events;
type ObserversMap = {
[Type in T]?: IObserver<Type>;
};
/* Singleton implementation of Global Event Bus */
class EventBus {
private static instance: EventBus;
private observers: ObserversMap = {};
private constructor() {}
static getInstance() {
if (!EventBus.instance) {
EventBus.instance = new EventBus();
}
return EventBus.instance;
}
public publish<Type extends T>(type: Type, event: Events[Type]): void {
if (!EventBus.getInstance().observers[type]) {
EventBus.getInstance().observers = {
...EventBus.getInstance().observers,
[type]: new Observer<Type>(),
};
}
// @ts-ignore
EventBus.getInstance().observers[type].publish(event);
}
public subscribe<Type extends T>(
type: Type,
listener: Listener<Type>
): () => void {
if (!EventBus.getInstance().observers[type]) {
EventBus.getInstance().observers = {
...EventBus.getInstance().observers,
[type]: new Observer<Type>(),
};
}
// @ts-ignore
return EventBus.getInstance().observers[type].subscribe(listener);
}
}
export default EventBus.getInstance();
由于他是用事件对应独立的Observers,所以会用 Map 来管理映射关系,ObserversMap 是映射的类型定义,键来自于先前的 Events。
私有的构造函数和 getInstance 让 EventBus 实现了单例模式,项目中只会有一个 EventBus 对象存在。
publish 和 subscribe 的预检查逻辑一致,都是先判断 observers 中有没有某事件类型,没有就创建新的,然后订阅/发布。(这两个 ts-ignore 可以不用管,之前已经判断过对象存在了)
到这里 EventBus 的功能已经基本实现了。
接下来的是事件类:
class BaseEvent implements IBaseEvent {
public id: string;
public timestamp: Date;
constructor(public type: EventType) {
this.id = generateKey();
this.timestamp = new Date();
}
}
export class MenusLoadEvent extends BaseEvent implements IMenusLoad {
constructor(public menu: Menu) {
super("menus-load");
}
}
export class ThemeChangeEvent extends BaseEvent implements IThemeChange {
constructor(public theme: "light" | "dark") {
super("theme-change");
}
}
id 和 timestamp 都是自动生成的,具体事件的参数在之前的 IBaseEvent 那已经定义了,这里是实现。
具体使用起来是这样的:
EventBus.getInstance().subscribe("theme-change", (event) => console.log(event));
事件类型不能乱填,TS 会检查 Events 映射表的键,同理 event 的类型也会根据 Events 的值确定。




