RitoLabo

Vuexを用いたVue.jsアプリケーションのシンプルな状態管理とデータフロー

  • 公開:
  • 更新:
  • カテゴリ: JavaScript Vue.js
  • タグ: JavaScript,Vue,Vuex

Vue.jsでは、シンプルに状態管理を行えるようにVuexという状態管理パターンライブラリが提供されています。

今回はVuexを使った状態管理の一連の流れを実装していきながら見ていきます。

アジェンダ
  1. 開発環境
  2. Vuex
  3. 前段準備
  4. store
  5. state
    1. mapStateヘルパー
  6. mutations
    1. mapMutationsヘルパー
  7. actions
    1. mapActionsヘルパー
  8. getters
    1. mapGettersヘルパー
  9. 動作確認
  10. modules
    1. module作成
    2. store
    3. コンポーネント

開発環境

今回の開発環境は以下の通りです。

  • Vue.js 2.x
  • Vue CLI 3.x
  • Node.js 12.x
  • npm 6.5

今回は、Vue CLIでプロジェクトを作成し実装を進めていきます。

Vuex

Vuexは、Vue.jsアプリケーションの状態管理を行う為のライブラリです。

Vue.jsで構築するアプリケーションは、その規模が大きくなるにつれ状態管理が複雑になっていきます。 この「状態を管理する」とは、何らかの操作によって値などが変更され再描画されていくなどの、一連のデータフローを指しますが、 このデータフローが煩雑だと、あちこちの方向から無造作に状態変更が行われたりしだすので、最終的にアプリケーション動作 の裏で起きている処理を掌握しきれなくなったりします。

こういった状態管理をできるだけシンプルに、常にデータフローを一方向にするように作られたパターンがVuexです。

詳しい概念や説明は公式リファレンスから確認できます。
https://vuex.vuejs.org/ja/

前段準備

今回はカウンターアプリを例にVuexでの実装を行っていきます。

まずは、元となるコンポーネントを2つ作成します。 App.vue より、Counterコンポーネントを読み込ませる形にして、そこからVuexを用いた実装を行っていきます。

components/Counter.vue
<template>
<div>
<p>{{ count }}</p>
<button>-</button>
<button>+</button>
</div>
</template>

<script>
export default {
name: "Counter",
data () {
return {
count: 0
}
}
}
</script>

<style lang="scss" scoped>

</style>
App.vue
<template>
<div id="app">
<counter></counter>
</div>
</template>

<script>
import Counter from './components/Counter.vue'

export default {
name: 'app',
components: {
Counter
}
}
</script>

<style lang="scss">

</style>

これらを以下のnpmコマンドを叩いてブラウザで確認します。

npm run serve

スタート時画面

カウンターの数値と増減のボタンが表示されています。 この時点ではまだ何も定義していないので、もちろんボタンを押下しても何も起こりません。

store

まずは状態管理の核となるstoreを作成します。

store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
state: {

},
mutations: {

},
actions: {

}
})

Vue CLIでプロジェクトを作成(Vuex有効)した場合は既に作成されています。 これに状態管理のコードを記述していきます。

state

stateを定義します。stateとは「状態」であり、今回のカウンターアプリでは「カウントの値」がそれに該当します。

従って、これまでCounterコンポーネントで保持していたcountをstateに定義し直し、componentに表示させます。

store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
count: 0,
},
mutations: {

},
actions: {

}
})

stateプロパティにcountを定義しています。初期値は0です。

components/Counter.vue
<template>
<div>
<p>{{ count }}</p>
<button>-</button>
<button>+</button>
</div>
</template>

<script>
import store from '../store'

export default {
name: "Counter",
computed: {
count () {
return store.state.count
}
}
}
</script>

<style lang="scss" scoped>

</style>

これまであったdataオプションを除去し、storeからcountを返すようにしています。

これでカウンター値がCounterコンポーネントからstoreへ移りました。

mapStateヘルパー

これからstoreで色々と管理をしていきますが、管理するものの数が増えると、コンポーネント側の記述が冗長になってきます。 これ毎回「store.state.count」って書くの?長くない?みたいなやつです。

コンポーネント側もできるだけシンプルに記述できるように、ヘルパーが用意されています。 stateに関するmapStateヘルパーを用いて記述をシンプルな形へ変更します。

components/Counter.vue
<template>
<div>
<p>{{ count }}</p>
<button>-</button>
<button>+</button>
</div>
</template>

<script>
// import store from '../store' <- 不要なのでコメントアウト
import { mapState } from 'vuex'

export default {
name: "Counter",
computed: {
/*
count () {
return store.state.count
}
*/
// ↓
// 変更
...mapState([
"count" // => `this.count` が `store.state.count` にマッピングされる
]),
}
}
</script>

