Typescript 事件监听器 - 类型到属性的映射
export enum GameEventType {
EVENT_ONE = 'event-one',
EVENT_TWO = 'event-two',
EVENT_THREE = 'event-three'
}
export type GameEvent =
| { type: GameEventType.EVENT_ONE; parameter: string }
| { type: GameEventType.EVENT_TWO; anotherParam: SomeType }
| { type: GameEventType.EVENT_THREE; };
type GameEventListener = (event: GameEvent) => void;
export class GameObserver {
private listenerMap = new Map<GameEventType, GameEventListener[]>();
public on(eventType: GameEventType, listener: GameEventListener) {
const existing = this.listenerMap.get(eventType) ?? [];
existing.push(listener);
this.listenerMap.set(eventType, existing);
}
public off(eventType: GameEventType, listener: GameEventListener) {
let existing = this.listenerMap.get(eventType) ?? [];
if (existing.length) {
existing = existing.filter((l) => l !== listener);
this.listenerMap.set(eventType, existing);
}
}
public fireEvent(event: GameEvent) {
const listeners = this.listenerMap.get(event.type) ?? [];
listeners.forEach((l) => l(event));
}
}
考虑上面的事件监听器类。当你注册一个事件时,你需要提供你关心的事件类型以及一个用于接收事件对象的回调。当触发事件时,通过为 type 属性提供正确的 GameEventType 枚举值,它会提示该事件对象的其他属性。很不错。
不太好的地方在于,消费者仍然必须确保事件类型符合预期,才能访问对象中的其他属性。即使消费者已经注册以接收 GameEventType.EVENT_ONE 的事件,并且即使它只会在触发该事件类型时收到事件,消费者仍然必须写 if (event.type === GameEventType.EVENT_ONE),才能访问整个事件对象中的属性。
gameObserver.on(GameEventType.EVENT_ONE, (event: GameEvent) => {
// This won't work - cannot access other props of event object
console.log(event.parameter)
// Must do this
if (event.type === GameEventType.EVENT_ONE) {
// In order to see the other props linked to that enum value under type
console.log(event.parameter)
}
});
我真正想要的是:
- 通过
on方法注册事件监听器时,如果回调函数的参数与事件的属性不匹配,它会发出警告 - 在触发事件时,它会根据事件类型知道事件对象的属性,并在你输入时自动完成/建议这些属性名
- 在消费时,回调函数只有事件的属性,而没有类型(我们知道类型,我们是针对它注册这个回调的!),因此无需检查事件类型就可以获取属性
我以不同的方式处理了后一点,使用了any,但牺牲了前两点(并且更倾向于避免使用any)。
对于这个问题的任何建议都将不胜感激!谢谢阅读。
答案#1
至少,你会希望使 on() 和 off() 方法在事件类型 K 上具有通用性,这样编译器才能表示第一个和第二个参数之间的连接。让我们把 GameEvent 变成 GameEvent<K>:
export type GameEvent<K extends GameEventType> = Extract<
| { type: GameEventType.EVENT_ONE; parameter: string }
| { type: GameEventType.EVENT_TWO; anotherParam: SomeType }
| { type: GameEventType.EVENT_THREE; }, { type: K }>
这里使用了 Extract<T, U> 工具类型,将原始联合类型过滤为仅包含 type 属性为 K 的成员。因此,一个 GameEvent<GameEventType.EVENT_TWO> 将具体为 {type: GameEvenetType.EVENT_TWO; anotherParam: SomeType}。相应地,对 GameEventListener 的修改是:
type GameEventListener<K extends GameEventType> =
(event: GameEvent<K>) => void;
现在我们可以在 on() 和 off() 中使用 K:
public on<K extends GameEventType>(eventType: K, listener: GameEventListener<K>) {
// same implementation
}
public off<K extends GameEventType>(eventType: K, listener: GameEventListener<K>) {
// same implementation
}
当你调用这些方法之一时,你可以验证它的行为是否符合预期:
const gameObserver = new GameObserver()
gameObserver.on(GameEventType.EVENT_ONE, (event) => {
console.log(event.parameter.toUpperCase()) // okay
});
你确实需要稍微调整一下其他代码以处理这些泛型。最棘手的地方是在你的 listenerMap 属性中,它使用了一个 Map 对象。不幸的是,对于 TypeScript 来说,Map<K, V> 的类型定义会给每个属性相同的类型 V,无论你使用的 K 是什么。最少工作量但安全性也最低的方法是给它一个像 Map<GameEventType, GameEventListener<any>[]> 这样的类型,而不去担心潜在的不匹配问题:
private listenerMap = new Map<GameEventType, GameEventListener<any>[]>();
任何其他引用 GameEvent 或 GameEventListener 的地方也需要相应地修改:
public fireEvent<K extends GameEventType>(event: GameEvent<K>) { /*impl */ }
那是“最小”重构,它只更改类型定义而不更改任何运行时代码,并且每当某些东西涉及 listenerMap 时会放弃一些类型安全性。例如,在 on() 内,你可以将 get(eventType) 替换为某个特定类型,比如 get(GameEventType.EVENT_THREE),而编译器根本无法察觉其中有问题。
const existing = this.listenerMap.get(GameEventType.EVENT_THREE) ??
[]; // no error, oops
如果你真的关心类型安全,你可以考虑给 Map 提供一个更强的类型,但更简单的方法是直接使用普通的对象类型,例如 {[K in GameEventType]?: GameEventListener<K>[],并将其他类型重构为微软在 microsoft/TypeScript#47109 中提出的 分布式对象类型,以有效处理相关泛型:
interface GameEventMap {
[GameEventType.EVENT_ONE]: { parameter: string };
[GameEventType.EVENT_TWO]: { anotherParam: SomeType };
[GameEventType.EVENT_THREE]: {}
}
type GameEvent<K extends GameEventType> = { [P in K]: GameEventMap[P] & { type: P } }[K]
type GameEventListener<K extends GameEventType> = (event: GameEvent<K>) => void;
type ListenerMap<K extends GameEventType> = { [P in K]?: GameEventListener<K>[] };
然后 listenerMap 将会是类型为 ListenerMap<K> 的对象,K 可以是你关心的任何类型:
export class GameObserver {
private listenerMap: ListenerMap<GameEventType> = {}
public on<K extends GameEventType>(eventType: K, listener: GameEventListener<K>) {
const listenerMap: ListenerMap<K> = this.listenerMap;
let listeners = listenerMap[eventType];
if (!listeners) {
listeners = [];
listenerMap[eventType] = listeners;
}
listeners.push(listener);
}
public off<K extends GameEventType>(eventType: K, listener: GameEventListener<K>) {
const listenerMap: ListenerMap<K> = this.listenerMap;
let listeners = listenerMap[eventType];
if (!listeners) {
listeners = [];
}
listeners = listeners.filter(l => l !== listener);
listenerMap[eventType] = listeners;
}
public fireEvent<K extends GameEventType>(event: GameEvent<K>) {
const listenerMap: ListenerMap<K> = this.listenerMap;
let listeners = listenerMap[event.type];
if (!listeners) {
listeners = [];
}
listeners.forEach((l) => l(event));
}
}
这有点类似“最大化”的重构,在这种情况下,编译器将更能够捕捉到你类方法内部的类型错误,例如下面这一行,类似于之前的 listenerMap.get(GameEventType.EVENT_THREE):
let listeners = listenerMap[GameEventType.EVENT_THREE]; // error!
代码的 Playground 链接
