TypeScriptでEventEmitterを原理を理解しながら実装する(初級編)
目次
こんにちは、@Himenonです。EventEmitter を TypeScript で実装する方法について紹介します。
EventEmitter とは
EventEmitter はデザインパターンの一つ、Observer Pattern の実装です。
Node.js では”events”モジュールから EventEmitter を提供しており、ブラウザ用でもバンドラーツールを利用すれば events モジュールを利用することができます。
ブラウザ上で JavaScript を書いている開発者は間違いなくaddEventListenerや.onclickなどを書いたことがあると思います。これらも根本的には EventEmitter と同じ構造を取っています。インフラストラクチャのコンテキストでは Pub/Sub などの購読モデルの実装でも同様に同じ構造になっています。
すなわち、インフラストラクチャー目線で EventEmitter を見れば、データの依存方向やイベントドリブンなシステムデザインが実装ベースでも可能となります。
紹介する内容と参考にする実装
OSS の EventEmitter として読みやすいものとしてmittというライブラリがあります。
v3.0.0 現在だと 119 行の実装で、TypeScript の型定義やコメントアウトを除けば 100 行にも満たない実装で終わります。今回はこれをベースに少し改造した内容を紹介していきます。
EventEmitter を実装する
前準備
EventEmiter を実装する上で重要な視点は「関数を参照(ポインタ)として扱う」ことです。EventEmitter では関数は定義して終わりではなく、その参照に対する参照を別の変数で取り扱うことを行います。
つまり、関数に対して次のような扱いをします。
const func1 = () => console.log("hello func 1");
function func2() {
console.log("hello func2 ");
}
const list = [func1, func2]; // 参照を格納
func1 === func2; // = true. 参照の比較
EventEmitter の簡単な実装例
今回は TypeScript の型定義については大幅に省略して、EventEmitter の本質的な実装部分を紹介していきます。行数も短いので先に全体の実装を見せると、次のようになります。TypeScript Playground
type Callback = (payload: any) => void;
const createEventEmitter = () => {
const callbackMap = new Map<string, Callback[]>();
const on = (key: string, callback: Callback) => {
const callbackList = callbackMap.get(key);
if (callbackList) {
callbackList.push(callback);
} else {
callbackMap.set(key, [callback]);
}
};
const off = (key: string, callback: Callback) => {
const callbackList = callbackMap.get(key);
if (callbackList) {
callbackMap.set(
key,
callbackList.filter((target) => target !== callback)
);
}
};
const emit = (key: string, payload: any) => {
(callbackMap.get(key) || []).forEach((callback) => {
callback(payload);
});
};
return {
on,
off,
emit,
};
};
使い方
実装した EventEmitter は次のように利用することが可能です。
// EventEmitterのインスタンスの作成
const myEventEmitter = createEventEmitter();
const showMessage = (payload) => {
console.log({ payload });
};
// 登録
myEventEmitter.on("hello", showMessage);
myEventEmitter.on("?", showMessage);
myEventEmitter.emit("hello", "world");
// { "payload": "world" }
myEventEmitter.emit("?", { message: "!" });
// { "payload": { message: "!" } }
myEventEmitter.off("?", showMessage);
myEventEmitter.emit("?", { message: "!" });
// empty
解説
EventEmitter の内部で管理する関数は一意に次のような型定義としています。
type Callback = (payload: any) => void;
createEventEmitter は EventEmitter の Factory として機能しています。
Facoty 内では new Map<string, Callback[]>を内部状態として持ち、ここに登録(購読)する関数の参照を格納していきます。Mpa の key は emit する際に利用するキー名です(”click”や”blur”と同じ)。
後は登録(on)・削除(off)・実行(emitt)の 3 つのパートに別れます。
登録
初めて登録するときは、Map に配列が存在しない可能性があるので、その場合は新規に作成し、存在する場合は、配列に対して push します。
const on = (key: string, callback: Callback) => {
const callbackList = callbackMap.get(key);
if (callbackList) {
// callbackListは参照なので、改めてMapに対してsetする必要はない
callbackList.push(callback);
} else {
callbackMap.set(key, [callback]);
}
};
削除
削除する場合は、key 名と関数の参照を渡すようにします。EventEmitter を実装する前準備で説明したように、関数の参照比較によって配列から除外することが可能です。
const off = (key: string, callback: Callback) => {
const callbackList = callbackMap.get(key);
if (callbackList) {
callbackMap.set(
key,
callbackList.filter((target) => target !== callback)
);
}
};
実行
実行時は、Map から Callback の配列参照を受け取り、forEach でループ処理させるだけです。
const emit = (key: string, payload: any) => {
(callbackMap.get(key) || []).forEach((callback) => {
callback(payload);
});
};
EventEmitter の使い所
上記の実装で見てきたように、EventEmitter は処理の登録と実行を分離することが可能です。このとき、EventEmitter の Interface を知っていれば良いので、処理を疎結合にすることができます。例えばプラグインシステムを実装する場合や、別のライフサイクルを持つフレームワークやライブラリとの腐敗防止層としても機能させることが可能です。
EventEmitter で考えること
EventEmitter にも弱点があります。今回作成した callbackMap の参照をいつ開放するか、という問題です。メモリリークの原因となるので、必ずクリーンアップしたり、要件によっては WeakMap を利用してガベージコレクションを任せたりと、取り扱いには注意が必要です。
また、一応、登録順に実行されるものの、どこに参照が伸びているのかわからないため、登録された順番に処理が実行されるとは限らない、という前提で利用する必要があります。
まとめ
EventEmiter の基本的な実装方法を紹介しました。実装の考え方はシンプルですが使い方によっては強力に利用できる実装パターンです。ただし、メモリリークをしないように登録した参照を開放することを忘れないようにしましょう。