<style lang="scss" scoped>

</style>

mapStateをインポートしています。これによってstoreのインポートは不要になります。

mapStateを定義した事で、store.state.count を this.count として使用出来るようになります。

ここまでで一度ブラウザから確認してみると、見た目は全く変わりませんが、devtoolsで確認すると、 countの管理がコンポーネントからstoreへ移っている事が確認できます。

countの管理がstoreになっている事の確認

mutations

mutationsを定義します。mutationsとは「変更」であり、今回の例では、カウンター値を変更する処理がそれに該当します。

store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
count: 0,
},
mutations: {
// 追加
increment (state) {
state.count++
},
decrement (state) {
state.count--
}
},
actions: {

}
})

mutationsにそれぞれincrementdecrementを定義しています。 カウンター値を1つ増やす・減らすの処理です。

mutationsにはstateを変更する為の処理を定義しますが、約束として「同期処理のみを定義する」というルールがあります。 非同期処理は書かないでね。という事です。

コンポーネント側も実装します。

components/Counter.vue
<template>
<div>
<p>{{ count }}</p>
<button @click="sub">-</button>
<button @click="add">+</button>
</div>
</template>

<script>
import { mapState } from 'vuex'

export default {
name: "Counter",
computed: {
...mapState([
"count"
]),
},
// 追加
methods: {
add () {
this.$store.commit('increment')
},
sub () {
this.$store.commit('decrement')
}
}
}
</script>

<style lang="scss" scoped>

</style>

methodsにそれぞれaddsubを定義しています。

コンポーネント側からmutationを実行するには、下記のようにcommitを実行します。

this.$store.commit('メソッド名')

mapMutationsヘルパー

stateと同じく、ミューテーションもヘルパーを用いて記述をシンプルにします。

components/Counter.vue
<template>
<div>
<p>{{ count }}</p>
<button @click="sub">-</button>
<button @click="add">+</button>
</div>
</template>

<script>
import { mapState } from 'vuex'
import { mapMutations } from 'vuex' // 追加

export default {
name: "Counter",
computed: {
...mapState([
"count"
]),
},
methods: {
// 追加
...mapMutations([
'increment', // => `this.increment()` が `this.$store.commit('increment')` にマッピングされる
'decrement' // => `this.decrement()` が `this.$store.commit('decrement')` にマッピングされる
]),
add () {
this.increment()
},
sub () {
this.decrement()
}
}
}
</script>

<style lang="scss" scoped>

</style>

mapMutationsをインポートしています。 mapMutationsを定義した事で、それぞれのメソッドの記述がシンプルになりました。

actions

actionsを定義します。actionsとは「アクション」であり、外部APIとの連携や非同期処理がそれに該当します。 今回の例では、非同期で値を追加する処理で実装していきます。

store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
count: 0,
},
mutations: {
increment (state) {
state.count++
},
decrement (state) {
state.count--
}
},
actions: {
// 追加
incrementAsync ({ commit }) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('increment')
resolve()
}, 1000)
})
},
// 追加
decrementAsync ({ commit }) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('decrement')
resolve()
}, 1000)
})
},
}
})

1秒後にそれぞれ1ずつ増減する処理をactionsに定義しています。

コンポーネント側も実装します。

components/Counter.vue
methods: {
...mapMutations([
'increment',
'decrement'
]),
add () {
this.increment()

this.$store.dispatch('incrementAsync') // 追加
},
sub () {
this.decrement()

this.$store.dispatch('decrementAsync') // 追加
}
}

コンポーネント側からactionを実行するには、以下のようにdispatchを実行します。

this.$store.dispatch('メソッド名')

ちなみにこれはmutationも同じですが、引数を渡す事も可能です。その場合は以下の様に定義します。

components/Counter.vue
methods: {
...mapMutations([
'increment',
'decrement'
]),
add () {
this.increment()

this.$store.dispatch('addAsync', {
amount: 1000
})
},
sub () {
this.decrement()

this.$store.dispatch('subAsync', {
amount: 1000
})
}
}

第一引数にメソッド名、第二引数に渡したい値を指定します。

処理を変更したので、actionの方も変更し指定数を追加できるようにします。

store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
count: 0,
},
mutations: {
increment (state) {
state.count++
},
decrement (state) {
state.count--
},
// 追加
add (state, amount) {
state.count += amount
},
// 追加
sub (state, amount) {
state.count -= amount
}
},
actions: {
// 追加
addAsync ({ commit }, payload) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('add', payload.amount)
resolve()
}, 1000)
})
},
// 追加
subAsync ({ commit }, payload) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('sub', payload.amount)
resolve()
}, 1000)
})
}
}
})

