エフアンダーバー

個人でのゲーム開発

【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

TypeScriptとVueのMixin

VueのMixinに関する(Webの世界では)一昔前の記事を読んでいて、

TypeScript 自体が Mixin 構文をサポートすればこのつらさもなくなるような気がしますが、 TypeScript 公式でこのめんどくさい Mixin のやり方を紹介しているので望みは薄いような気も……。

という記述を発見。

そういえばどこかのタイミングでTypeScriptがMixinに対応してたな、 と思ったのでどうにかできないかやってみました。

Mixinの問題

VueのコンポーネントをTypeScriptで書く方法のひとつのとして vue-class-componentを用いたデコレータによるものがあります。

@Component
class MyComponent extends Vue { ... }

しかし、この方法で単純にVueのMixinを利用しようとすると、

@Component({
    mixins: [ MyMixin ]
})
class MyComponent extends Vue { ... }

という記述になり、デコレータ内ですべて処理されてしまうため、Mixinされたメンバーが型として現れません。

そこで、Mixinされたメンバーの型情報を取り入れるため、Mixinしたクラスのインターフェースをimplementsします。

@Component({
    mixins: [ MyMixin ]
})
class MyComponent extends Vue implements MyMixin { ... }

一応実装としてはこれでいいのですが、 implementsするのでMixinクラスに既に書いた型情報を再度書き直すはめになります。

・・・で、TypeScriptにはMixin構文のサポートもあるし、これをもっと簡単に書けないか、というのが今回の話です。

解決方法

結論から言うとできました・・・おそらくMixin構文とは特に関係なく。

ジェネリクスを用いて、引数の型すべての交差型(intersection type)を返す関数を定義し、 その関数の戻り値を継承するようにすればいいようです。

import { Vue } from 'vue/types/vue';
import { ComponentOptions } from 'vue/types/options';

type VueCtor<V extends Vue> = { new(...args: any[]): V };
type VueClass<V extends Vue> = VueCtor<V> & typeof Vue;
type MixinType<V extends Vue> = ComponentOptions<V> | VueClass<V>;

function mixin<
    T extends VueClass<Vue>,
    M1 extends Vue>(
    Base: T,
    mixin1: MixinType<M1>): T & VueCtor<M1>;
// overloads ...
function mixin(Base: VueClass<Vue>, ...mixins: MixinType<Vue>[]) {
    return Base.extend({ mixins });
}
@Component
class Derived extends mixin(Base, FooMixin, BarMixin, ...) { ... }

一応Mixin構文も試してみたのですが、 コンストラクタとtypeof Vueの交差型を求めるComponentデコレータと、 純粋なコンストラクタを求めるMixin構文の相性が悪く、うまくいきませんでした。

ソースコード

TypeScript 'mixin' function for vue-class-componen ...

ライブラリ

記事にする前に軽くググってみたところ、 Awesome Vue TSというライブラリが同様の実装でMixin関数を公開しているようです。

軽く眺めた限りでは、vue-property-decoratorのようなデコレータやJSXへの対応など、 Vueをより型安全に使うための機能をまとめたライブラリのようです。

別の方法

そもそもなぜ元記事でMixin構文があれば楽なのに、という話になったかというと、 Mixinの型情報を書き直さなければならないためでした。

で、たまたま最近見かけたコードにこんなものが。

interface IFoo { ... }

interface Foo extends IFoo { }
class Foo implements IFoo { ... }

重要なのはinterface Foo extends IFoo {}の部分です。

TypeScriptでは同名のクラスとインターフェースの型情報はマージされます(Declaration Merging)。 なので、Fooインターフェースにメンバーを定義すると、Fooクラスのインスタンスもそのメンバーを持っていることに型の上ではなります。

ここでは、FooインターフェースはIFooインターフェースを継承しているため、 クラス定義内で明示的に書かなくてもFooクラスのインスタンスはIFooインターフェースのメンバーを型として持つことになります。

これと同様のことをMixinのケースですれば、次のように書けます。

interface MyComponent extends MyMixin { }
@Component({
    mixins: [ MyMixin ]
})
class MyComponent extends Vue { ... }

ほとんど試していないので、あらゆる場合に対応できるかどうかはわかりませんが、 特別な用意のいらないシンプルな解決策としては悪くないと思います。

未来の方法

さらにそもそもの話。 Mixinの処理はデコレータがやるんだから、その型付けもデコレータがやるべきだというのは多分皆の思うところだと思います。

現状、TypeScriptではデコレータは型情報に干渉できません(対象となる型を制限することはできますが)。 しかし、これではデコレータの能力が制限されてしまうということで提案が挙がっているようです。 もしこの提案が受け入れられれば、デコレータを付加するだけでMixinのメンバーにアクセスできるようになるかもしれません。

