1. 出発点
比較的独立したコンポーネントにおいて、action -> state -> viewという単方向データフローは保証されます。しかし、実際の業務シナリオでは状態(ステート)の伝達や共有が頻繁に必要となります。一般的な方法は以下の通りです:
-
状態の伝達:親コンポーネントから子コンポーネントへの通信は
propsを通じて行います(属性値を順方向に渡し、メソッドを逆方向に渡す)。兄弟コンポーネント間の通信は、イベントを利用するか、状態を親コンポーネントに引き上げる(兄弟間の通信問題を親子間の通信に変換する)ことで実現します。 -
状態の共有:1つのコンポーネント内に配置し、他のコンポーネントが何とかしてその状態の参照を取得するか、独立したシングルトンとして抽出し、各コンポーネントで共有します。
深い階層へのpropsの伝達はかなり苦痛であり、兄弟コンポーネント間で交錯するイベント通信は保守上の問題を引き起こします。状態を親に引き上げると、親コンポーネントが肥大化し、過剰な状態の詳細を管理することになります。共有状態を1つのコンポーネントに置く場合、他のコンポーネントから状態の参照を取得するのは手間がかかります。シングルトンとして抽出する方がいくらかマシですが、コンポーネントツリーの外に散在する共有状態が存在することになり、これも保守上の問題をもたらす可能性があります。
状態管理レイヤーを独立して抽出することで、状態の伝達と共有の問題を効果的に解決できます。さらにactionを用いて状態変更にセマンティクス(意味)を持たせることで、保守の問題を軽減するだけでなく、デバッグの面でもメリットが生まれます。
2. 基本原則
-
アプリケーションレベルの状態は
storeで一元管理する。 -
状態を変更する唯一の方法は、同期的な
mutationをcommitすることである。 -
非同期ロジックは
action内に配置する。
管理しやすい単一状態ツリー(シングルステートツリー)や状態変更方法の標準化といった概念は支持されていますが、それ以上に業務の現実に即しており、設計段階から非同期シナリオが考慮されています。
3. 構造
Reduxのような独特さ(reducerが一見するとFluxと無関係に見える点など)はなく、VuexはよりオーソドックスなFluxの実装に近いと言えます:
component ビュー層 dispatch action
---
action イベント層 commit mutation
非同期 非同期リクエストを一元管理
---
mutation レスポンス層 mutate state
同期 論理的にアトミックな状態変更
---
state データモデル層 update model
データバインディングを通じてビューの更新にマッピング
この中で、mutationとactionはグローバルで共有されるため、コンポーネント間の通信問題も解決します(手動で状態を渡す必要はなく、storeに何が起きたかを伝えるだけで、storeが何をすべきかを判断します)。これにより状態の引き上げや伝達を避けられ、セマンティックなメリットも得られます。
グローバル共有には名前の衝突という問題がつきものですが、Vuexは名前空間(ネームスペース)のオプションも提供しています。
Fluxとの比較
actionの発生 actionの伝達 update state
viewの操作 -----------> dispatcher -----------> stores --------------> views
最大の違いは、Vuexがactionを非同期シナリオ用のactionと同期シナリオ用のmutationに細分化し、store自身がdispatcherの役割(action/(mutation)の登録とディスパッチ)を担っている点です。
つまり、actionとmutationを1つの層(Fluxにおけるaction)と見なせば、両者の構造は完全に一致しています。そのため、VuexはよりオーソドックスなFluxの実装に近いと言えるのです。
store
stateのコンテナとして機能し、さらにdispatcherの役割も果たします。
storeを用いてstateを管理することは、役割としてはglobal.share = {}に相当しますが、Vuexにおけるstore.stateにはいくつか特有の特徴があります:
-
stateはリアクティブ(反応的)なデータである。 -
storeが保持するstateを直接変更することは許可されず、明示的にcommit mutationを行わなければならない。
コンポーネントのdataと同様に、store.stateもリアクティブであり、コンポーネントの算出プロパティ(computed property)と関連付けられることで、stateの更新が正確にview層に伝達されます。
また、store.stateの直接変更を許可しないというのは道徳的な制約であり、strictオプションを有効にするとエラーにはなりますが、実際には変更が反映されてしまいます。ここで強力な制約(書き込み保護)を行わないのは、市場(利用者の利便性など)を考慮してのことかもしれません。
さらに:
-
単一状態ツリーを採用しており、Reduxと同様ですが、管理(
stateの分割と組織化)のためのモジュール化メカニズムを追加で提供しています。 -
同様に、すべての
stateをVuexに詰め込むことは推奨しておらず、比較的独立した状態はコンポーネントレベルで管理することが推奨されています。
getter
役割としてはstoreの算出プロパティに相当します。
stateをラップし、元のstateをビューの表示に必要な形に加工(例えばstore.stateに対してfilter、count、findなどの簡単な計算を行う)するために使用します。
getterがない場合、これらの弱いロジックはcomputed内に配置するか、テンプレート内に記述することになりますが、getterを提供することで、stateに関連するすべてのロジックを抽出できます。
mutation
stateの更新を担当します。mutationはすべて同期処理であり、commit mutationの次の行ではすでにstateの更新が完了しています。
あらかじめstoreに登録されており、commitされるたびにmutationのリストを検索し、対応するstate更新関数を実行します。
注意点として、mutationは必ず同期的でなければなりません。もし非同期で状態を変更した場合、デバッグツールが正しい状態のスナップショットを取得できなくなり、状態の追跡が破壊されてしまいます。
action
非同期シナリオに対応し、mutationの補完として機能します。
VuexはFluxにおけるactionを、同期のmutationと非同���のactionに分けていると考えることができます。
actionはmutationのように直接stateを変更するのではなく、commit mutationを通じて間接的に変更します。つまり、アトミックな状態更新操作に対応するのはmutationのみです。
action内には非同期操作を含めることができます。設計上、非同期処理をactionとして同期処理のmutationから意図的に分離しています。
非同期フロー制御
非同期フローの制御は、actionにpromiseを返させることで解決でき、コールバック関数を渡すよりもエレガントです。
Vuex v2.x(現時点の2017/7/1では最新がv2.3.0)のstore.dispatchはデフォルトでpromiseを返します。非promiseのactionの戻り値もPromise.resolve()を経由してpromiseにラップされます。
dispatch(type: string, payload?: any, options?: Object) | dispatch(action: Object, options?: Object)
Actionをディスパッチします。options に root: true を指定すると、名前空間付きモジュール内でルートのアクションをディスパッチできます。すべてのトリガーされたアクションハンドラーを解決する Promise を返します。
(API Reference より引用)
しかし、非同期操作においては意味がなく(Promise.resolve(undefined)となってしまうため)、非同期フローを制御する必要がある場合は、手動でpromiseを返し、必要な情報を内側のpromiseから伝達するべきです。
module
storeを分割・組織化するためのモジュール化メカニズムです。
namespacedオプションが提供されており、登録時にモジュールのパスをプレフィックスとして使用します。非常に洗練された設計であり、モジュール内にlocal.dispatch/commit/getters/stateを注入することで名前空間の影響を吸収しています。これにより、モジュール内部では名前空間を意識する必要がなく、モジュール外部(ビジネスロジックや他のモジュール)からアクセスする際にのみ名前空間が必要になります。名前空間は単なるスイッチのようなオプションとなり、storeの部分には何の影響も与えません。
4. ツール
同様に、Vuexもstate -> viewの部分を処理する必要があります(役割としてはreact-reduxに似ており、状態管理層をビュー層に接続します)。
正確なデータバインディングをサポートするVueは、Reactのように面倒なこと(仮想DOMツリーにいくつかのcontainerを挿入し、store.stateの変更を伝播させること)をする必要はなく、store.stateとコンポーネントの状態を結びつけるだけで十分です。シンボリックリンクのように、コンポーネントとstoreがstateオブジェクトを共有し、stateの変更がリアクティブな特性を通じてコンポーネントに伝わり、ビューが更新されます。
mapState
store.stateとコンポーネントのcomputedを結びつけます。
注意:mapStateは、コンポーネント内で直接computedを変更して実際の状態に影響を与えることを強制的に禁止できます(mapStateによって生成された算出プロパティは読み取り専用です)。
{
configurable: true,
enumerable: true,
get: function computedGetter(),
set: function noop()
}
mapGetters
store.getterとコンポーネントのcomputedを結びつけます。
mapStateと同様に、書き込み保護があります。
mapMutations
mutationとコンポーネントのmethodsを結びつけます。
コンポーネント内でのcommit mutationのプロセスを簡略化します(トップレベルでのstoreの注入が必要です)。
mapActions
actionとコンポーネントのmethodsを結びつけます。
dispatch actionのプロセスを簡略化します(同様にstoreの注入が必要です)。
5. 疑問点
1. 同一コンポーネント間で状態が共有されるのをどう防ぐか?
例えば、リストの中に同じコンポーネントが3つある場合、stateを共有することによる状態の不整合(すべてのコンポーネントが同じ状態になってしまう問題)をどう防ぐのでしょうか?
これはモジュールの再利用と状態共有の衝突です。dataを処理するのと同じように、オブジェクトのstateではなく、関数のstateを使用して新しい状態オブジェクトを返します。これにより、3つのコンポーネントに対応するstate(store.state上の一部分)がそれぞれ独立し、追加の状態管理も不要になります。
注意:関数stateの機能はVuex v2.3.0以降で利用可能であり、それより古いバージョンでは以下のような別の方法を検討する必要があります:
-
stateを1段階引き上げる(配列を管理し、state listを管理する) -
共有できない局所的な状態はコンポーネントレベルに置き、共有可能なデータと操作を
storeに置く
1つ目の方法はstoreの急激な肥大化を招き、さらにaction/mutationなどにindexが必要となり、コンポーネントからstoreにindexを戻さなければならず、非常に面倒で推奨できません。
2つ目の方法が究極の解決策です。stateを分割するテクニックはどこでも通用します。Vuex化すること自体を目的としてVuexを使用してはいけません。すべての状態をコンポーネントから抽出してstoreに置くことは不可能ではありませんが、storeが保持する状態が細かすぎると、開発や保守において多大な労力を要します:
-
開発時、コンポーネント内のどんな些細な変更でも
dispatch/commitを通さなければならない。 -
保守時、非常に複雑な
storeと数千ものmutation typeに向き合うことになる。
これらの面倒は完全に自業自得です。では、状態をどのように分割すべきか考えましょう:
-
インタラクションに関連するUI状態はコンポーネントレベルに置く。例えば、展開/折りたたみ、
loadingの表示/非表示、tab/テーブルのページネーションなど。 -
共有できないデータ状態はコンポーネントレベルに置く。例えば、フォームの入力データなど。
-
共有可能なデータ状態は状態管理層に置く。例えば、キャッシュ可能なサーバーデータなど。
storeの役割はserver + databaseであり、フロントエンドのデータ層として存在するべきです。単にアプリケーションの状態をコンポーネントツリーから抽出して状態ツリーにするだけでは、あまり意味がありません。
2. computedプロパティとVuexのstore.stateはどのように関連付けられているのか?
実行時の依存関係収集メカニズムによるものです。
// コンポーネント
computed: {
user() {
return this.$store.state.user;
}
}
// store
mutations: {
[types.SET_USER] (state, user) {
state.user = user;
}
}
各computedプロパティを計算する際、user()の実行過程でstore.state.userにアクセスすると、stateのgetterがトリガーされ、user()関数がstore.state.userに依存しているという情報が記録されます。
その後、commit mutationによってstore.state.userが変更されると、stateのsetterがトリガーされ、userプロパティに依存するすべての項目(その中にuser()関数が含まれます)が再評価されます。
続いてcomputedのsetterがトリガーされ、computed.userに依存するすべての項目(その中にビューの更新関数が含まれます)が実行され、ビューの更新が完了します。
P.S. 依存関係収集メカニズムの具体的な実装については、vue/src/core/observer/dep.js を参照してください。
3. storeの伝達メカニズム
react-reduxのProviderと同様に、一度注入すればグローバルで利用可能になる方法を提供しています(Vue.use(Vuex)を実行し、トップレベルコンポーネントをnewする際にstoreを渡します)。
Vuexはプラグインとして機能し、Vue.prototypeを変更して$storeを登録することで、すべてのvm(Vueインスタンス)で共有できるようにしています。
4. inputなどの双方向バインディングとstore.stateを直接変更できない仕様との衝突
算出プロパティのgetter/setterを通じて処理します:
-
getter内でstore.stateを読み取る。 -
setter内でcommit mutationを行いstore.stateに書き込む。
コメントはまだありません