网站搜索

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 链接