まあ、その時までVueが生きていればの話ですが。

おわりに

思いつきで始めて、試行錯誤の結果できたけど、探してみたらすでにあった、という徒労話なのですが、 なんか悔しかったのでいろいろ加えて記事にしてみました。 どうも自分は思いつきだけで行動しようとするところがあっていけない。 まあ、試した分だけ知識も付くので悪いことばかりではない(と思いたい)ですが。



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

WebpackでTypeScriptのモジュール解決

TypeScriptのコンパイラオプションには baseUrl、paths、rootDirsといういくつかの特別なモジュール解決の設定があります。

しかし、これらはあくまでもTypeScriptコンパイラのための設定でWebpackやts-loaderはこれらを考慮してくれません。

そこで、Webpackにこれらの設定を反映させるためのプラグインを書いてみました。

TypeScriptのモジュール解決

TypeScriptのコンパイラオプションには主に次の四つのモジュール解決に関する設定があります。

  • moduleResolution
  • baseUrl
  • paths
  • rootDirs

moduleResolutionは"Classic"か"Node"のどちらかでNode.jsのモジュール解決に準拠するか否かの設定です。

baseUrlは非相対パスが与えられたときにどの基準となる場所を指定します。 tsconfig.json内で指定する場合にはtsconfig.jsonを基準に、直接コンパイラで指定する場合にはカレントディレクトリを基準に設定します。

pathsはpath mappingのためのオプションです。 例えば"@model/foo"というモジュールを"src/model/foo"あるいは"lib/model/foo"に対応させたい場合には、

paths: {
  "@model/*": [
    "src/model/*",
    "lib/model/*"
  ]
}

のように書いておくと、コンパイラが探して解決してくれます。

rootDirsは同じ構造を持つ複数のディレクトリを同一のディレクトリのように扱う設定です。 例えば、自動生成されるコードをgeneratedという別のディレクトリに分離しておきたい場合に、

rootDirs: [
  "src",
  "src/generated"
]

と書いておくと、相対パスで指定した時にこれらのディレクトリはまるでマージされたひとつのディレクトリのように振る舞います。

Webpackにおける問題

TypeScriptのモジュール解決は便利ですが、これらの設定をWebpackやts-loaderは考慮してくれません。 例えエディタ上でエラーが発生していなくても、Webpackでビルドした際にモジュールを解決できずにエラーになります。

moduleResolutionは"Node"にしておけば特に問題は起きませんが、 baseUrl, paths, rootDirsはWebpackの設定ではどうにもならない場合があります (aliasの設定等でなんとかなる場合もあります)。

awesome-typescript-loaderのTsConfigPathsPluginを使うとpathsに関しては解決できるらしいのですが、 残念ながら自分の環境では動作しませんでした(Windowsだから?)。

モジュール解決プラグイン

そこで、TypeScriptのモジュール解決用のプラグインを自作することにしました。

ソースコードは後述。

使い方はwebpack.config.js内でインポートして、resolve直下のplugins内にインスタンスを設定します。

const TsConfigPlugin = require("./tsconfig-webpack-plugin");
...

resolve: {
  plugins: [
    new TsConfigPlugin()
  ]
}

デフォルトではカレントディレクトリのtsconfig.jsonの設定を読みに行きます。

別の場所にあるtsconfig.jsonを読ませたいときは場所を指定します。

new TsConfigPlugin("ts/tsconfig.json")
new TsConfigPlugin({ config: "ts/tsconfig.json" })

tsconfig.jsonを読ませたくない場合にはnullを指定します。 その場合、各オプションはoptionsで直接指定します。

new TsConfigPlugin({
  config: null,
  options: { baseUrl: "src" }
})

デフォルトでは.tsファイルや.tsxファイル以外からモジュール解決をリクエストされても無視します。 それ以外のファイルからのリクエストも受け入れる場合にはtestに対象を正規表現で指定します。

new TsConfigPlugin({ test: /\.\w+$/ })

デフォルトではbaseUrl、paths、rootDirsのすべてを解決しようとしますが、個別に指定することもできます。

const { TsBaseUrlPlugin, TsPathsPlugin, TsRootDirsPlugin } = require("./tsconfig-webpack-plugin");

ソースコード

WebpackのResolverプラグインに関する資料が少なすぎて、 正直ただしく書けている自信がないのですがとりあえず自分の環境では動いてます。

gist.github.com

おわりに

とりあえず作ったので公開してみたのですが、 実は自分に必要な部分以外はほぼテストしていません。 プラグインのオプション周りとかrootDirsとかは動くか怪しいので、 もし動かないようであれば連絡もらえるとありがたいです。



執筆時のwebpackのバージョン: 3.8.1