Observer — プラットフォームが持っている仕組み
標準 API でどこまで足り、自作の通知機構が要るのはどこからかを扱います。
このパターンが解こうとした問題
GoF は Observer を、多数の観測側が 1 つの出来事を受け取れるようにする発行購読の仕組みとして収録しています1。
通知する側が通知先を名指しで知っていると、通知先が増えるたびに通知側を書き換えることになります。Observer は通知先を登録制にすることで、通知側から通知先への依存を切ります。
このパターンには設計手法としての側面と、実装の手間としての側面があります。前者は今も有効です。後者は、実行環境が同じ仕組みを標準で持っているかどうかで話が変わります。
クラスで素直に書く
ダウンロードの進捗通知を題材にします。登録簿を自分で持つ形です。
type ProgressDetail = {loaded: number; total: number};
interface ProgressObserver {
onProgress(detail: ProgressDetail): void;
}
class DownloadJob {
private readonly observers: ProgressObserver[] = [];
subscribe(observer: ProgressObserver): void {
this.observers.push(observer);
}
unsubscribe(observer: ProgressObserver): void {
const index = this.observers.indexOf(observer);
if (index >= 0) {
this.observers.splice(index, 1);
}
}
report(detail: ProgressDetail): void {
for (const observer of this.observers) {
observer.onProgress(detail);
}
}
}
動きますが、unsubscribe に難があります。登録したときと同じ参照を保持していないと解除できません。 無名のオブジェクトを渡して登録すると、解除する手段がなくなります。購読が残り続けると、観測側のオブジェクトが解放されません。
言語機能で置き換える
ブラウザと Node.js のどちらにも EventTarget があります。
The
addEventListener()method of theEventTargetinterface sets up a function that will be called whenever the specified event is delivered to the target.2
解除については、参照を持ち回らずに済む仕組みが用意されています。
An
AbortSignal. The listener will be removed when theabort()method of theAbortControllerwhich owns theAbortSignalis called.2
これが効きます。購読の解除を、購読側の都合ではなく「有効期間」で表せます。
type ProgressDetail = {loaded: number; total: number};
class DownloadJob extends EventTarget {
report(detail: ProgressDetail): void {
this.dispatchEvent(new CustomEvent<ProgressDetail>('progress', {detail}));
}
}
const job = new DownloadJob();
const controller = new AbortController();
job.addEventListener(
'progress',
(event) => {
const {loaded, total} = (event as CustomEvent<ProgressDetail>).detail;
console.log(`${Math.round((loaded / total) * 100)}%`);
},
{signal: controller.signal},
);
job.report({loaded: 512, total: 1024});
// 画面を閉じるときなど、まとめて解除できる
controller.abort();
登録簿の管理コードが消え、解除の取りこぼしも起きにくくなります。同じ signal を複数の購読に渡せば、abort() 1 回ですべて解除されます。
型が付かない部分がある
上の例には as CustomEvent<ProgressDetail> というキャストが 1 つ残っています。addEventListener が渡してくるのは Event 型なので、detail の中身の型は自分で言い直すことになります。ここは型検査が届いていません。
キャストを呼び出し側にばらまかないよう、通知する側に型付きの入り口を用意します。
type ProgressDetail = {loaded: number; total: number};
class DownloadJob extends EventTarget {
report(detail: ProgressDetail): void {
this.dispatchEvent(new CustomEvent<ProgressDetail>('progress', {detail}));
}
// キャストをこのメソッドの中だけに閉じ込める
onProgress(
listener: (detail: ProgressDetail) => void,
options?: {signal?: AbortSignal},
): void {
this.addEventListener(
'progress',
(event) => listener((event as CustomEvent<ProgressDetail>).detail),
options,
);
}
}
const job = new DownloadJob();
const controller = new AbortController();
job.onProgress(({loaded, total}) => {
console.log(`${Math.round((loaded / total) * 100)}%`);
}, {signal: controller.signal});
呼び出し側はキャストを書かずに済み、detail の型が付きます。キャストは 1 か所に集約されました。
ただし包むことで挙動も変わります。onProgress は呼ばれるたびに新しい関数を addEventListener へ渡すので、同じ関数を 2 回登録すると 2 回呼ばれます。素の addEventListener は同じ参照を重複登録しません。個別の解除もできなくなるので、解除は signal に一本化することになります。
本連載の判断
判定: 条件付き。 出来事の種類の数と、型付けをどこまで求めるかで決まります。
- 出来事が数種類で、解除の管理が主な関心なら
EventTarget— 登録簿を自作する理由がありません。AbortSignalによる一括解除は、自作した場合に必ず作り込むことになる部分です。 - 出来事の種類ごとに引数の型を厳密に分けたいなら、型付きの自作機構 —
EventTargetは文字列のイベント名とEvent型が前提なので、種類ごとの型付けはキャストか包み込みで補うことになります。種類が十数個あって、それぞれ違う形のデータを運ぶなら、最初から型で引ける仕組みのほうが読みやすくなります。 - UI の状態を配りたいだけなら、そもそも Observer を書かない — フレームワークの状態管理がその役目を持っています。通知の仕組みを自作すると、フレームワーク側の再描画と二重管理になります。
自作を選ぶ場合でも、登録簿と解除の実装は AbortSignal を受け取る形に寄せると、標準の道具と組み合わせて使えます。
この判定が覆る条件
EventTarget は Node.js でも使えますが、Node.js には歴史的に別の通知機構 (EventEmitter) があり、既存のライブラリはそちらを前提にしていることがあります。**依存ライブラリがどちらを使っているかで、揃える先が決まります。**この選択は言語機能の優劣ではなく、周辺との整合の問題です。
もう 1 つ、dispatchEvent は同期的に購読側を呼びます。購読側が重い処理をすると通知側が待たされ、購読側が例外を投げたときの扱いも自分で決めることになります。購読側の失敗が通知側に伝播してよいかは、仕組みを選ぶ前に決めておく必要があります。
既存記事との関係
- ジェネリクスと型制約 に、型付きのイベントエミッターを自作する例があります。イベント名から引数の型を引く書き方は、そちらが扱っています。本章の判断でいう「型付きの自作機構」の具体形にあたるので、そちらを選ぶ場合の実装として参照できます。本章は標準 API の側から扱っているので、題材も構成も重なりません。