zukucode
主にWEB関連の情報を技術メモとして発信しています。

TypeScript イベント名と通知データを型で対応させる

プロフィールを更新したとき、ヘッダーの表示や別の画面へ変更を知らせたい場合があります。

更新処理からすべての画面を直接呼ぶと、通知先が増えるたびに更新処理も修正することになります。

今回は、イベント名と通知データの型を対応させ、小さな通知の仕組みを作る方法を紹介します。イベントとは、ここでは「プロフィールが更新された」といった出来事の通知です。

直接呼ぶか、通知するかを分ける

保存後に必ず行う処理が1つなら、関数を直接呼ぶ方が簡単です。

一方、通知先が複数あり、送信側がそれぞれの画面を知る必要がないなら、イベントバスを使う方法があります。イベントバスは、通知する側と受け取る側をつなぐ仕組みです。

方法向いている場面
関数を直接呼ぶ戻り値や処理の完了を待ちたい
イベントで知らせる起きたことを、登録されている複数の処理へ知らせたい

イベントを使うと依存を減らせますが、どの処理が動くのかは登録箇所を探さないとわかりません。すべての処理をイベントへ置き換える必要はありません。

イベント名とデータを1つの型へまとめる

AppEvents.ts
export type AppEvents = {
  profileUpdated: { userId: string };
  signedOut: { reason: 'userAction' | 'expired' };
};

profileUpdatedには利用者ID、signedOutにはログアウトの理由を渡す約束です。

両方を単なる文字列とunknownで受け取るより、名前を選んだ時点で渡すデータの型も決まる方が、呼び出し側の間違いを見つけやすくなります。

通知と購読の処理を作る

EventBus.ts
type Listener<T> = (payload: T) => void;

export class EventBus<E extends object> {
  private listeners: {
    [K in keyof E]?: Set<Listener<E[K]>>;
  } = {};

  on<K extends keyof E>(type: K, listener: Listener<E[K]>): () => void {
    (this.listeners[type] ??= new Set()).add(listener);

    return () => {
      this.listeners[type]?.delete(listener);
    };
  }

  emit<K extends keyof E>(type: K, payload: E[K]): void {
    const listeners = this.listeners[type];
    if (!listeners) return;

    for (const listener of [...listeners]) {
      listener(payload);
    }
  }
}

keyof Eは、イベント名の候補です。E[K]は、その名前に対応するデータの型を表します。

onは通知を受け取る関数の登録、emitは通知です。Setは登録した関数を保持します。同じ関数を二重に登録しても1つとして扱うため、解除するとその登録はなくなります。

通知時には配列へコピーしています。通知中に新しい購読を追加しても、今回の通知対象へ途中参加させないためです。ただし、コピー後に解除された関数も今回の通知では呼ばれます。この程度の動作も、小さな仕組みを作るときに決めておきます。

呼び出す側で型を確認する

events.ts
import { EventBus } from './EventBus';
import type { AppEvents } from './AppEvents';

export const events = new EventBus<AppEvents>();
利用例
import { events } from './events';

const unsubscribe = events.on('profileUpdated', ({ userId }) => {
  console.log(`更新された利用者:${userId}`);
});

events.emit('profileUpdated', { userId: 'user-1' });
unsubscribe();

profileUpdatedへ{ reason: 'expired' }を渡すと、型エラーになります。イベント名を変えた場合も、古い名前の参照を型チェックで探せます。

通知データには、必要なIDなどに絞った情報を入れます。画面の内部状態や大量のデータを渡し始めると、機能同士が細かい構造に依存するようになります。

登録したら解除する

画面を開くたびに購読して解除しないと、同じ通知へ複数回反応する原因になります。

onから解除用の関数を返しておけば、呼び出し側は登録の詳細を知らずに後片付けできます。ReactならEffectのクリーンアップから呼ぶ方法があります。

useEffectの後片付けでイベント登録を解除する方法

届かなかった通知を保存するか決める

この例は、購読者がいないと通知を破棄します。後から画面を開いても、過去の通知は届きません。

通知を一時保存する設計もできますが、その場合は、保存件数の上限、いつ消すか、最初の購読者だけが受け取るかなど、新しいルールが必要です。

現在のプロフィールを知りたいだけなら、過去の通知を保存するより、画面を開いたときに最新データを取得する方が単純な場合もあります。イベントを状態の保存場所として使わないことが大切です。

購読した関数は順番に呼ばれます。途中の関数が例外を投げると、その後の通知は止まります。

非同期の購読処理が返すPromiseも待ちません。完了や失敗を管理したい処理は、通常の非同期関数として呼ぶか、そのための通知ルールを別途用意します。

また、このイベントバスは作成したインスタンスの中だけで動きます。別タブや別サーバーへは通知しません。サーバーで利用する場合も、利用者ごとの情報が混ざらないよう、インスタンスを共有する範囲を決めます。

導入するときに確認すること

確認する操作期待する結果
2つの関数を登録して通知両方へ同じデータが届く
登録を解除してから通知解除した関数は呼ばれない
購読者がいない状態で通知この例では保存せず終了する
別のイベントを通知関係のない購読者は呼ばれない
データの型を間違えて通知TypeScriptの型チェックで検出する

通知先を増やすたびに送信側のimportが増えているなら、イベントで知らせる構成を検討できます。処理の順番や成功を保証したいところは直接呼び出しのままにすると、追いやすさも保てます。


関連記事