Posted at

Vue.observable を使ったストアパターンと TypeScript の組み合わせはどのくらいスケールするか?

Vue.js の公式ドキュメントには「状態管理」という章があり、そこでは単純なストアパターンと、より大規模向けという Vuex ライブラリを使う 2 つの手法が紹介されています。

ですが、それぞれどこに長所・短所がありどのような基準で選択すれば良いのかは「大規模」というあやふやな基準でしか示されていません。実際その境界はどこにあるのでしょうか?

さて、TypeScript も同様に大規模アプリケーション向けと言われます。型安全性の有用さは他言語の世界で実証されています。ところが残念なことに Vuex の API 設計とは極めて相性が悪いというのはよく言われます。現時点で両者を両立させるのは大変苦労しそうです。

そこでこの記事では、ストアパターンを TypeScript で実装してみることで、その限界がどの辺りにあるのかを探っていきたいと思います。


実現すること


  • テンプレート以外の TypeScript 部分がほぼ型安全であること

  • 同一のストアクラスを、単一のコンポーネント / 兄弟コンポーネント間で共有 / グローバルで共有できること

  • グローバルで共有するストアのモジュール化


実現しないこと


  • DevTool の Vuex 機能との連携


ソースコード

https://github.com/tmy/vue-ts-store-sample


どの Vue API を使うか

TypeScript で Vue を使うにあたり、どの API を使うかが問題になります。


  • 従来の API

  • Class API (vue-class-component と vue-property-decorator を使用)

  • Function API (vue-function-api を使用)

この記事では Function API を使うことにします。ただし、現時点で絶賛開発中のため仕様が変わる可能性が高いです。

注: 従来の API では今回の記事の内容を型安全に書く方法が足りません。


Data で状態管理

まず、単純な Vue コンポーネントを書いてみます。値を増やしたり減らしたりできる単純なカウンターです。


components/SimpleCounter.vue

<template>

<div>
<button @click="decrement">
-
</button>
<span class="count">
{{ count }}
</span>
<button @click="increment">
+
</button>
</div>
</template>

<script lang="ts">
import { value } from 'vue-function-api';

export default {
setup() {
const count = value(0);
return {
count,
increment() {
count.value += 1;
},
decrement() {
count.value -= 1;
},
};
},
};
</script>


以下、このサンプルにストアパターンを適用していきます。


ストアで状態管理

先ほどの Vue コンポーネントから、状態管理に関する部分だけ引き剥がしてストアを作成してみます。


stores/counter.ts

import Vue from 'vue';

export default function counterStore() {
const state = Vue.observable({
count: 0,
});

return {
get count() {
return state.count;
},

increment() {
state.count += 1;
},

decrement() {
state.count -= 1;
},
};
}

export type CounterStore = ReturnType<typeof counterStore>;


ストアを生成する関数と、ストアの型をエクスポートしています。

Vue 公式のストアパターンと state オブジェクトの扱いが少々異なります。


  • Vue 2.6 で導入された Vue.observable() によってリアクティブになっています。

  • 直接アクセスできません。代わりに getter で値を参照するようにして安全性を高めています。

これを使う Vue コンポーネントは以下のように書けます。

import { computed } from 'vue-function-api';

import counterStore from '@/stores/counter';

export default {
setup() {
const counter = counterStore();
const count = computed(() => counter.count);

return {
count,
increment: counter.increment,
decrement: counter.decrement,
};
},
};

以下の箇所が変わっています。


  • ストアの値を computed で参照しています。ストアの値はリアクティブになっているので、値が更新された時だけ再計算されることになります。

  • 値の操作はストアにそのまま委譲しています。

これにより、以下のようなデータのサイクルが出来上がります。

ストアを定義する際は、以下の点に気をつけると良いでしょう。


  • コンポーネントから呼ばれるメソッドは値を返さないようにする

  • Getter でオブジェクトを返す場合、そのオブジェクトの型が T なら Readonly<T> と返値を定義して中身を書き換えられないようにする


兄弟コンポーネント間でストアを共有

Vue コンポーネントが複雑になってくると、一つのコンポーネントを複数コンポーネントに分割したくなることはよくあると思います。この際の props/emit のバケツリレーが辛いというのはよくある話だと思います。このときに使えるのが Dependency Injection (依存性の注入) の仕組みです。ストアを使いたいコンポーネントの共通の祖先に当たるコンポーネントでストアを準備してやることで、その子孫コンポーネント間でストアを共有できるようになります。

元のサンプルのコンポーネントを increment/decrement のボタンコンポーネントと現在値を表示するコンポーネントの 3 つに分割することを考えます。


キーを定義する

