こんにちは、フロントエンドエンジニアの小玉です。
今回は、Vue.js
における状態管理についてお話したいと思います。
Vue2.x時代、多くの方はVuex
を使用していたのではないでしょうか?Vue2.x、Vue3.xともに公式のドキュメントにおいても、状態管理のセクションでVuex
が紹介されています。
ただVue3.0以降は、Vuex
に打って変わる状態管理方法がいくつか提唱されていますので、それぞれの内容を簡単に見つつ比較したいと思います。
Vuex
Vuex
はVue.js
のアプリケーションのための状態管理ライブラリであり、単純なグローバルオブジェクトとは異なり、Vuex
のストアはリアクティブです。
また、Vuex
は以下のように単方向のデータフローになるので、変更される状態の追跡が明示的です。
vue-devtools
を使用すると、getter
で得られる値、mutation
をcommit
した履歴などが確認できます。
actions
state
を直接更新することはなく、mutations
を経由することでstateを更新する。- 非同期の処理を入れることができる。
mutations
actions
から受け取った値をstate
にセット
mutations
では単にstate
の値を変更するだけの処理を行うことが多いかと思います。
それに対しactions
では比較的複雑なロジックを記述しmutations
に対して値を返すイメージです。
実際のコード
今回は簡単なカウンターアプリを実装します。
SFC内部で全て完結させる場合は上記のような形になります。
<template> <div> <p>{{ count }}</p> <button @click="decrement">-</button> <button @click="increment">+</button> </div> </template> <script lang="ts"> import { reactive, computed, defineComponent } from "vue"; export default defineComponent({ name: "App", setup() { const state = reactive({ count: 0, }); const count = computed(() => state.count); return { count, increment() { state.count += 1; }, decrement() { state.count -= 1; }, }; }, }); </script>
では、実際にVuex
を使用し、コンポーネントを分割した場合はどのような記述になるでしょうか。
ディレクトリ構成は以下のような形で進めてみます。
/src ├ components │ ├ DecrementBtn.vue │ └ IncrementBtn.vue ├ store │ └ index.js └ App.vue
Vuex
のストアの定義を以下のようにします。
▼store/index.js
import { createStore } from "vuex"; const state = { count: 0 }; const mutations = { increment(state) { state.count++; }, decrement(state) { state.count--; } }; const actions = { increment: ({ commit }) => commit("increment"), decrement: ({ commit }) => commit("decrement") }; export default createStore({ state, actions, mutations });
▼親コンポーネント(App.vue)
<template> <div> <p>{{ count }}</p> <increment-btn /> <decrement-btn /> </div> </template> <script lang="ts"> import { computed, defineComponent } from "vue"; import { useStore } from "vuex"; import IncrementBtn from "./components/IncrementBtn.vue"; import DecrementBtn from "./components/DecrementBtn.vue"; export default defineComponent({ components: { IncrementBtn, DecrementBtn, }, setup() { const counter = useStore(); const count = computed(() => counter.state.count); return { count, }; }, }); </script>
▼子コンポーネント(DecrementBtn.vue)
<template> <button @click="decrement">+</button> </template> <script> import { defineComponent } from "vue"; import { useStore } from "vuex"; export default defineComponent({ setup() { const counter = useStore(); return { decrement: () => counter.dispatch("decrement"), }; }, }); </script>
実際の挙動の確認はこちら↓
簡単なアプリケーションということもありますが、コンポーネント間のストアへのアクセス方法や、データフローなど非常に明示的であることがわかるかと思います。
Provide / inject
Provide / inject
は、これまでコンポーネントの階層の深さに関係なく、親コンポーネントから子階層へ依存関係を提供するプロバイダとして機能します。
これまでは親コンポーネントから子コンポーネントにデータを渡す際、props
を使用していたかと思います。
しかし、階層が深くなるにつれバケツリレーのように伝搬していくのは非常に面倒でした。
こちらはVue2.2から実装されているものでしたが、Vue3.0からはComposition API
のリリースに伴い注目されています。
Composition API
Composition API
は、ロジックをカプセル化することでコンポーネント間でのロジックの再利用を可能にするAPIです。
特に大規模なアプリケーションになると、Vue2.xまではViewとロジックが密結合になっており、SFCが冗長になってしまうことが問題でした。
しかし、Composition API
を活用することでSFCにはViewに関することのみを記述し、ロジックを外部に切り出すようなことも可能になります。
実際のコード
基本的なディレクトリ構成は変わらず、以下のような形。
/src ├ components │ ├ CountView.vue │ ├ DecrementBtn.vue │ ├ ParentProvider.vue │ └ IncrementBtn.vue ├ store │ └ index.ts │ └ key.ts └ App.vue
コンポーネント内でしていた状態管理の部分を切り抜き、ストアを作成しキーを定義します。
▼index.ts
import { reactive } from "vue"; export default function counterStore() { const state = reactive({ count: 0 }); return { get count() { return state.count; }, increment() { state.count += 1; }, decrement() { state.count -= 1; } }; } export type CounterStore = ReturnType<typeof counterStore>;
▼key.ts
import { InjectionKey } from "vue"; import { CounterStore } from "./index"; const CounterKey: InjectionKey<CounterStore> = Symbol("counterStore"); export default CounterKey;
provide
は( key , value )を受け取り、provide
された値はコンポーネントからキーを用いてinject
を取り出せます。
▼親コンポーネント(ParentProvider.vue)
<template> <div> <slot /> </div> </template> <script lang="ts"> import { provide } from "vue"; import counterStore from "../store/index"; import CounterKey from "../store/key"; export default { setup() { provide(CounterKey, counterStore()); return {}; }, }; </script>
これでParentProvider.vue以下のコンポーネントはどの階層においてもストアオブジェクトを受け取ることが可能です。
▼子コンポーネント(CountView.vue)
<template> <p>{{ count }}</p> </template> <script> import { defineComponent, inject, computed } from "vue"; import CounterKey from "../store/key"; export default defineComponent({ setup() { const counter = inject(CounterKey); const count = computed(() => counter.count); return { count, }; }, }); </script>
ストアオブジェクトを受け取る側はinject
にキーを指定します。同様の記述でボタン関係も実装します。
最終的には以下のようにまとめることでVuex
やSFC1つで記述していたものと同様の動きをします。
▼App.vue
<template> <div> <parent-provider> <count-view /> <increment-btn /> <decrement-btn /> </parent-provider> </div> </template> <script lang="ts"> import { defineComponent } from "vue"; import ParentProvider from "./components/ParentProvider.vue"; import CountView from "./components/CountView.vue"; import IncrementBtn from "./components/IncrementBtn.vue"; import DecrementBtn from "./components/DecrementBtn.vue"; export default defineComponent({ components: { ParentProvider, CountView, IncrementBtn, DecrementBtn, }, setup() { return {}; }, }); </script>
実際の挙動の確認はこちら↓
まとめ
provide
は、今回のようなルートコンポーネント以外のコンポーネントからも使用することができます。
つまり、限定的な範囲でストアを定義することが可能となります。
Vuex
ではグローバルに定義する必要があったため、プロジェクトの規模によってはスコープの小さい状態管理ができるProvide / inject
を使用するのもいいかもしれません。
一方で、Vuex
は、Flux
でデータフローなどが決まっているため、チーム内でのガイドライン等を作る手間が少なく、さらにDevToolでのデバックができることも大きな利点かと思います。
チームとしてComposition API
を使用し、かつ規模の小さいプロジェクトの場合Provide / inject
を使用し、大規模なプロジェクトの場合Vuex
を使う。そんな選択が今後できるかと思います。
参考にさせていただいた記事等
- なぜ、Vue Composition APIを使うのか、理解する【メリット/デメリットまとめ】 - Qiita
- Vue Composition API を使ったストアパターンと TypeScript の組み合わせはどのくらいスケールするか? - Qiita
- 【Vue3】Composition APIを使ったVuexの代替 - Qiita
最後になりますが、Wizではエンジニアを募集中です!
興味のある方は是非覗いてみてください↓