写在前面
最近有个朋友在面试候选人的时候,问了一个关于 TypeScript 事件总线的题目,很多人都没有写出来。作为一个热门的面试题,相信大家都能写出基本的事件总线,但是朋友的面试题要求更进一步,需要能够推导入参类型。本文会分享如何实现这样一个事件总线
基础事件总线
事件总线的核心是维护一张"事件名 -> 监听函数列表"的映射表。这里使用 Map 保存所有事件:

typescript
private events = new Map<keyof T, Array<(...args: any[]) => void>>();
整体流程如下:
-
on注册事件监听- 接收事件名
event和监听函数listener。 - 如果当前事件还没有对应的监听数组,就先创建一个空数组。
- 然后把监听函数追加到数组中。
- 接收事件名
-
emit触发事件- 根据事件名从
Map中取出对应的监听函数数组。 - 如果有监听函数,就遍历数组并依次执行。
- 触发时传入的参数会透传给每个监听函数。
- 根据事件名从
-
off移除事件监听- 根据事件名找到对应的监听函数数组。
- 通过
filter过滤掉需要移除的监听函数。 - 注意:这里要求传入的
listener必须和on时传入的是同一个函数引用,否则无法移除。
代码大概是这样:
typescript
class EventEmitter {
private events = new Map<string, Array<(...args: any[]) => any>>();
on(event: string, listener: (...args: any[]) => any) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event)!.push(listener);
}
emit(event: string, ...args: any[]) {
const listeners = this.events.get(event);
if (listeners) {
listeners.forEach((listener) => listener(...args));
}
}
off(event: string, listener?: (...args: any[]) => any) {
if (!listener) {
this.events.delete(event);
return;
}
const listeners = this.events.get(event);
const filtered = listeners?.filter((item) => item !== listener);
if (!filtered || filtered.length === 0) {
this.events.delete(event);
} else {
this.events.set(event, filtered);
}
}
}
限制入参类型
为了让 TypeScript 根据事件名自动限制参数类型,可以先定义一个事件类型表:
typescript
type Events = {
click: { x: number; y: number };
change: string;
close: void;
};
这个类型表表达的是:
click事件的参数必须是{ x: number; y: number }change事件的参数必须是stringclose事件不需要参数
然后通过泛型把这张事件类型表传给 EventEmitter:
typescript
const emitter = new EventEmitter<Events>();
关键点有两个:
- 使用
keyof T限制事件名
typescript
on<K extends keyof T>(event: K, listener: ...)
emit<K extends keyof T>(event: K, ...)
off<K extends keyof T>(event: K, listener: ...)
这里的 K extends keyof T 表示:事件名只能是 Events 中存在的 key,比如 click、change、close。如果写成 emitter.emit('submit'),TypeScript 就会报错。
- 使用
T[K]根据事件名推导参数类型
typescript
listener: T[K] extends void ? () => void : (args: T[K]) => void
这段类型的意思是:
- 如果当前事件对应的参数类型是
void,监听函数就不接收参数。 - 否则,监听函数必须接收一个类型为
T[K]的参数。
所以:
typescript
emitter.on("click", (event) => {
event.x;
event.y;
});
这里的 event 会被自动推导成 { x: number; y: number }。
emit 的参数限制也是同理:
typescript
emit<K extends keyof T>(
event: K,
...args: T[K] extends void ? [] : [T[K]]
): void
这里使用了剩余参数和条件类型:
- 如果事件参数是
void,args类型就是空数组[],也就是不能传第二个参数。 - 如果事件参数不是
void,args类型就是[T[K]],也就是必须传一个对应类型的参数。
因此下面这些调用会得到正确的类型约束:
typescript
emitter.emit("change", "hello"); // 正确
emitter.emit("change", 123); // 错误,change 需要 string
emitter.emit("close"); // 正确
emitter.emit("close", false); // 错误,close 不需要参数
emitter.emit("click", { x: 1, y: 2 }); // 正确
emitter.emit("click", { x: 1 }); // 错误,缺少 y
完整代码
typescript
class EventEmitter<T extends Record<string, any>> {
private events = new Map<keyof T, Array<(...args: any[]) => void>>();
constructor() {}
on<K extends keyof T>(
event: K,
listener: T[K] extends void ? () => void : (args: T[K]) => void,
) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event)!.push(listener as any);
}
emit<K extends keyof T>(event: K, ...args: T[K] extends void ? [] : [T[K]]) {
const listeners = this.events.get(event);
if (listeners) {
listeners.forEach((listener) => listener(...args));
}
}
off<K extends keyof T>(
event: K,
listener?: T[K] extends void ? () => void : (args: T[K]) => void,
) {
if (!listener) {
this.events.delete(event);
return;
}
const listeners = this.events.get(event);
const filtered = listeners?.filter((item) => item !== listener);
if (!filtered || filtered.length === 0) {
this.events.delete(event);
} else {
this.events.set(event, filtered);
}
}
}
type Events = {
click: { x: number; y: number };
change: string;
close: void;
};
const emitter = new EventEmitter<Events>();
emitter.on("click", (event) => {
event.x;
});
emitter.emit("change", "hello");
emitter.on("close", () => {
console.log("closed");
});
写在最后
感觉您耐心看完这篇文章,希望您能喜欢。这里是《前端毕业班》,前端开发者的自救互助小组。在 AI 与不确定性并存的时代,我们一起看清焦虑,聊技术、聊趋势,也聊前端还能走多远,走去哪。