mizdra's blog

ぽよぐらみんぐ

150B で動く strictly-typed な Event Emitter を作った

超軽量で strictly-typed な (強く型付けされた) Event Emitter を作りました。

github.com

背景

従来 Event Emitter は ブラウザでは EventTarget として、Node.js では EventEmitter として、それぞれ独自に実装されていました。これらは機能的には変わりがありませんが、インターフェイスの互換性がありません。そのため、両方の環境で同じ API で動作する universal な Event Emitter が欲しければ、eventemitter3 のようなライブラリを利用する他ありませんでした。しかし、最近になってブラウザで実装されている EventTarget が Node.js に実装され始め *1*2EventTarget が Universal な Event Emitter としての地位を確立しつつあります。

EventTarget はネイティブな API であるため、eventemitter3 のような 3rd-party ライブラリと比較してサイズが小さい *3 という利点があります。しかし、一方で EventTargeteventemitter3 のように dispatch するイベントの種類を制限したり、addEventListener に渡したイベント名からリスナーで受け取れるイベントの値の型を推論することができません。そのため、予期せぬ種類のイベントが dispatch される可能性を考慮してコードを記述する必要があったり、明示的に型チェックを行わなければならないといった問題がありました。

@mizdra/strictly-typed-event-target の紹介

そこで、@mizdra/strictly-typed-event-target というライブラリを作りました。EventTargetCustomEvent をベースに作られているため、とても軽量です。ES Module 形式と UMD 形式の2つのビルドが用意されていて、この内 ES Module 形式はなんとたったの 150 B (厳密にいうと 152 B) で構成されています。

使い方

FooEventMap のようなインターフェイスにイベント名とイベントの値の型のリストを記述し、createSTEventTarget に型パラメータとして渡すと、強く型付けされた CustomEventEventTarget が返り値で得られます。簡単ですね。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 が生えていて、全く同じように使えます。ただし、FooCustomEventdetail フィールドが 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

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.