Vue+VuexのデータフローをCQSライクに設計する方法
Vue + Vuexを使ったWebアプリケーションを開発していて、以下のような悩みにぶち当たったことありませんか?
悩み1. VuexのmapHelperを使うとコードが読みにくくなる
「created
内で呼ばれているこの関数はどこに定義されているんだ…? methods
? それともStoreのアクション…?」
「import
部分を見るにこのComponentはどのStoreにも依存してなさそうだ…。と思いきや、mapState
でいろんなStoreの値を読み込んでいるぞ…」
悩み2. ビジネスロジック層がない
「ページ読み込み時に走るAPIアクセスはどこに実装されている? Componentのcreated
? Storeのアクション? ロジックがまとまっている層がなくて処理の入り口を見つけにくい…」
「クリックされたら見た目を変えてAPIアクセスしてStoreを更新する実装をしたいけど、どうやって責務を分離していくといいかな…」
「ユーザーの権限を元に表示内容を変える実装したいのだけど、複数のStoreの値を組み合わせたロジックが必要そうだ。こういう場合はComponentのmethods
に実装すべき…? 将来を見越してmixinとして実装すべき…? それとも…?」
悩み3. Moduleのnamespaceが使いにくい
「VuexStoreのモジュールのnamespace
は分けたいけれど、そうするとFluxのように全モジュールに対してDispatchできない…」
* * *
今回はこれらの問題を解決するデータフロー設計について提案してみます。記事の最後でこの設計を採用したサンプルプロダクトも紹介していますので、併せてご参考ください。
(この記事はVue2.x系の利用を想定しています)
CQS ライクに考える
設計パターンのひとつである Command–Query Separation (コマンドクエリ分離) の考え方を参考に設計を考えていきます。このパターンは、アプリケーションにおける処理を
- Query = 参照系の処理。アプリケーションの状態を変更せず、また副作用のない処理
- Command = 更新系の処理。アプリケーションの状態を変更し、副作用を伴う処理
の2つに分類します。この考え方とFluxのDispatcherの考え方を組み合わせてデータフローを構築します。大まかな完成図は以下のようになります。
Query (ゲッターのエイリアス) を実装する
まずは比較的シンプルなQueryから考えます。
Componentからthis.$store.getters
やmapGetters
などを利用することで「副作用なくアプリケーションの状態を取得する」ことが可能なので、一応Queryとして完成していると言えます。が、悩み1にあるようにこれらの方法ではどのStoreに依存しているかが読み取りにくいため、今回は自前のQueryを実装します。
今回は以下の4つの要件で実装してみます。一般的なVue+Vuexアプリケーションではあまり見ない設計となるので、なるべく強制力が弱く理解しやすくすることを要件に盛り込みます。
- Storeの更新に応じてリアクティブに値を受け取れる仕組みとなっていること
- Componentの
import
部分を見ることで、どのStoreを利用しているかが読み取れるようなQueryとなっていること - Storeを実装する際に、Queryの存在を意識せず従来通りに実装できること
- Queryを実装する際または利用する際に、複雑なルールを覚えることを強要しない仕組みとなっていること
(ちなみにこの記事ではVuexのモジュールをStoreと呼ぶこととします)
まず要件1を満たす状態を想像しやすくするために、Queryを利用するComponentから実装してみます。
SampleComponent.vue
<template>
<div>
<div>{{ todoCount }}</div>
</div>
</template>
<script>
import { TodoStoreQuery } from '@/queries';
export default {
computed: {
todoCount() {
return TodoStoreQuery.todoCount;
}
}
}
</script>
import部分をみるとTodoStoreQuery
があるので、TodoStore
の値を利用しているComponentであることが読み取れます。
ついでにQueryの利用箇所にも触れておくと、computed
の中でゲッターを使うときと同じ書き口で使われていますね。このようなQueryを用意することができれば、要件4の「複雑なルールを覚えることを強要しない仕組みであること」も満たせていると言えそうです。
ではこのQueryを実装していきます。参考にTodoStore
の実装も添えておきます。
store.js
export const TodoStore = {
state: {
todos: [],
},
getters: {
todos: state => state.todos,
todoCount: state => state.todos.filter(todo => !todo.done).length
},
};
export const vuexStoreInstance = new Vuex.Store({
modules: {
TodoStore,
},
});
queries.js
import { TodoStore, vuexStoreInstance } from "@/store";
// Storeの情報からqueryを生成する関数を定義する
function createGettersAlias(storeTemplate = {}) {
const result = {};
Object.keys(storeTemplate.getters).forEach(key => {
result[key] = vuexStoreInstance.getters[key];
const descriptor = Object.getOwnPropertyDescriptor(vuexStoreInstance.getters, key);
Object.defineProperty(result, key, descriptor);
});
return result;
}
// queryを生成してexportする
export const TodoStoreQuery = createGettersAlias(TodoStore);
// 新規storeが増える度にこの処理を追加していく
// export const AnotherStoreQuery = createGettersAlias(AnotherStore);
createGettersAlias
関数は、VuexStoreのインスタンスからTodoStore
のゲッター要素だけを抜き出して返す関数です。やっていることはmapGetters
とほぼ同じです。
Storeを追加する際は、通常通りの方法でStoreを実装することに加えてcreateGettersAlias
を呼び出すだけでOKです。ちなみに私はStoreへアクセスする際は必ずゲッターを経由するようにしているためステートのエイリアスはつくっていませんが、このあたりは好みですね。
Command (ビジネスロジック+Dispatch) を実装する
次は副作用を伴う更新系処理のCommandを実装します。
こちらも4つの要件で実装します。
- 返り値のない関数とする
- システム的な処理単位ではなく、ユースケース単位で1つ1つのCommandを実装する
- 処理に必要な依存関係は基本的に引数で受け取る
- storeを更新するときはVuexの
dispatch
を経由する
今回は「TODOを完了にする」ユースケースのCommandの実装を例にします。
actionTypes.js
export const ActionTypes = {
'DONE_TODO': 'doneTodo'
}
Dispatchしやすいようにアクション名を定数化しておきます。
dispatcher.js
import { vuexStoreInstance } from '@/store';
export const dispatch = (actionType, payload) => {
vuexStoreInstance.dispatch(actionType, payload);
};
Commandから直接vuexStoreInstance.dispatch
を呼び出しても良いのですが、今後Dispatch部分を修正する可能性を考慮して、Store層とCommand層の間にDispatcher層を噛ませておきます。
commands.js
import { dispatch } from '@/dispatcher';
import { ActionTypes } from '@/actionTypes';
const doneTodo = todoId => {
dispatch(ActionTypes.DONE_TODO, { todoId });
}
export const Commands = {
doneTodo
}
ここまで実装できたら、ComponentからCommands.doneTodo
を呼び出せば完成ですね。ちなみにdoneTodo
を直接export
せずCommands
オブジェクトで束ねているのは、grepしやすくするためです。好みなので要件には含んでいません。
ちなみにQueryとCommandに依存したComponentのimport
部分は以下のようになります。
// SampleComponent.vue
<script>
import { TodoStoreQuery, AnotherStoreQuery } from '@/qieries';
import { Commands } from '@/commands';
</script>
Componentから見ると「どのStoreの値を利用しているか」は明確であったほうが良いですが、「どのStoreを更新しているか」は意識する必要がありません。それがちょうど良く表現されている点も気に入っています。
まとめ
CQSの考え方を参考にVue+Vuexアプリケーションの設計方法を提案してみました。しかしよく見ると実はあまり特別なことはしていなくて、Vuexの使い方に一手間加えている程度です。
別の見方をすると、今回実装したCommandとQueryはStore層にVuexを採用しなかったとしても利用できる仕組みです。そのためこの設計方法はVuexから脱却したVueアプリケーションの設計方法の提案でもあります。Vuexが悪いとは言いませんが、選択肢は多く持っておけると良いですよね。そういった意図も込めての紹介でした。
余談ですが、「設計の紹介」と聞いてもっと斬新なもの期待させてしまっていたらすみませんでした。
私にとって(私にできる)フロントエンドの設計とは、「情報をどう捉えるとか」と「それをどう分離していくか」を繰り返していく作業です。捉え方や分離の仕方には流行や好みが反映されることもあります。最近の私の好みは「部分的に切り離して捨てやすいかどうか」ですが、好みが変われば設計方法も変わると思いますので、そのときはまたこうして記事を書こうと思います。
サンプル
今回紹介した設計を利用し、一覧ページと詳細ページを行き来する簡単なサンプルアプリを実装しました。ご参考にどうぞ。