まず最初にコンポーネント間でストアを共有するのに使うキーを定義します。キーは Symbol か文字列で定義できますが、Symbol の方が型の相性が良いようです。


components/counter-key.ts

import { Key } from 'vue-function-api';

import { CounterStore } from '../stores/counter';

const CounterKey: Key<CounterStore> = Symbol('CounterStore');
export default CounterKey;



ストアを準備するコンポーネントを作成する

ストアを準備する側のコンポーネントでは、provide() を使って子孫コンポーネントにストアを渡します。


components/CounterProvider.vue

<template>

<div>
<slot />
</div>
</template>

<script lang="ts">
import { provide } from 'vue-function-api';
import counterStore from '../stores/counter';
import CounterKey from './counter-key';

export default {
setup() {
provide(CounterKey, counterStore());
return {};
},
};
</script>


ストアを生成して provide() にセットしているだけです。

また、スロットをを使うことで子孫のコンポーネントを指定する必要がなくなります。


ストアを利用するコンポーネントを作成する

ストアを利用する側のコンポーネントでは、inject() を使ってストアを受け取ります。

値を increment するコンポーネントはこう書けます。


components/InjectedIncrementButton.vue

<template>

<button @click="increment">
+
</button>
</template>

<script lang="ts">
import { computed, inject } from 'vue-function-api';
import CounterKey from './counter-key';

export default {
setup() {
const wrapper = inject(CounterKey);
if (!wrapper) {
throw new Error(`${CounterKey} is not provided`);
}
const counter = wrapper.value;

return {
increment: counter.increment,
};
},
};
</script>


inject() で受け取れる値は、funciton API によってストアがラップされたオブジェクトです。.value 属性に元のストアオブジェクトが入っています。

ストアオブジェクトを受け取った後の処理は分割前のコンポーネントと全く同じです。

値を decrement するコンポーネントは increment とほとんど同じなので省略します。

現在値を表示するコンポーネントは同様にこう書けます。


components/InjectedCounterDisplay.vue

<template>

<span class="count">
{{ count }}
</span>
</template>

<script lang="ts">
import { computed, inject } from 'vue-function-api';
import CounterKey from './counter-key';

export default {
setup() {
const wrapper = inject(CounterKey);
if (!wrapper) {
throw new Error(`${CounterKey} is not provided`);
}
const counter = wrapper.value;
const count = computed(() => counter.count);

return {
count,
};
},
};
</script>


provide する側のコンポーネントは、slot を使うことで子孫のコンポーネントを指定する必要がなくなります。


分割したコンポーネントを組み合わせる

これら分割したコンポーネントを以下のように子孫関係を持たせることで Dependency Injection の仕組みが完成します。


App.vue

<template>

<CounterProvider>
<InjectedDecrementButton />
<InjectedCounterDisplay />
<InjectedIncrementButton />
</CounterProvider>
</template>


ストアをグローバルで共有

(本質的な意味で) アプリケーション全体に関わる状態を管理したい場合には、全ての Vue コンポーネントからストアを参照できるようにしたくなるでしょう。

もちろん Dependency Injection の方法を踏襲してルートコンポーネントでストアを provide する方法も使えます。ここでは別の方法として本当に全ての Vue コンポーネントからグローバルにアクセス可能にする方法を試します。これは従来グローバルにアプリケーションを拡張する際に見られた方法です。

まず、ストアをグローバルに参照するための集まりを定義します。といっても単なるコンポジションです。

(このサンプルでは 1 つですが、実際には複数のストアを一つにまとめていると思ってください)


stores/global.ts

import counterStore from './counter';

export default function globalStore() {
return {
counter: counterStore(),
};
}

export type GlobalStore = ReturnType<typeof globalStore>;


次に、アプリケーションの初期化コードでこのオブジェクトを生成して Vue の prototype に入れてどの Vue コンポーネントからも参照可能にします。


main.ts

Vue.prototype.$store = globalStore();


さらに、.$store の型定義を追加します。


global-store.d.ts

import Vue from 'vue';

import { GlobalStore } from './stores/global';

declare module 'vue/types/vue' {
interface Vue {
readonly $store: Readonly<GlobalStore>;
}
}


これで全てのコンポーネントの .$store 属性から型安全にストアを参照可能になります。

ただ、この拡張方法は function API とはあまり相性が良くありません。setup() 関数内で this が参照できないためです。現時点では代わりにルートコンポーネント経由で参照する方法が提案されています。おそらくこの参照方法は今後変わると思われますが、この方法でカウンターの値を参照するコードは以下のようになります。

import { computed, SetupContext } from 'vue-function-api';

export default {
setup(props: {}, context: SetupContext) {
const { counter } = context.root.$store;
const count = computed(() => counter.count);

return {
count,
};
},
};