ここでのポイントは、アクションでの処理でも、実際にstateを変更する場合はmutationで行う点です。 このルールを守る事で単一のデータフローを遵守できます。

また、しれっと以下の記述があると思います

strict: process.env.NODE_ENV !== 'production',

これは厳格モードといって、ミューテーション以外でステートの変更があった場合にエラーを投げるように出来る設定です。開発環境でのみ動作するようにしています。

mapActionsヘルパー

アクションもヘルパーを用いて記述をシンプルにします。

components/Counter.vue
<template>
<div>
<p>{{ count }}</p>
<button @click="sub">-</button>
<button @click="add">+</button>
</div>
</template>

<script>
import { mapState, mapMutations } from 'vuex'
import { mapActions } from 'vuex' // 追加

export default {
name: "Counter",
computed: {
...mapState([
"count"
]),
},
methods: {
...mapMutations([
'increment',
'decrement'
]),
// 追加
...mapActions([
'addAsync', // => `this.addAsync()` が `this.$store.dispatch('addAsync')` にマッピングされる
'subAsync' // => `this.subAsync()` が `this.$store.dispatch('subAsync')` にマッピングされる
]),
add () {
this.increment()

// 変更
this.addAsync({
amount: 1000
})
},
sub () {
this.decrement()

// 変更
this.subAsync({
amount: 1000
})
}
}
}
</script>

<style lang="scss" scoped>

</style>

mapActionsをインポートしています。 mapActionsを定義した事で、それぞれのメソッドの記述がシンプルになりました。

getters

gettersを定義します。ここではstateの前処理を行う事が出来ます。 今回は、カウンター値の正負を判定する処理を実装して、それをコンポーネント側のクラス付与の判定に用います。

store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
count: 0,
},
// 追加
getters: {
isPositive: state => {
return state.count > 0
},
isNegative: state => {
return state.count < 0
},
},
mutations: {
increment (state) {
state.count++
},
decrement (state) {
state.count--
},
add (state, amount) {
state.count += amount
},
sub (state, amount) {
state.count -= amount
}
},
actions: {
addAsync ({ commit }, payload) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('add', payload.amount)
resolve()
}, 1000)
})
},
subAsync ({ commit }, payload) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('sub', payload.amount)
resolve()
}, 1000)
})
}
}
})

それぞれ、正の数か負の数かを判定して真偽値を返しています。

これをコンポーネント側で利用します。

components/Counter.vue
<template>
<div>
<p :class="classesCount">{{ count }}</p><!-- classディレクティブ追加 -->
<button @click="sub">-</button>
<button @click="add">+</button>
</div>
</template>

<script>
import { mapState, mapMutations, mapActions } from 'vuex'

export default {
name: "Counter",
computed: {
...mapState([
"count"
]),
// 追加
classesCount () {
return {
'blue': this.$store.getters.isPositive,
'red': this.$store.getters.isNegative
}
}
},
methods: {
...mapMutations([
'increment',
'decrement'
]),
...mapActions([
'addAsync',
'subAsync'
]),
add () {
this.increment()

this.addAsync({
amount: 1000
})
},
sub () {
this.decrement()

this.subAsync({
amount: 1000
})
}
}
}
</script>

<style lang="scss" scoped>
// 追加
.red {
color: red;
}
.blue {
color: blue;
}
</style>

computedにclassesCountを定義しています。 gettersへのアクセスは、以下の書式で行えます。

this.$store.getters.methodName

これによってクラス名を付与して文字色を変更したいので、styleも定義しています。

mapGettersヘルパー

mapGettersヘルパーを用いて、コンポーネント側のgettersの記述をシンプルにします。

components/Counter.vue
<template>
<div>
<p :class="classesCount">{{ count }}</p>
<button @click="sub">-</button>
<button @click="add">+</button>
</div>
</template>

<script>
import { mapState, mapMutations, mapActions } from 'vuex'
import { mapGetters } from 'vuex' // 追加

export default {
name: "Counter",
computed: {
...mapState([
"count"
]),
// 追加
...mapGetters([
'isPositive', // => `this.isPositive` が `this.$store.getters.isPositive` にマッピングされる
'isNegative', // => `this.isNegative` が `this.$store.getters.isNegative` にマッピングされる
]),
classesCount () {
return {
'blue': this.isPositive, // 変更
'red': this.isNegative // 変更
}
}
},
methods: {
...mapMutations([
'increment',
'decrement'
]),
...mapActions([
'addAsync',
'subAsync'
]),
add () {
this.increment()

this.addAsync({
amount: 1000
})
},
sub () {
this.decrement()

this.subAsync({
amount: 1000
})
}
}
}
</script>

