用恶心的类型魔法构建类型安全的 Custom Event

TypeScript 的类型系统和 JavaScript 的事件系统似乎有点相性不合,具体点讲,如果你想在里面加一些自定义的类型标注就必须的直接改 EventTarget (或者 EventEmitter)的类型定义,再或者用类似 event.detail as SomeType 的方式构建一种很微妙的「类型安全」氛围。

这两种方法都是我不喜欢的,前者会对类型系统造成污染,个人更加喜欢把「做同一件事情的代码放在一起」,但是按照前者的思路,类型定义和真正的 Event Listener 会「身首异处」,看起来非常可怜,而且,比如你在模块 A 中没有用到模块 B 声明的类型定义,但是它的 EventTarget 还是会带着模块 B 的类型定义,看起来就很脏,有一种全局变量满天飞的味道。而第二种做法则完全没有没有做到真正的类型安全,如果你 as 错了,那一切都没得聊了。

前些日子花了点心思研究了一下这种东西究竟要怎么写,最后选择了一种把我自己恶心到了的方法,可以做到大面上干净清爽但是内部恶臭得要死。

对了,同学我跟你讲,基本遇到这种需求魔改 EventTarget 和 CustomEvent 是没办法避免的,如果您在这部分有洁癖的话还请直接用 as(

基本思路:我们希望类型安全系统怎么工作?

在设计这套系统的时候我的整体思路是,给字符串加个泛型作为事件类型定义并且导出,用的时候把这个类型定义导进来,泛型拆开,读里面的类型定义,最后扔到 Custom Event 里,我们举个例子,比如你在做一个 RPG 游戏:

1
2
3
4
5
6
7
8
9
10
11
12
// Let's define some events in this file.
import { EventDefinition } from 'utils/CustomEventTarget';
import { currentPlayer } from '../session';

export const CHARACTER_PROFILE_UPDATE = EventDefinition<{level: number, role: string}>();
export const upgradeLevel = (level: number, role: string = 'Priest') => {
const event = new CustomEvent(
CHARACTER_PROFILE_UPDATE,
{ detail: { level, role } }
);
currentPlayer.dispatchEvent(event);
}

这个架空的例子蛮简单的,我们定义了一个事件,叫做 CHARACTER_PROFILE_UPDATE,是角色信息发生变化时触发的事件,一个叫 upgradeLevel 的函数,当角色等级发生变化时就调用这个函数,之后让 currentPlayer 广播出一个升级事件,订阅这个事件的地方刷新 UI 元素或者发送网络请求(嚯,还是个网游?)。

我们期待的是 TypeScript 能够通过自动的检查第 9 行处的 event detail 类型。

接下来,如果我们要订阅这个事件的话,可以这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Let's consume the event definition in this file.
import { CHARACTER_PROFILE_UPDATE } from './upgradeLevel';
import { currentPlayer } from '../session';

const PlayerProfileLevelLabel = () => {
const $label = document.createElement('SPAN');

currentPlayer.addEventListener(
CHARACTER_PROFILE_UPDATE,
(event) => $label.innerText = event.detail.level
)

return $label
}

呃……其实这块应该用 PIXI 来写的,但是考虑到不是每个人都用过,所以用了看起来比较怪异的 DOM 写法,只是为了演示怎么加监听,不要在意细节……

好的,类型魔法师们你们已经懂要怎么实现了,请 Ctrl + W 关掉本窗口,下面的东西是给 TypeScript 新手看的。 _(:3 」∠ )_

类型魔法:用神奇的方法给字符串绑上泛型

通过 Hacky 的方式给字符串加泛型

最脏的地方就是这里了,我自认为我通过反模式的方法达成了一些不可告人的目的(?

首先,我们声明一个 EventName 的类型,它扩展了 String:

1
declare class EventName<T> extends String {};

declare 关键字不一定非要用在 .d.ts 文件里面用,正常的 .ts 文件也可以 declare 一个类型,这里我们不真的构建一个 class,而是定义一个「架空的类型」,这个架空的类型完全就是一个能够装泛型的容器。

然后,我们定义一个生成事件名称的函数:

1
2
3
4
5
import uuid from 'uuid';

export function EventDefinition<T>() {
return uuid.v4() as EventName<T>
}

uuid 是一个你要额外装的包,这个函数在做的事情是生成一个 v4 的 uuid,也就是完全随机的 uuid。我们要用 asuuid 函数输出的 string 强行把拧成 EventName<T>。因为 EventName 扩展了 string,所以整个代码的逻辑在氛围上是没问题的。

这里我选择用一个 uuid 是为了确保其唯一性,如果让开发者自己指定事件的名称会有两个问题:

  • 难免会脑残起重名了,这样就会有莫名其妙的冲突;
  • 我希望编辑器能自动帮你判断事件名有没有打错,如果事件名打错了你是监听不到事件的(我跟你讲我在这个地方有血泪史)。通过把事件名声明成一个常量,编辑器就会告诉你变量名有没有打错,如果你打的是一个字符串,那编辑器就毛都看不出来了。

一些简单的封装

接下来的事情就很简单了,把 CustomEventEventTarget 重新包装包装,让它能自己做类型诊断。

让 CustomEvent 认得你的 EventName

这块思路相对来讲比较简单,直接把 CustomEvent 封一下就好了:

1
2
3
4
5
6
7
8
export class CustomEvent2<T> extends CustomEvent<T> {
constructor(
typeArg: EventName<T>,
eventInitDict: CustomEventInit<T>
) {
super(typeArg as string, eventInitDict);
}
}

核心思路是,把你原来宁歪的类型用 as 关键字给强行拧回去,让类型检查不要大叫。

让 EventTarget 认得你的 CustomEvent

这块相对来讲也比较直接一些,EventTarget 一共只有三个方法,各自覆盖一次就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export class EventTarget2 {
private eventListener = new EventTarget()
addEventListener<T>(
type: EventName<T>,
listener: EventListener2<T>,
options?: boolean | AddEventListenerOptions
) {
return this.eventListener.addEventListener(
type as string,
listener as EventListener,
options
)
}
dispatchEvent<T>(event: CustomEvent2<T>) {
return this.eventListener.dispatchEvent(event)
}
removeEventListener<T>(
type: EventName<T>,
callback: EventListener2<T>,
options?: EventListenerOptions | boolean
) {
return this.eventListener.removeEventListener(
type as string,
callback as EventListener,
options
)
}
}

因为封装的非常轻度,所以 overhead 不会很大,性能接近原生 EventTarget

额外的一些内容

通过 infer 关键字封装工具类型

接下来我们尝试做一件很常见的事情,封装一个泛型,能够把 EventDefinition 里面的泛型抠出来,主要为了处理有些包该导出类型的时候不导出你又要用它类型这种情况。

首先我们先来学习一下怎么样抠泛型,假设我们定义了一个有泛型的数组:

1
const skillIds: number[] = [1, 5, 9, 21];

skillIds 的类型会是 number[](额,或者你可以比较错误的理解成 Array<number>),如果想从这个类型里面把 number 抠出来的话得用 infer 关键字:

1
type GenericOfArray<T> = T extends (infer R)[] ? R : never;

我们来把这行翻译成「人话」:类型 T 是否继承了一个泛型为 R 的数组?如果是,就把 R 吐出来,否则就返回 never (这个类型表示啥也不是)。接下来,就算你没有明确的指定类型 RTypeScript 也会帮你把 R 推断出来

比如我们这样用:

1
type SkillId = GenericOfArray<typeof skillIds>; // The type SkillId would be number.

类似的,你还可以把 Promise 的泛型抠出来:

1
type GenericOfPromise<T> = T extends Promise<infer R> ? R : never;

然后如果你想封装一个抠 EventName 的类型,就这么写:

1
type GenericOfEventName<T> = T extends EventName<infer R> ? R : never;

用的时候可以这么用:

1
type LevelUpEventDetail = GenericOfEventName<typeof CHARACTER_PROFILE_UPDATE>;

结尾

傻逼 Safari 最近才把 EventTarget 给实现出来,所以记得打 polyfill。

完整的实现和用例在这里,你可以快乐的复制粘贴。

以上就是今天的开发笔记,祝大家开发愉快。

Comments

No comments here,

Why not write something?