この記事は?
Vue.jsでは状態管理ライブラリとしてVuexが提供されています。
この記事では以下のようなことをまとめ、VuexのHello Worldとして、Vue.jsで作成したカウンターアプリにVuexを追加してみようと思います。
- クライアントサイドでのコンポーネント間における状態管理の必要性
- Vueのアーキテクチャ
クライアントサイドでのコンポーネント間における状態管理の必要性
昨今、SPA(Single Page Application)と呼ばれるWebサイトの構築手法が流行し、旧来サーバーサイドで実行していたロジックの一部がクライアントサイドへ移行してきています。
そのような流れの中で、クライアントサイドの状態管理には以下のような特性が強くなりました。
- 複数のビューが同じ状態に依存する
- 異なるビューからのイベントで、同じ状態を更新する
ここで、上記のような特性を考慮した状態管理が行われていないと、画面の一部が更新されないといったようなバグの原因になります。
素のVue.jsも状態管理の機能は持っていますが、MVVMと呼ばれる状態管理パターンを採用しておりこのパターンは複数のコンポーネント間の状態管理を行うことに問題があると考えられています。
そこで登場するのが、Vuexです。
Vuexとは
VuexはVue.jsのために作成された状態管理ライブラリです。ちなみに読み方は「びゅーえっくす」みたいです。
Vue.jsの状態管理との違い
上記のようにVue.jsにも状態管理の機能はあります。これは、MVVMと呼ばれるパターンを採用しており、図示すると以下のようになります。
このパターンは双方向バインドを可能とし、Viewの更新された場合も、Modelが更新された場合でもお互いにイベントを検知することができます。これはコンポーネントが少ない状態であればよく働きます。
しかし、ここで、コンポーネントが増えていくと以下のような状態になっていきます。
状態の依存関係が複雑になりメンテナンス性を落とします。上記の図ではわかりづらく感じるので、どういうことなのかもう少し掘り下げてみます。
Vue.jsはコンポーネントシステムを採用しており、コンポーネントには親子関係が存在します。
ここで、親コンポーネントは子コンポーネントへ状態を受け渡す必要があり、さらに状態はそれぞれのコンポーネントで状態管理されます。
このような状況下では以下のような問題が起こります。
- 親コンポーネントから子コンポーネントへの状態の受け渡しが煩雑になる。
- 状態変更イベントが複雑化しメンテナンス性がさがる。
後者の一例を示すと、状態変更イベントの通知は下の図のような流れで実施しなければなりません。
例はまだ単純ですが、業務アプリの場合コンポーネントは多数で依存関係はさらに複雑になることがあり得ます。
別画面の更新で、同じ状態を同期的に変更していくのは非常に難しくなっていくでしょう。
このような問題に対してVuexではFlux、Redux、The Elm Architectureから影響を受けた以下のような状態管理を行います。
グローバルに参照できるストアを用意することで、Vueインスタンスのそれぞれが同じデータにアクセスすることができ、値の受け渡しなどの複雑性を排除します。
また、更新の流れが一方向であり、かつ、更新時の役割を分割することによってメンテナンス性を向上させることができます。
Vuexのアーキテクチャ
VuexではVue.jsの状態管理とは違い以下の概念が登場します。
- StateとStore
- Action
- Mutations
- Getter
StateとStore
StateはVuexでコンポーネント間でグローバルな状態を持つ単一のステートツリーです。StateはStoreで管理されます。
Stateはアプリケーション内で1つだけ作成されます。そのため、アプリケーションが信頼できる唯一の情報源として機能します。
ここで、Vuexではすべての状態をStateに入れて管理をすべきであるとは言っておらず状態が1つのコンポーネントで完結しているのであればStateに状態を入れておく必要はありません。
Mutations
VuexのStateを変更する唯一の方法はMutationをコミットすることです。
const store = new Vuex.Store({
state: {
count: 1
},
mutations: {
increment (state) {
// 状態を変更する
state.count++
}
}
})
Mutationはイベントに近い概念を持っておりタイプとハンドラを持ちます。
例えば上記のコードではincrement
がタイプで、メソッド部分を含めるとハンドラになります。
Mutationは直接呼び出すことはできず下記のようにミューテーションタイプを指定しコミットすることになります。
store.commit('increment')
また、状態変更を非同期に組み合わせることは、プログラムの動きを予測することを非常に困難にするため、Mutationでは非同期な処理を記述することはできません。
Vuexすべての状態変化は同期的に行うということが作法となっています。
Action
アクションではMutationのコミットを行います。
アクションではMutationと違い非同期な処理を書くことができます。
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment (state) {
state.count++
}
},
actions: {
increment (context) {
context.commit('increment')
}
}
})
アクションではcontext
オブジェクトを受けとります。context.commit
でMutationをコミットすることもでき、context.getter
でgetterにアクセスすることも可能です。
アクションそのものは、store.dispatch
がトリガーとして実行されます。
store.dispatch('increment')
context.commit
を直接呼び出さないのはAction内で非同期な処理が記述されることが可能であるからです。
actions: {
incrementAsync ({ commit }) {
setTimeout(() => {
commit('increment')
}, 1000)
}
}
Getter
例えばStateの状態からフィルタリングして値と取り出したいと考えたときに、Getterが使えます。
const store = new Vuex.Store({
state: {
todos: [
{ id: 1, text: '...', done: true },
{ id: 2, text: '...', done: false }
]
},
getters: {
doneTodos: state => {
return state.todos.filter(todo => todo.done)
}
}
})
Getterは以下のように呼び出すことが可能です。
store.getters.doneTodos
Getterを利用することにより、処理の再利用性を高めることができます。
Hello World
Vue.js製のカウンターアプリにVuexを追加してみたいと思います。
環境
- Vue CLI(v3.11.0)
- Node(v11.9.0)
- Chrome
前回の記事で作ったカウンターアプリ
「 Vue.jsについてまとめて、カウンターアプリを作ってみる。 」で作成したカウンターアプリに対してVuexを導入します。そこまで複雑なアプリでないので、この記事を先に読むことは必須ではないです。しかし、作成されているカウンターアプリの構造に把握していることを前提に進めますので、ソースコードに一度目を通してみてください。(そんなに時間はかからないと思います。)
Vuexの導入
まずはVuexをインストールします。
$ npm install vuex --save
package.json
にvuex
の依存が追加されました。
"dependencies": {
"core-js": "^2.6.5",
"vue": "^2.6.10",
"vuex": "^3.1.1"
}
次にStoreを作成します。StoreはVuexのStateを管理するためのコンテナです。
counter.js
という名前でファイルを作成し以下のコードを記述します。
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store(
{
state:{
num: 0
},
mutations:{
increment(state) {
state.num++
},
decrement(state) {
state.num--
}
},
actions:{
increment(context) {
context.commit('increment')
},
decrement(context) {
context.commit('decrement')
}
}
}
)
まず、Vue.use(Vuex)
の箇所ではVue.jsに対しプラグインの設定を行いってます。今回はVuexのプラグインを使うのでその登録を行っています。
Vuex.Store(....)
の箇所で実際にStoreを作成します。state
とmutations
、actions
をそれぞれ定義しています。今回はgetter
は必要ないので作成しませんでした。
上記で作成したStoreをmain.js
で読み込ませます。
import Vue from 'vue'
import App from './App.vue'
import store from './store'
Vue.config.productionTip = false
new Vue({
store,
render: h => h(App),
}).$mount('#app')
その後作成したStoreのActionを呼び出すDispatch部分と、初期値のStateをStoreから取り出す部分の記述を行います。
<template>
<div id="counter">
<span>{{ num }} </span>
<button @click="increment">up</button>
<button @click="decrement">down</button>
<p v-if="num === 0">
Press 'up' or 'down' button!!!!
</p>
</div>
</template>
<script>
import {mapState, mapActions} from 'vuex'
export default {
name: 'Counter',
computed: mapState({
num: state => state.num
}),
methods: mapActions([
'increment',
'decrement'
])
}
</script>
computed
は算出プロパティーと呼ばれるもので、この内部で定義されているnum
が変更された際に依存関係にあるコンポーネントの部分が更新される仕組みになっています。
computed
ではさらにmapState
と呼ばれるVuexで用意されているヘルパー関数を利用しています。このヘルパー関数を利用することによって複数のStateを利用する際の冗長なコードを削減することができます。
例えば、mapState
を使わないと複数Stateを扱いたい場合に以下のような実装になります。
// mapStateを使わない場合
computed:{
hoge(){
return this.$store.state.hoge
},
fuga(){
return this.$store.state.fuga
}
}
// 使った場合
computed: mapState({
hoge: state => state.hoge,
fuga: fuga => state.fuga
}),
今回は、Stateが1つしかないのであまり使う意味はなかったかもしれません。。。
次に、methods
ですが、ここではActionの呼び出しを行っています。ここでもヘルパー関数のmapAction
を利用しています。
mapActions
を使わない場合this.$store.dispatch("ACTION_TYPE")
を呼び出す関数をそれぞれ定義することになりこれも冗長になります。
ひとまず、ここまでで以前作成したカウンターアプリにVuexを導入できました。
ただ、これだけだとVuexを導入した面白味に欠けるので、1つコンポーネントを作成して、num
プロパティを複数個所から読み込めるようにしてみたいと思います。
num
に任意の数字を足し引きできるフォームを追加します。
まずはcounter.js
にActionとMutationを追加します。
mutations:{
increment(state) {
state.num++
},
decrement(state) {
state.num--
},
// 追加
calc(state, n){
state.num +=n
}
},
actions:{
increment(context) {
context.commit('increment')
},
decrement(context) {
context.commit('decrement')
},
// 追加
calc(context, n){
context.commit('calc', n)
}
}
次にフォーム用のVueコンポーネントを1つ追加します。
CalcNumForm.vue
を作成しまします。
<template>
<div>
<input v-model="calcNum" placeholder="type number">
<button @click="calc(calcNum)" >calculate</button>
</div>
</template>
<script>
import {mapState} from 'vuex'
export default {
name: "CalcNumForm",
computed: mapState({
num: state => state.num
}),
methods: {
calc: function (calcNum) {
this.$store.dispatch('calc', parseInt(calcNum))
}
}
}
</script>
まず、template
タグのinput
の部分ですが、v-model
を利用しています。
v-model
はVue.jsのタグディレクティブでinput要素などで、双方向バインディングを作成してくれます。
次にscript
タグの中で、ステートのバインディングとActionの呼び出しを行っています。
今回、Actionの引数として、入力された値を渡したかったので、呼び出しにはmapActions
を使わずにthis.$store.dispatch
を呼び出しています。
(mapActions
でもできるのかもしれませんが、やり方がいまいちわからなかったです。)
そして、作成したコンポーネントをApp.vue
から呼び出して表示します。
これで動くはずです。
Devサーバを起動して動かしてみます。
動きました。
実はこのアプリは入力ボックスに数値以外のものを入れてしまうとNaN
になってしまうのですが、ここではその対応までは作りこみません。
一旦、VuexへのHello Worldはできた気がします。
感想
今回使用したカウンターアプリではVuexの利点はわかりづらいかもしれませんが、業務レベルの巨大なアプリケーションになればもう少しVuexの利点を身近に感じれるのではないかと感じました。
あとは、今回は触りませんでしたが、Stateが大きくなってきた際にその定義を分割できるモジュールと呼ばれる機能もあるようです。