個人的な印象では、Dependency Injection の方が優れているように思えます。


非同期処理の扱い方

ストアの値を更新するのに API などの非同期処理を呼ぶことがよくあると思います。この場合ストアのメソッドを async 関数で定義すれば他の言語でよく見る感じの素直なコードになります。

ついでにロード中のステータスも適宜更新しておくとローディング表示に役立つでしょう。

const state = Vue.observable({

loading: false,
user: null as User | null,
});

return {
get user() {
return state.user;
}

get loading() {
return state.loading;
}

async load(id: number) {
state.loading = true;
try {
state.user = await getUser(id);
} finally {
state.loading = false;
}
}
};


このパターンの限界

ストアをグローバルで共有するケースで複数のストアをまとめるクラスを作りましたが、それぞれのストアはお互いに独立していることを前提としています。

ところが、複雑なアプリケーションではストア間に依存関係が生じる場合があります。例えば、ログイン状態を持つアプリケーションではログイン状態でのみ有効な機能が出てきます。そうするとそういう機能の状態を保持するストアはログイン情報を持つストアに依存するはずです。今回のストアの仕組みではこれを綺麗に表現することができません。

一応、ストアの状態はリアクティブなので、どこかの Vue コンポーネントでストアの値を watch して別のストアのメソッドを呼ぶことはできます。

new Vue({

watch: {
'$store.foo.someValue': function(value) {
this.$store.bar.someAction(value);
},
},
});

が、これを定義すべきコンポーネントはどこかと考えると、なかなか悩ましいです。できればストアの内部で完結させたいところですが……

特別なライブラリ抜きでできる限界はこの辺りでしょうか。

いわゆる flux パターンはこのようなケースに対応することが考慮されています。(Facebook の) flux パターンでは、一つの action に対して複数のストアが反応することを前提に、ストア間の依存性を扱うため Dispatcher に waitFor() というメソッドが用意されています。Redux でも一つの action に対して複数の reducer が反応できますし、middleware で処理することもできるでしょう。Vuex でもグローバル名前空間によって一つの mutation/action タイプに対して複数のモジュールが反応できます。

逆に言うと、アクションとストアが 1:1 の関係にある限りは、flux パターンでなくストアパターンでどうにかなると言えるのではないでしょうか?

さらにストアパターンを flux の視点から解釈すると、アクションとストアが 1:1 の関係であることを前提としてアクションをメソッド名と引数に分解して Dispatcher を JavaScript の処理系そのもので代用したものと考えることも可能でしょう。

この辺りが、flux 方面でよく言われる「大規模」の正体ではないか、と考えています。


まとめ


  • 兄弟コンポーネントでのバケツリレーを防ぐだけであれば Vuex がなくてもストアパターンで問題なくできる

  • ストア間の依存関係を扱うのはストアパターンだけでは綺麗に解決できない


Vuex に対して思うところいろいろ

型の問題は散々言われていると思うので、それ以外で。まあポエムです。



  • 大規模とは何かを定義していないこと

    公式ドキュメントの「いつ、vuexを使うべきでしょうか?」の説明では、結局必要になったら使え以上のことは言っていません。これは単なるトートロジーでしかなく、不必要に初学者を苦しめていることにつながっているように思えてなりません。

    ホビープログラミングならともかく、ビジネスのアプリケーションでは、それが小規模なままか大規模に成長するかは誰も予測できません。故に自分が書く小規模アプリケーションが将来大規模化して技術的負債にならないかと考えると初めから大規模に対応できるようにしておこうと考えるのは良心的な技術者であれば仕方ないですよね。YAGNI を突き通すにも覚悟と度胸が要りますしね。


  • グローバルである必要がない状態もグローバルに管理しなければならないこと

    単に兄弟コンポーネント間で状態を共有したいだけなのにスコープをグローバルに広げないといけないというのは「牛刀をもって鶏を割く」ということわざを思い出させます。


  • なぜか外部インターフェースが dispatch()commit() の 2 つあること。なぜストアを呼び出す側が 2 つを使い分けなければならないのか。


  • Vue 自体は mutable に物事を解決するアプローチで成り立っているのに、なぜ immutable で成り立っている Redux の真似をしないといけないのか

    かつて Singleton とか Service Locator などの一見有力に見える集中管理アプローチが肥大化するにつれ結局苦労する羽目になった経験からすると、グローバルシングルトンはやはり警戒してしまいます。Redux は immutable にデータを扱うというアプローチなのでグローバルオブジェクトのデメリットを大方打ち消せているようにも見えますが、Vuex はあくまで mutable にデータを更新するので……


参考記事