网站搜索

泛型 - 我如何用 TypeScript 创建 EventHandler 类?


我正在尝试将我的项目从 JavaScript 迁移到 TypeScript,并且在迁移处理事件的类时遇到了问题。

为了避免对添加/移除事件监听器的选项进行重复描述,我们使用这样的包装器:

constructor() {
  this.windowResizeHandler = new MyEventHandler(
    target: window,
    event: 'resize',
    handler: e => this.handleResize_(e),
    options: {passive: true, capturing: true},
  );
} 

connectedCallback() {
  this.windowResizeHandler.add();
}

disconnectedCallback() {
  this.windowResizeHandler.remove();
}

现在我不知道如何在 TypeScript 中编写这个而不丢失关于事件类型的信息。例如:

document.createElement('button').addEventListener('click', e => {
  // Here e is MouseEvent.
});

但是如果我这样写我的包装器:

interface EventHandlerParams {
  readonly target: EventTarget;
  readonly event: Event;
  readonly listener: (e: Event) => void;
  readonly params: AddEventListenerOptions;
}

export class EventHandler {
  public constructor(params: EventHandlerParams) {}
}

然后我丢失了类型定义:

new MyEventHandler(
  target: document.createElement('button'),
  event: 'click',
  handler: e => { /* Here e is just Event not MouseEvent */ },
  options: {passive: true, capturing: true},
);

我在这里有任何选项可以使用 lib.dom.d.ts 中的事件类型吗?


我尝试过类似这样的东西:

interface Mapping {
  [Window]: WindowEventMap;
  [HTMLElement]: HTMLElementEventMap;
}

interface EventHandlerParams<TTarget extends keyof Mapping,
                             TEventName extends keyof Mapping[TTarget],
                             TEvent extends Mapping[TTarget][TEventName]> {
  readonly event: TEventName;
  readonly listener: (event: TEvent) => void;
  readonly params?: AddEventListenerOptions;
  readonly target: TTarget;
}


export class EventHandler<TTarget extends keyof Mapping,
                          TEventName extends keyof Mapping[TTarget],
                          TEvent extends Mapping[TTarget][TEventName]> {
  public constructor(params: EventHandlerParams<TTarget, TEventName, TEvent>) {}
}

但是我不能使用这个,因为类型不能作为接口属性,并且没有其他选项为 TTarget 提供约束。

答案#1

lib.dom.ts 中有一个类型,它包含所有事件名称与事件参数类型之间的映射。它被称为 WindowEventMap。

所以例如我们可以写如下内容:

interface EventHandlerParams<T extends keyof WindowEventMap> {
    readonly target: EventTarget;
    readonly event: T;
    readonly options: AddEventListenerOptions;
    readonly listener: (e: WindowEventMap[T]) => void
}

export class EventHandler<T extends keyof WindowEventMap> {
    public constructor(params: EventHandlerParams<T>) { }
}


new EventHandler({
    target: document.createElement('button'),
    event: 'click',
    options: { passive: true },
    listener: e => { e.x /* e is MouseEvent */ }
});

EventHandlerParams 现在是泛型的,并且会将事件名称捕获为类型参数 T。我们还将 EventHandler 设为泛型,它的 T 将由传递给它的参数决定。通过获得 T(它将包含事件的字符串字面量类型),我们可以从 WindowEventMap 获取事件的实际参数类型,并在我们的监听器签名中使用它。

注意 我认为在 3.0 之前,传递给 listener 的参数可能无法推断为正确的类型(可能被推断为 any)。如果你遇到这个问题,请告诉我,我可以提供 3.0 之前的版本。

答案#2

我知道这有点晚,但我自己遇到了这个问题,所以我打算在这里留下我觉得非常有用的内容。James Garbutt 有一篇关于这个的好文章:在 TypeScript 中使用强类型事件。

简短的回答是:扩展全局的 EventMap 之一。TypeScript 有各种内置的映射,总结了常见的 DOM 事件。我将以 WindowEventMap 为例,但请记住还有其他映射,例如 DocumentEventMap 或 HTMLElementEventMap。

//This is something i used in a small game.
interface AudioEventDetail {
    action: 'play' | 'pause' | 'stop' | 'resume';
    type: 'background' | 'ui';
    audioId: number;
}

declare global {
    interface WindowEventMap {
        'audioEvent': CustomEvent<AudioEventDetail>;
        //You can add multiple definitions here:
        'tooltipEvent': CustomEvent<TooltipDetail>; //Like this.
    }
}

在这里,我只是扩展了接口 WindowEventMap,因为想法是添加一个像这样的监听器:

window.addEventListener('audioEvent', (event: CustomEvent<AudioEventDetail>) => {
    const { action, type, audio } = event.detail;
    //Here you have strongly typed access to action, type and audio parameters.
});

要发出它,你需要这样做:

window.dispatchEvent(new CustomEvent('audioEvent', { detail: { action: 'play', type: 'background', audio: 0 } }));

我唯一不喜欢这种方法的地方是,添加事件监听器时会是强类型的,但在构建它们时,基本上依赖于 CustomEvent 构造函数。

不过,您可以创建一个 class 来作为这种用途的语法糖:

class AudioEvent extends CustomEvent<AudioEventDetail> {
    constructor(detail: AudioEventDetail) {
        super('audioEvent', { detail });
    }
}

//So when extending WindowEventMap it would look like this:
declare global {
    interface WindowEventMap {
        'audioEvent': AudioEvent; //Here we use the class instead.
    }
}

//When Adding Listeners you're using the same class to type the event itself.
window.addEventListener('audioEvent', (event: AudioEvent) => {
    const { action, type, audio } = event.detail;
    //Your code here...
});

//And when emmiting a new event you'll use a constructor with strongly typed event parameters.
window.dispatchEvent(new AudioEvent({ action: 'play', type: 'background', audio: 0 }));

既然我们正在使用构造函数,你可以直接请求每个参数或设置默认值。你将再也不会丢失那些类型了!

希望能帮助到某人!:D