Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 9164

TypeScript の event を良い感じの type safety に実装する

$
0
0

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 と組み合わせて使用しています。

events.ts
import{EventEmitter}from"events";export{EventEmitter};/**
 * A port to deliver an event to listeners.
 */exportclassEventPort<Textends(...args:any[])=>void>{/**
     * Initialize an instance of EventPort<T> class.
     * @param name The name of the event.
     * @param emitter An instance of EventEmitter class.
     */publicconstructor(name:string|symbol,emitter:EventEmitter){this._name=name;this._emitter=emitter;}privatereadonly_name:string|symbol;privatereadonly_emitter:EventEmitter;/**
     * Gets the name of the event.
     */publicgetname(){returnthis._name;}/**
     * Adds a listener.
     * @param listener The listener to be added.
     */publicon(listener:T){this._emitter.on(this._name,listener);}/**
     * Adds a listener that will be called only once.
     * @param listener The listener to be added.
     */publiconce(listener:T){this._emitter.once(this._name,listener);}/**
     * Removes a listener.
     * @param listener The listener to be removed.
     */publicoff(listener:T){this._emitter.off(this._name,listener);}/**
     * Removes the all listeners.
     * @param listener
     */publicremoveAllListeners(){this._emitter.removeAllListeners(this._name);}}declaremodule"events"{interfaceEventEmitter{emit<Textends(...args:any[])=>void>(port:EventPort<T>,...args:Parameters<T>):boolean;emit(name:string|symbol,...args:any):boolean;}}(EventEmitterasany).prototype._emit=(EventEmitterasany).prototype.emit;(EventEmitterasany).prototype.emit=function(event:any,...args:any[]){constname=eventinstanceofEventPort?event.name:event;return(thisasany)._emit(name,...args);};

下記は使用例です。

import{EventEmitter,EventPort}from"./events";classHoge{publicconstructor(){this._eventEmitter=newEventEmitter();this._fugaCalledEvent=newEventPort("fugaCalled",this._eventEmitter);this._piyoCalledEvent=newEventPort("piyoCalled",this._eventEmitter);}privatereadonly_eventEmitter:EventEmitter;privatereadonly_fugaCalledEvent:EventPort<(value:string)=>void>;privatereadonly_piyoCalledEvent:EventPort<(a:number,b:number)=>void>;publicgetfugaCalledEvent(){returnthis._fugaCalledEvent;}publicgetpiyoCalledEvent(){returnthis._piyoCalledEvent;}publicfuga(value:string){this._eventEmitter.emit(this._fugaCalledEvent,value);}publicpiyo(a:number,b:number){this._eventEmitter.emit(this._piyoCalledEvent,a,b);}}consthoge=newHoge();hoge.fugaCalledEvent.on(value=>console.warn(`value: ${value}`));hoge.piyoCalledEvent.on((a,b)=>console.warn(`a: ${a}, b: ${b}`));hoge.fuga("Hello.");// value: Hellohoge.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 です。
image.png
image.png

まとめ

以上を踏まえて表を更新してみました。

方法共通クラス導入方法event 表現方法event 実装コスト表現力設計汚染emit 隠蔽
EventEmitter + 型定義不要1引数不十分
StrictEventEmitterパッケージ追加引数不十分
TypedEvent自前実装プロパティ2不十分
EventPort自前実装プロパティ完全

右端2列が気にならない方は StrictEventEmitter、気になる方は EventPort が良いと思います。
EventPort は自前実装の形になりますが、非常に単純なつくりですので問題にならないと思います。

あとは event 表現方法ですかね。どっちが優れているというものではなく、好みの問題が大きいかなと思います。
ちなみに私は元々 C#er なので、プロパティ形式の方がどちらかというとしっくりきます。


  1. Node.js 上で動作させる、或いは WebPack 等でバンドルしてブラウザ上で動作させる場合。それ以外の場合はパッケージ追加が必要。 

  2. event の引数の表現力がやや低く、名前が固定、一つしか持たせられない、などの欠点がある。 


Viewing all articles
Browse latest Browse all 9164

Trending Articles