超軽量で strictly-typed な (強く型付けされた) Event Emitter を作りました。
背景
従来 Event Emitter は ブラウザでは EventTarget
として、Node.js では EventEmitter
として、それぞれ独自に実装されていました。これらは機能的には変わりがありませんが、インターフェイスの互換性がありません。そのため、両方の環境で同じ API で動作する universal な Event Emitter が欲しければ、eventemitter3
のようなライブラリを利用する他ありませんでした。しかし、最近になってブラウザで実装されている EventTarget
が Node.js に実装され始め *1*2、EventTarget
が Universal な Event Emitter としての地位を確立しつつあります。
EventTarget
はネイティブな API であるため、eventemitter3
のような 3rd-party ライブラリと比較してサイズが小さい *3 という利点があります。しかし、一方で EventTarget
は eventemitter3
のように dispatch するイベントの種類を制限したり、addEventListener
に渡したイベント名からリスナーで受け取れるイベントの値の型を推論することができません。そのため、予期せぬ種類のイベントが dispatch される可能性を考慮してコードを記述する必要があったり、明示的に型チェックを行わなければならないといった問題がありました。
@mizdra/strictly-typed-event-target
の紹介
そこで、@mizdra/strictly-typed-event-target
というライブラリを作りました。EventTarget
と CustomEvent
をベースに作られているため、とても軽量です。ES Module 形式と UMD 形式の2つのビルドが用意されていて、この内 ES Module 形式はなんとたったの 150 B (厳密にいうと 152 B) で構成されています。
使い方
FooEventMap
のようなインターフェイスにイベント名とイベントの値の型のリストを記述し、createSTEventTarget
に型パラメータとして渡すと、強く型付けされた CustomEvent
と EventTarget
が返り値で得られます。簡単ですね。eventemitter3
を触ったことのある人なら分かると思うのですが、EventMap
インターフェイスを渡して型付けする部分は eventemitter3
を真似てます。
import { createSTEventTarget } from '@mizdra/strictly-typed-event-target'; interface FooEventMap { onmessage: string; onerror: Error; oninstall: undefined; } const [FooCustomEvent, FooEventTarget] = createSTEventTarget<FooEventMap>();
FooCustomEvent
/ FooEventTarget
はネイティブのものと同じ API が生えていて、全く同じように使えます。ただし、FooCustomEvent
の detail
フィールドが EventMap
に応じて型付けされていたり、EventMap
で定義されていないイベントの dispatch が禁止されていたりと、より強く型付けされた APIとなっています。
const fooEventTarget = new FooEventTarget(); /** `addEventListener` */ // error: Argument of type '"invalid-event"' is not assignable // to parameter of type '"onmessage" | "onerror" | "oninstall"'. fooEventTarget.addEventListener('invalid-event', () => {}); fooEventTarget.addEventListener('onmessage', (event) => { // `event.detail` is infered `string` type. }); fooEventTarget.addEventListener('onerror', (event) => { // `event.detail` is infered `Error` type. }); /** `dispatchEvent` */ fooEventTarget.dispatchEvent( new FooCustomEvent('onmessage', { detail: 'hello' }) ); // error: Type 'Error' is not assignable to type 'string'. fooEventTarget.dispatchEvent( new FooCustomEvent('onmessage', { detail: new Error() }), ); fooEventTarget.dispatchEvent(new FooCustomEvent('oninstall'));
152 B のトリック
実装をよく見てもらえると分かるのですが、ライブラリの大部分は型情報で、JS にトランスパイルされた時に残る部分は createSTEventTarget
の実装部分だけです。createSTEventTarget
も本当に素朴なことしかしていなくて、ネイティブの CustomEvent
/ EventTarget
をただ strictly-typed な CustomEvent
/ EventTarget
へと type assertion しているだけです。ズルいですね。
export function createSTEventTarget<EventMap extends {}>() { const STCustomEvent = (CustomEvent as unknown) as STCustomEvent<EventMap>; const STEventTarget = EventTarget as STEventTarget<EventMap>; return [STCustomEvent, STEventTarget] as const; }
そしてこれをトランスパイルし、ES Modules 形式で出力したものが以下になります。これで丁度 152 B です。
export function createSTEventTarget() { var STCustomEvent = CustomEvent; var STEventTarget = EventTarget; return [STCustomEvent, STEventTarget]; } //# sourceMappingURL=index.js.map
ちなみにより踏み込んだ話をするなら、minify すればもっと小さくなりますし、何なら以下のように型定義だけ使えば、トランスパイル時に型定義が削除されて 0 B になります。まあたった 152 B を嫌って長ったらしい型注釈を書きたい人は居ないはずなので、ただの面白テクニックという感じですが。
import { STCustomEvent, STEventTarget, } from '@mizdra/strictly-typed-event-target'; interface FooEventMap { onmessage: string; onerror: Error; oninstall: undefined; } const FooCustomEvent = (CustomEvent as unknown) as STCustomEvent<FooEventMap>; const FooEventTarget = EventTarget as STEventTarget<FooEventMap>;
変更履歴
- 2020/09/21
- 本ライブラリが他のライブラリと比較して高速であるという旨の説明をしていましたが、実際には割と遅いほうだということが判明したため、速度に関する表記を削除しました (参考1, 参考2)
- 当初既存の
EventTarget
が型安全ではないという説明がなされていましたが、これは誤りでした。実際にはEventTarget
は型安全であり、本ライブラリはそれをより強く型付けしたライブラリという位置づけです。そこでそれを反映すべくライブラリをstrictly-typed-event-target
からstrictly-typed-event-target
に rename しました。 - サイズがgzip後のものを指していたため、gzip前のサイズになるよう本文や記事タイトルを書き換えました (151 B => 158 B)
- 合わせて関数名の rename を行った結果、サイズが少し減りました (158 B => 152 B)
*1:2020/09 現在ではまだ一般ユーザに公開されていない、internal で実験的な機能です。
*2:IE11及びSafariでは一部のAPIがサポートされていません。
*3:むしろ一切バンドル不要なので、実質 0 B