TypeScript における代表的な event 実装方法
TypeScript は言語仕様としては event 機能を持っていません。
なので class に event を実装する場合、自前で実装するか EventEmitter のような補助クラスを使うことになりますが、TypeScript ですのでやはり type safety に実装したいところです。
type safety に実装する代表的な方法は次の通りです。
- EventEmitter + 型定義 (event 一つ一つに特化した on(), once(), off(), removeAllListeners() あたりのメソッドを定義していく)
- StrictEventEmitter を導入する
- TypedEvent を用意する
それぞれの特徴は次の通りです。
方法 | 共通クラス導入方法 | event 表現方法 | event 実装コスト | 表現力 |
---|---|---|---|---|
EventEmitter + 型定義 | 不要1 | 引数 | 高 | 高 |
StrictEventEmitter | パッケージ追加 | 引数 | 低 | 高 |
TypedEvent | 自前実装 | プロパティ | 低 | 中2 |
この中では StrictEventEmitter が断然お勧めです。今後のデファクトスタンダードになるのかなとも思います。
StrictEventEmitter に対して気になる点
StrictEventEmitter に対して、2点ほど気になる点があります。
- EventEmitter の継承が必要
- emit が public アクセス可能
前者はオブジェクト指向設計の観点からあまり適切ではないですね。設計が濁ります。概念として抽象・具象の関係にあるものが継承されるべきです。
後者は前者よりも切実です。event の発火をクラス外部からも行えてしまいます。prefix としてアンダースコアでも付いているならまだしも、他のメソッドと全く同じようにぶらさげているのでは「発火しても構わないよ」と言っているようなものです。全然 safety ではありませんね。
例えば event の発生条件を勘違いしたチームメンバーが「なぜかこのタイミングでは event が通知されないので手動で発生させる」なんてコメントと共に外部から emit() を叩いてしまうかもしれないわけです。
ちなみに後者は、他の2つの方法についても同じく生じる問題です。
EventEmitter + EventPort というアプローチ
ということで、私はこれらのいずれの方法でもなく、下記の EventPort というクラスを自前実装して EventEmitter と組み合わせて使用しています。
import { EventEmitter as OriginalEventEmitter } from "events";
export class EventEmitter extends OriginalEventEmitter {
public emit<T extends (...args: any[]) => void>(port: EventPort<T>, ...args: Parameters<T>): boolean;
public emit(name: string | symbol, ...args: any): boolean;
public emit(event: any, ...args: any[]) {
const name = event instanceof EventPort ? event.name : event;
return super.emit(name, ...args);
}
}
/**
* A port to deliver an event to listeners.
*/
export class EventPort<T extends (...args: any[]) => void> {
/**
* Initialize an instance of EventPort<T> class.
* @param name The name of the event.
* @param emitter An instance of EventEmitter class.
*/
public constructor(name: string | symbol, emitter: EventEmitter) {
this._name = name;
this._emitter = emitter;
}
private readonly _name: string | symbol;
private readonly _emitter: EventEmitter;
/**
* Gets the name of the event.
*/
public get name() {
return this._name;
}
/**
* Adds a listener.
* @param listener The listener to be added.
*/
public on(listener: T) {
this._emitter.on(this._name, listener);
}
/**
* Adds a listener that will be called only once.
* @param listener The listener to be added.
*/
public once(listener: T) {
this._emitter.once(this._name, listener);
}
/**
* Removes a listener.
* @param listener The listener to be removed.
*/
public off(listener: T) {
this._emitter.off(this._name, listener);
}
/**
* Removes the all listeners.
* @param listener
*/
public removeAllListeners() {
this._emitter.removeAllListeners(this._name);
}
}
下記は使用例です。
import { EventEmitter, EventPort } from "./events";
class Hoge {
public constructor() {
this._eventEmitter = new EventEmitter();
this._fugaCalledEvent = new EventPort("fugaCalled", this._eventEmitter);
this._piyoCalledEvent = new EventPort("piyoCalled", this._eventEmitter);
}
private readonly _eventEmitter: EventEmitter;
private readonly _fugaCalledEvent: EventPort<(value: string) => void>;
private readonly _piyoCalledEvent: EventPort<(a: number, b: number) => void>;
public get fugaCalledEvent() {
return this._fugaCalledEvent;
}
public get piyoCalledEvent() {
return this._piyoCalledEvent;
}
public fuga(value: string) {
this._eventEmitter.emit(this._fugaCalledEvent, value);
}
public piyo(a: number, b: number) {
this._eventEmitter.emit(this._piyoCalledEvent, a, b);
}
}
const hoge = new Hoge();
hoge.fugaCalledEvent.on(value => console.warn(`value: ${value}`));
hoge.piyoCalledEvent.on((a, b) => console.warn(`a: ${a}, b: ${b}`));
hoge.fuga("Hello.");
// value: Hello
hoge.piyo(1, 2);
// a: 1, b: 2
EventPort クラスは TypedEvent クラスのようにプロパティとして event を表現します。
TypedEvent クラスとの大きな違いは、
- 外部に本当に公開したい機能だけを持つ
- event の引数ではなく listener の型を指定する形にすることで表現力の弱さを解消
- 処理は EventEmitter に委譲
- event の発火は EventEmitter を通じて本体クラスが行う
の4点です。
また、本体クラスは EventEmitter の継承は行わず、単に集約の関係に留めます。これにより、誤った継承関係の排除だけでなく emit() の隠蔽も達成しています。
ちなみに emit() については、第一引数に EventPort を受け取れるようにし、可変長引数を Conditional Types でフィットさせるようにオーバーロードしています。
ご覧の通り、listener 登録も event 発火も type safety です。
まとめ
以上を踏まえて表を更新してみました。
方法 | 共通クラス導入方法 | event 表現方法 | event 実装コスト | 表現力 | 設計汚染 | emit 隠蔽 |
---|---|---|---|---|---|---|
EventEmitter + 型定義 | 不要1 | 引数 | 高 | 高 | 有 | 不十分 |
StrictEventEmitter | パッケージ追加 | 引数 | 低 | 高 | 有 | 不十分 |
TypedEvent | 自前実装 | プロパティ | 低 | 中2 | 無 | 不十分 |
EventPort | 自前実装 | プロパティ | 低 | 高 | 無 | 完全 |
右端2列が気にならない方は StrictEventEmitter、気になる方は EventPort が良いと思います。
EventPort は自前実装の形になりますが、非常に単純なつくりですので問題にならないと思います。
あとは event 表現方法ですかね。どっちが優れているというものではなく、好みの問題が大きいかなと思います。
ちなみに私は元々 C#er なので、プロパティ形式の方がどちらかというとしっくりきます。
更新履歴
- 2020/07/14: events.ts で EventEmitter を拡張する際の二重実行対策 (_emit が存在しない場合にのみ実行) を追加。
- 2020/08/20: events.ts で EventEmitter を継承+オーバーロードで拡張するよう変更。