エフアンダーバー

個人開発の記録

【TypeScript】 Vuexもクラス形式で書きたい!!

vue-class-componentvue-property-decoratorvuex-classといったデコレータによるクラス形式のコンポーネント記述に感動したので、 Vuexもクラス形式で書けないかと仕組みを試作してみました。

ちなみにVuexの初心者も初心者、ほとんどドキュメントに目を通しただけくらいの人間なので、 それが本当に必要とされているのかとか、あらゆる使い方に対応できているのかといったことは知りません。 前回に引き続き、思いつきでやってみたシリーズです。

使い方

使い方というか完成イメージというか。

必要な作業は次の通りです。

  • モジュールを表すクラスに@Moduleデコレータをつける。
  • ステートを表すプロパティに初期値を入れる(コンストラクタ内で初期化されるようにする)。
  • ゲッターを表す(TypeScriptの)ゲッターに@Getterデコレータをつける。
  • ミューテーションを表すメソッドに@Mutationデコレータをつける。
  • アクションを表すメソッドに@Actionデコレータをつける。
  • 子モジュールを表すプロパティに@Childデコレータをつけてモジュールの定義を渡す。

あとは通常のクラスと同じようにthisを通して他のメンバーにアクセスすれば、 自動的に適切な方法(state, getters, commit, dispatch)でメンバーが呼び出されます。 ただし、アクセスできない関係の場合には例外が投げられます。

@Moduleデコレータは引数として追加のモジュール定義を受け取ることができます。 namespacedなどはここで指定します。

rootStaterootGettersにアクセスしたい場合には$rootプロパティを使用します。 このプロパティはIModule<R>インターフェースに定義されています。 型引数Rはルートの型で、省略するとany型になります。

生のstategetterscommitdispatchを取得したい場合にはそれぞれの前に$を付けたプロパティにアクセスします(e.g. $state)。 これらもIModuleインターフェースに定義されています。

IModuleインターフェースの定義を参照したい場合には、 implementsするとすべてのプロパティの再定義を強制されるため、 @Moduleをつけたクラスと同名のインターフェースにIModuleインターフェースをextendsさせるのがいいと思います。 あるいはimplementsせずに必要な定義だけ書いてしまうのもありです。

実装

方針

thisはメンバーの種類(e.g. ミューテーション)によって呼び出し方(e.g. commit)を変えなければならないので、 Proxyによってメンバーの参照(get)をトラップして適切な処理に置き換えます。

この際、参照されたメンバーの種類が何であるかを知っている必要があるため、 各デコレータによってメンバー情報をモジュールクラスに記憶しておきます。 ただし、ステートに関してはどのメンバーにも一致しなかったものを自動的にステートとみなすため記憶されません。

これでモジュールクラスからメンバー情報が取得できるようになりましたが、 今度はモジュールクラスにアクセスする方法が必要になります。 キャプチャして関数に埋め込むこともできますが、 各モジュールはルートにアクセスできる必要があるため、ルートモジュールも同時にキャプチャしなければなりません。 そうなると、各モジュールがルートモジュールを参照するため循環参照となりなかなか面倒なことになります。

そこで、モジュールクラスはステートにプロパティとして埋め込むことにします。 名前が衝突すると嫌なので、キーにはSymbolを使います。 Vueではプロパティをリアクティブにする際にObject.keysを利用しているようなので、 Symbolをキーとするプロパティはスキップされます。

これで基本的な仕組みは整いました。 あとは@Moduleデコレータ内にて、 Proxyを生成後それをthisとしてメンバーの本体をcallまたはapplyするゲッター、ミューテーション、アクションを定義し、 それらをモジュールクラスに生やします。

reflect-metadata

@Childデコレータではモジュールの定義を引数として受け取ります。 しかし、このモジュールの定義は多くの場合にデコレータを付けたプロパティの型と一致します。 同じことを二度書くのは手間なので、メタデータを用いてこれを省略する機能を入れています。

TypeScriptのコンパイラーオプションでemitDecoratorMetadataをONにすると、 @Childデコレータは引数を省略した場合に自動的にメタデータにアクセスし、 プロパティの型をモジュール定義として扱います。

メタデータは執筆時点でブラウザの標準機能ではないため、この機能のためにreflect-metadataをimportしています。 reflect-metadataパッケージをインストールしていない場合にはエラーとなるため、 この機能が不要な場合には対象コードを削除してください。 reflect-metadataパッケージはvue-property-decoratorでも使われているため、 vue-property-decoratorをインストールしていれば自動的にインストールされます。

ソースコード

Decorators for class-style vuex modules.

注意点

ストア生成時

クラス形式で書かれたモジュールからストアを生成する場合には、

new Vuex.Store(mod as any)

というように型表明(type assertion)をする必要があります。

これはデコレータでプロパティを生やしているので、モジュールに必要なプロパティの型情報が入っていないためです。 ストアオプションは本来空のオブジェクトでもいいはずなのですが、 TypeScriptにはオプションプロパティしか持たない型に対してはその内少なくともひとつのプロパティを持つ型しか代入できないという制限があるため、 型としてストアオプションのプロパティをひとつも持たないクラスモジュールはエラーとなります。

デコレータは付加されたクラスのコンストラクタを全く別のオブジェクトに置き換えてしまう機能を有しているので、 本来デコレータの返した型がクラスを参照した際の型となるべきなのですが、 現時点でTypeScriptはデコレータによる型情報の変更には対応していません。 一応提案は挙がっているので、 今後改善される可能性はあります。

ペイロード

クラス形式で書かれたモジュールのミューテーションやアクションをcommitdispatchから呼び出す場合、 ペイロードは常にクラスで宣言した引数の配列になります。

本来はクラス形式で書く場合にもミューテーションやアクションの引数はペイロードひとつにするべきだとは思うのですが、 TypeScriptにおける引数の分割代入(destructuring assignment)はなかなか面倒な記法を強いられるのでこの仕様にしました。

対応環境

Symbol, Proxy, Reflect, WeakMapといろいろ使っているためES6に対応した環境が必要です(IEでは動きません)。

頑張ればES5にも対応できるかもしれませんが、Proxyを置き換える辺りは結構面倒くさそうな気がします (ステート周りは若干の制限がかかりそう)。

名前の衝突

クラス形式でモジュールを書く場合、各メンバーがすべて同じスコープに属するため、名前の重複ができなくなります。 ステートとゲッター、ミューテーションとアクションで同じ名前を使うことができないので注意です。

パフォーマンス

特に計測したわけではないですが、 thisにアクセスする度にプロキシハンドラが動いたり、プロキシが生成されたりするのでそれなりのオーバーヘッドがあります。 そのため、無闇にthisへのアクセスを繰り返さないようにする必要があります。

おわりに

正直Vuexのドキュメントを読んだ最初の感想は、クラスで書くのと何が違うんだろう、というものでした。 よく設計されたクラスとVuexの構造というのはあまり変わらないのだと思います(もちろんVuexでは構造が強制されるという違いはありますが)。 今回、クラス形式で書いてみたのもその辺の意識があったためです。

成果物に対して一応自分としてはそれなりに満足しているのですが、 Vuexをまだよく理解できていないだけに何か盛大に勘違いをしてそうで怖かったり。 もし何かやらかしていたら、そっと教えてもらえると幸いです。



執筆時のTypeScriptのバージョン:2.6.2