<style lang="scss" scoped>
.red {
color: red;
}
.blue {
color: blue;
}
</style>

mapGettersを定義した事で、それぞれのメソッドの記述がシンプルになりました。

動作確認

ここまでで一連の実装が完了したので、ブラウザから確認してみます。

Vuexによる状態管理の実装

Vuexを用いて意図した通りの処理を実装できました。
サンプルページ

modules

これまで行ってきた手順によってVuexを用いた状態管理は実現できました。 ただ、アプリケーションが大きくなっていくとそれにつれてstoreの中身も大きくなっていく事は想像できると思います。

この時に、モジュールという単位にstoreを切り分ける事が出来ます。

ここまでのソースコードをモジュールに切り分けていきます。

module作成

modulesディレクトリを作成し、そこへcounter.jsを作成します。

modules/counter.js
export const counter = {
namespaced: true,
state: {
count: 0,
},
getters: {
isPositive: state => {
return state.count > 0
},
isNegative: state => {
return state.count < 0
},
},
mutations: {
increment (state) {
state.count++
},
decrement (state) {
state.count--
},
add (state, amount) {
state.count += amount
},
sub (state, amount) {
state.count -= amount
}
},
actions: {
addAsync ({ commit }, payload) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('add', payload.amount)
resolve()
}, 1000)
})
},
subAsync ({ commit }, payload) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('sub', payload.amount)
resolve()
}, 1000)
})
}
}
}

storeに定義したカウンター機能をそっくりこちらへ持ってきています。 それぞれ、state/getters/mutations/actions を定義できます。

store

モジュールへカウンターのストア定義を移動させたので、storeはシンプルになります。

store.js
import Vue from 'vue'
import Vuex from 'vuex'

import {counter} from './modules/counter';

Vue.use(Vuex)

export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
modules: {
counter: counter
}
})

counter.jsをインポートし、モジュールとして登録する事で、storeへ登録されます。

コンポーネント

最後にコンポーネント側も、参照先が変わるので記述を変更します。

components/Counter.vue(mapヘルパーなし)
<template>
<div>
<p :class="classesCount">{{ count }}</p>
<button @click="sub">-</button>
<button @click="add">+</button>
</div>
</template>

<script>
import store from '../store'

export default {
name: "Counter",
computed: {
count () {
return store.state.counter.count
},
classesCount () {
return {
'blue': this.$store.getters['counter/isPositive'],
'red': this.$store.getters['counter/isNegative'],
}
}
},
methods: {
add () {
this.$store.commit('counter/increment')
this.$store.dispatch('counter/addAsync', {
amount: 1000
})
},
sub () {
this.$store.commit('counter/decrement')
this.$store.dispatch('counter/subAsync', {
amount: 1000
})
}

}
}
</script>

<style scoped>
.red {
color: red;
}
.blue {
color: blue;
}
</style>
components/Counter.vue(mapヘルパーあり)
<template>
<div>
<p :class="classesCount">{{ count }}</p>
<button @click="sub">-</button>
<button @click="add">+</button>
</div>
</template>

<script>
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'

export default {
name: "Counter",
computed: {
...mapState({
count: state => state.counter.count,
}),
...mapGetters({
isPositive: 'counter/isPositive',
isNegative: 'counter/isNegative'
}),
classesCount () {
return {
'blue': this.isPositive,
'red': this.isNegative,
}
}
},
methods: {
...mapMutations({
increment: 'counter/increment',
decrement: 'counter/decrement'
}),
...mapActions({
addAsync: 'counter/addAsync',
subAsync: 'counter/subAsync'
}),
add () {
this.increment()
this.addAsync({
amount: 1000
})
},
sub () {
this.decrement()
this.subAsync({
amount: 1000
})
}

}
}
</script>

<style scoped>
.red {
color: red;
}
.blue {
color: blue;
}
</style>

詳しい説明は割愛しますが、こうしてモジュールとしてstoreを切り出す事で、機能が増えても適切に管理していく事が可能になります。 サンプルページ

まとめ

以上で作業は終了です。 長丁場だったのでダイジェストでの紹介でしたが、Vuexを導入する事でデータフローを一方向に整理でき、シンプルな状態管理を実現する事ができます。

ただし、リファレンスでも記載がある通り、すべてにこのVuexが適用できるわけではありません。 規模や複雑さによってはオーバースペック的になり、コードがただ冗長になるなどするので、 その場合はシンプルなstoreパターンを用いるなど、アプリケーションの規模によって使い分けるのが良いでしょう。 ぜひ試してみてください、

サンプルコード