VuexのStore設計のTips - 薄いGetter
Vue + Vuexで中〜大規模なアプリケーションの開発をするとき、どんな設計にするか未だによく悩みます。試してみては捨ててを繰り返していて、そろそろ自分の中でベターなパターンを固めたいと思いつつも固まらず、気づけば数年経ちました。
そういった前提を踏まえつつではありますが、現時点で設計時に意識していることをTips的に少しずつまとめてみようと思います。今回はVuexのGetterに関するお話です。
- Getterの役割を見直す
- 副作用のないクエリとして実装する
- プロパティアクセスとメソッドアクセスを区別して命名する
- プリミティブでシンプルなクエリとして実装する
- 表示用の加工処理はComponentに実装する
- おわりに
Getterの役割を見直す
VuexはFluxアーキテクチャを参考にしており、他のFlux系フレームワークと同様にStoreではStateを保持し、Stateが更新されるとそれをViewに通知します。
このときVuexのStoreはGetterを通じてStateの一部を切り出してViewに渡すことができます。よくあるFlux系StoreはgetState
関数などでStateオブジェクトを丸々渡すケースが多いので対照的ですよね。
この性質に着目すると、VuexのStoreは外部に対して読み込み系のGetterと書き込み系のAction(とMutation)の仕組みを備えていると考えられます。
副作用のないクエリとして実装する
書き込み系と読み込み系の分離といえば、CQS (Command-Query Separation) や CQRS (Command and Query Responsibility Segregation) などの設計パターンが思い当たります。ここではよりシンプルなCQSを参考に考えます。
CQSの基本的な考え方は、オブジェクトが持つメソッドを大きく2つのカテゴリに分類するというものです。1つは副作用が起き得る書き込み系の処理のCommand、もう一つは副作用のない読み込み系の処理のQueryです。
Getterもこれにならって副作用が起きないように実装します。
これだけ見ると当たり前なのですが、「〇〇を表示したときにイベントをemitする」「〇〇を表示したときにフラグを立てる」といった実装をするとき、〇〇を返すGetterの中でemitしたりフラグを立てたりするのもNGなので要注意です。代わりに副作用の起きる処理はMutationやActionに寄せます。
// NG
getters: {
articles: state => {
state.isShown = true;
return state.articles;
}
}
// OK
getters: {
articles: state => state.articles
},
mutations: {
shown(state) {
state.isShown = true;
}
}
プロパティアクセスとメソッドアクセスを区別して命名する
これは完全に好みの話なので蛇足ですが、
- プロパティアクセスのGetterは返すデータを表す名詞で命名する
- メソッドアクセスのGetterは
find{名詞}
やfind{名詞}By{条件}
と命名し、引数に条件をとる
というマイルールで実装しています。
getters: {
// プロパティアクセスの場合は名詞で命名する
books: state => state.books,
// メソッドアクセスの場合は動詞(find)で命名する
findBook: state => id => state.books.find(each.id === id),
// 条件別にGetterを用意する場合は 動詞 + by条件 と命名する
findBooksByAuthor: state => author => state.books.filter(each.author === author),
findBookByISBN: state => ISBN => state.books.find(each.ISBN === ISBN)
}
ちなみに過去にgetHoge
, filterHoge
など動詞を使い分けていたこともあったのですが、開発者によって使い分けルールが曖昧だったので最近はすべてfind
に統一しています。
プリミティブでシンプルなクエリとして実装する
Getterで取り出した値をどう使うかは使う側に依存するので、値を加工したりせずState(の一部)をそのまま返すだけのシンプルなGetterを実装するように意識します。
これを守るためにも上述の命名ルールが役立っていて、データをまるごと返す場合はデータ名を使った名詞で命名し、データの集合から一部を検索して返す場合はfindで命名すると限定することで、自然とシンプルでプリミティブな役割を持つクエリとして実装することができます。
では、データを加工して渡したい場合はどうするのが良いでしょうか?
firstName
とlastName
を組み合わせて一つのfullName
文字列として扱う例のように、Stateの値は書き換えないまま一時的に加工された値を生成する場面はよくあると思います。この加工処理をどこで担うのが良いでしょうか?
表示用の加工処理はComponentに実装する
Stateを加工したり組み合わせたりして表示に適した値に変換する処理は、原則Component側で担うようにしています。StoreからGetterで取得した値をComputedなどで変換して表示する……という実装方法です。
データモデルやアプリケーションの状態といったコアな情報のみをStoreで持ち、Viewの状態をComponentで持つようにすることで、Viewの仕様変更に耐えやすくする狙いです。
ちなみにFluxではViewの状態もStoreに持たせる傾向にありますが、Vue + VuexアプリケーションをMVVMと捉えるならVueComponentはVMに相当し、表示のための加工とその結果の保持を担うべき場所とみなすことができます。捉え方によって解釈も変わってきそうですが、私はStoreのGetterをViewに依存させないこと(Viewの仕様に引きずられないこと)を重視したいので、MVVM寄りの捉え方をすることが多いです。
とはいえこれはあくまで原則。
例外的な場面での対処法も考慮しておく必要があります。
例外1. 加工された値の賞味期限が長い場合
1つ目の例外は、加工された値の賞味期限がComponentのライフサイクルより長い場合です。複数のComponentで使われる場合もこれに該当します。
Componentが破棄された後も値を保持し続けなければいけない場合は、当然ですがStoreで保持しなければなりません。Storeで保持する以外にもClosureで実装したりSingletonで実装したりと値の保持方法はありますが、状態を持つ仕組みが複数存在すると複雑さが増すので注意が必要です。
例外2. 加工処理が複数Componentで繰り返される場合
そこそこの規模・複雑さのアプリケーションを開発していると、「Module分けされたAとBのStoreからStateを取ってきて組み合わせに応じた表示をする」なんて場面が出てくるかもしれません。また「複雑な条件分岐を経てどのように加工されるかが決まる」といった場面もあり得るでしょう。
このように加工処理が複雑で、なおかつ複数のComponentで必要な場合は、加工処理を関数として外出しする選択肢がとれます。
// 外出しした加工処理の実装
export function convert(stateA, stateB) {
// 引数をもとに加工された値を生成して返す
return result;
}
// component側
import { convert } from './convert';
export default {
computed: {
displayValue() {
const getters = this.$store.getters;
// 外出しされた加工処理を利用する
return convert(getters.stateA, getters.stateB);
}
}
}
例えば「Listを表示する画面にて、ユーザーがソート方法を切り替えるとインタラクティブにItemの並び順が変わる」といったような場面では、数種類のsort関数を外出ししておくことができそうですよね。
おわりに
設計に関することは日々うにゃうにゃ考えているものの、当たり前の内容であったり状況次第で何とでも解釈できる内容であったりして、書き残すことにあまり意味を感じていませんでした。が、いざ設計しようと思ったときに考慮すべき観点や選択肢としてパッと浮かんでくるものは、やはり言語化して腹落ちしたものなんですよね。
また、言語化することでフィードバックをもらえたり議論のきっかけになったりする良さもあると思うので、1ヶ月後には意見が変わっているかもしれませんがこうして書き残す試みを今後もしていこうと思います。