Typescript 事件侦听器 - 类型到道具映射

Typescript event listener - type to props mapping

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));
  }
}

考虑上面的事件侦听器 class。当你注册一个事件时,你提供你关心的事件类型和一个回调来接收事件对象。触发事件时,通过给 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)
  }
});

我真正喜欢的是:

我以不同的方式处理了后一点,使用 any,它在前两点上失败了(并且希望避免任何)。

如有任何关于此问题的建议,我们将不胜感激!感谢阅读。

至少,您需要在事件的类型 K 中创建 on()off() 方法 generic,以便编译器可以表示第一个和第二个参数之间的联系。让我们把 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 }>

这仅对 type 属性 为 K 的成员使用 the Extract<T, U> utility type to filter the original union。因此 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 属性,它使用 a Map object。不幸的是,TypeScript 的 Map<K, V> 类型给每个 属性 相同的类型 V,无论您使用的是 K 中的哪个键。最少的工作量但最不安全的是给它一个像 Map<GameEventType, GameEventListener<any>[]> 这样的类型,而不用担心潜在的不匹配:

private listenerMap = new Map<GameEventType, GameEventListener<any>[]>(); 

和任何其他引用GameEventGameEventListener的地方需要相应地修改:

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

如果你真的关心类型安全,你可以看看 , but it's much easier to just use a plain old object type like {[K in GameEventType]?: GameEventListener<K>[], and refactor the other types to be distributive object types as coined in 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));
  }
}

这是一种“最大”重构,其中编译器将更能够捕获您的 class 方法中的类型错误,例如类似于之前的 listenerMap.get(GameEventType.EVENT_THREE) 的以下行:

let listeners = listenerMap[GameEventType.EVENT_THREE]; // error!

Playground link to code