Vue.js で状態管理 Vuex の基本を学ぶ

最終更新日
2017.11.06

状態管理・Vuexを勉強しながらフォームのコンポーネントを作ります。

状態管理って何?Flux?ストア?ディスパッチ?俊足?コーナーで差をつけろ!

  • Vue 2.5.2
  • Vuex 3.0.0
  • サンプルは ES6 の記述あり。

Vuex は Vue.js が公式で提供している状態管理用のエコシステムで拡張機能的なものです。

私は Vuex の基本となっている Flux という概念もよく分かっていなかったのでゼロからでしたが、学習コスト的には恐らくコンポーネントと親子関係の範囲と同じぐらいなのでそこまで大きくないです。

理解までわりとだるいので、Vue 使ってても何かデータ管理がつらくなってきた!と思うまではやらなくていいと思います。Vuex の一番最初の説明にもこう書いてあるのです。

もし、あなたが大規模な SPA を構築することなく、Vuex を導入した場合、冗長で恐ろしいと感じるかもしれません。そう感じることは全く普通です。あなたのアプリがシンプルであれば、Vuex なしで問題ないでしょう。

しかし使い方に慣れてしまえばすごく便利です。多分このページを見ている jQuery 難民のフレンズもここら辺が辛くなってくると思いますが頑張りましょう(•ө•)ノ

ちなみに IE をサポートしたい場合 es6-promise など一部の Polyfill が必要です。

状態管理について

今までいくつかコンポーネントを作ってきましたが、親子関係の場合は propsemit を使い、親子関係でない場合はイベントバスという空の Vue インスタンスを使いました。コンポーネントやネストが複雑になると一つ上に emit で送信して またその上に emit で送信してという苦行のバケツリレーがもれなく付いてきます。

参照先を固定すればこの苦行が楽になる上にデータの整合性も保ちやすいというのが状態管理です。Vuex はイベントバスの上位互換のようなもので、簡単に言えば Vue アプリ上に仮想のデータベース(ストア/データ保管庫)を作り、データが必要な時はそれを参照してね、ただしデータを操作する時はストア専用ルートを通ってね。Vuex との約束だよ!という感じです。

Vuex について

Vuex でよく見る図です。この類の図を見ると頭が痛くなるんですけどまぁ一個づつ見ていきましょう。

コンポーネントから Dispatch(ディスパッチ)という命令を送ると Action (アクション)に繋がっています。上を見ると API 等からデータを受け取ったりするのはこの部分みたいですね。その次は Commit(コミット)という命令で Mutation(ミューテーション)に繋がっている。ストアが持っているデータを実際に書き換えるのはミューテーションという事みたい。なるほどわからん。

正直な所 Vue 同様に Vuex のマニュアルも翻訳されているので本気でひととおり読むだけでもだいぶ分かるけど、実際書かないてみないとわからない!

シンプルなストア構造

const store = new Vuex.Store({
  state: {
    // 単純なテキストデータ
    message: '初期メッセージ'
  },
  mutations: {
    // メッセージの書き換え
    setMessage(state, payload) {
      state.message = payload
    }
  },
  getters: {
    // message をそのまま使用
    message(state) { return state.message }
  }
})

これはメッセージの内容をコンポーネントではなくストアに置いた場合のシンプルなストアの構造です。コンポーネントから以下のようにメッセージを書き換える事ができる。

ストアのメッセージを書き換える

this.$store.commit('setMessage', 'フォームとかで適当なメッセージに変更')

これを実際に動かす前に上の図の名称を簡単に抑えておこう。

ステート State

実際の状態(最新のデータ)が入ったもの。コンポーネントでいうデータのようなもの。ミューテーション以外から直接書き換えてはいけない。

登録の仕方

state: {
  count: 0
}

コンポーネントからの使い方

this.$store.state.count

ミューテーション Miutation

ステートを変更させるロジック。非同期処理を含めない。

登録の仕方


mutations: {
  mutationName(state, payload) {
    /* ステートを書き換える処理 */
  }
}

payload はオプションの引数。

コミット Commit

ミューテーションを実行する命令。

コンポーネントからの使い方

this.$store.commit('mutationName')

ネームスペースがある場合以下のようにルートから他のモジュールを使用できる。

commit('message/mutationName', null, { root: true })

アクション Action

非同期処理を行うロジック。API との通信して結果をコミットしたりコンポーネントに返すなど。ステートを操作したい場合はここからミューテーションにコミットする。

登録の仕方


actions: {
  actionName({commit, dispatch, state, rootState, getters, rootGetters}, payload) {
    /* コミットするデータを決める処理 */
  }
}

payload はオプションの引数。

コンテキストから多くのプロパティが使用できるので、ステート更新以外のこまごました処理はアクション内で済ませるのがいい。

ディスパッチ Dispatch

アクションを実行する命令。

コンポーネントからの使い方

this.$store.dispatch('actionName')

ネームスペースがある場合以下のようにルートから他のモジュールを使用できる。

dispatch('message/actionName', null, { root: true })

ゲッター Getter

ステートを取得する際の処理。ストア専用の算出プロパティ(computed)的なもの。算出プロパティと違い引数を使えるけどセッターは使えない。

登録の仕方


getters: {
  count(state, getters, rootState) { ... }
}

登録の仕方(引数を使う場合)


getters: {
  count(state) {
    return function(id) {
      return state.list[id].count
    }
  }
}
// アロー関数を使うとスッキリ書ける
getters: {
  count: (state) => (id) => state.list[id].count
}

コンポーネントからの使い方


this.$store.getters.count
this.$store.getters.count(id)

これ以外にもいくつかあったけど、まずはこの辺のルールを押さえておけば大丈夫そう。

ストアを実際に使ってみる

const store = new Vuex.Store({
  state: {
    // 単純なテキストデータ
    message: '初期メッセージ'
  },
  mutations: {
    // メッセージの書き換え
    setMessage(state, payload) {
      state.message = payload
    }
  },
  getters: {
    // message をそのまま使用
    message(state) { return state.message }
  }
})

最初に書いたシンプルなストア構造です。このストアを実際に使用するには直接 store 変数を使用するか、Vue のコンストラクタのオプションで登録しておけば this.$store でどこからでも参照できます。

ルートとコンポーネントも簡単に作ってみましょう。

<div id="app">
  <h1>{{ message }}</h1>
  <cmp-child></cmp-child>
</div>
<script type="text/x-template" id="tple-child">
  <div>
    <input value="新しいメッセージ" ref="input"> <button @click="update">click!</button>        
  </div>
</script>
// ★子コンポーネント
Vue.component('cmp-child', {
  template: '#tple-child',
  methods: {
    update() {
      // ボタンを押したらコミット@ ref を使っているけど勿論データバインドでも良い。
      this.$store.commit('setMessage', this.$refs.input.value)
    }
  }
})
// ★ストアを登録して Vue インスタンスを作成
new Vue({
  store: store,
  el: '#app',
  computed: {
    message() { return this.$store.getters.message }
  }
})

上のメッセージが更新されたと思います。

もし API など非同期処理が含まれる場合はアクションが追加されます。

const store = new Vuex.Store({
  state: {
    // 単純なテキストデータ
    message: '初期メッセージ'
  },
  mutations: {
    // メッセージの書き換え
    setMessage(state, payload) {
      state.message = payload
    }
  },
  actions: {
    // メッセージを API からGET
    getMessage(commit) {
      axios.get('/api/message').then(function (response) {
        // 確定したデータをここからコミットする。引数の commit を使う
        commit('setMessage', response.data.message)
      })
    },
    // 今ある state のメッセージを API にPUT
    saveMessage({commit, state}) {
      axios.put('/api/message', { message: state.message })
    },
  },
  getters: {
    // message をそのまま使用
    message(state) { return state.message }
  }
})

サーバーからデータを取得

this.$store.dispatch('getMessage')

サーバーにデータを送信

this.$store.dispatch('saveMessage')

双方向バインディング

ちなみに上のサンプルの通り Vuex も v-model を使用しない場合単方向データフローなのだけど、ストアのデータはコミット以外から勝手に操作してはいけない決まりがあるので入力と同時に操作してしまう v-model は指定できません。双方向風にするには以下のような方法があります。

<div id="app">
  <cmp-view></cmp-view>
  <cmp-edit></cmp-edit>
</div>
<script type="text/x-template" id="template-view">
  <div>
    <h1>{{ categoryName }}</h1>
    <ul>
      <li v-for="item in items">{{ item.name }} {{ item.price }}円</li>
    </ul>
  </div>
</script>
<script type="text/x-template" id="template-edit">
  <div>
    カテゴリ名:<input type="text" v-model="categoryName" size="10">
    <ul>
      <li v-for="item in items">
        名前:{{ item.name }}
        価格:<input type="text" @input="edit($event, item.id)" :value="item.price" size="3">
      </li>
    </ul>
  </div>
</script>
// ★ストアを作成
var store = new Vuex.Store({
  state: {
    // 単純なテキストデータ
    categoryName: '果物リスト',
    // リストのデータ
    items: [
      { id: 1, name: 'りんご', price: 100 },
      { id: 2, name: 'ばなな', price: 200 },
      { id: 3, name: 'いちご', price: 300 }
    ]
  },
  mutations: {
    // リストの価格を更新
    updatePriceById: function (state, payload) {
      state.items.forEach(function (item, idx) {
        if (item.id === payload.id) {
          state.items[idx].price = payload.price
          return true
        }
      })
    },
    // カテゴリ名を更新
    updateCategoryName: function (state, payload) {
      state.categoryName = payload
    }
  },
  getters: {
    items: function (state) { return state.items },
    categoryName: function (state) { return state.categoryName }
  }
})

// ★編集フォームのコンポーネント
Vue.component('cmp-edit', {
  template: '#template-edit',
  computed: {
    items: function () {
      return this.$store.getters.items
    },
    // 直接書き換える事はできない@セッターを使うとシンプルに書ける
    categoryName: {
      get: function () { return this.$store.getters.categoryName },
      set: function (val) { this.$store.commit('updateCategoryName', val) }
    }
  },
  methods: {
    // リストで computed を使えない場合はこんな感じ
    edit: function (e, id) {
      this.$store.commit('updatePriceById', { id: id, price: e.target.value })
    }
  }
})

// ★リスト表示のコンポーネント
Vue.component('cmp-view', {
  template: '#template-view',
  computed: Vuex.mapState(['categoryName', 'items'])
})

// ★ストアを登録して Vue インスタンスを作成
new Vue({
  store: store,
  el: '#app'
})

v-model を使って単純に文字列や数字を更新したい場合は computed のセッター&ゲッターを使うとシンプルに書く事ができます。

カテゴリ名:<input type="text" v-model="categoryName" size="10">
Vue.component('cmp-edit', {
  computed: {
    categoryName: {
      get: function() { return this.$store.getters.categoryName },
      // セッターで入力時に自動的にコミットします
      set: function(val) { this.$store.commit('updateCategoryName', val) }
    }
  }
})

v-for を使ったリスト内では v-model の代わりに @input を使うといい。

カテゴリ名部分と同じように v-model のセッターゲッターでやりたいなら、配列部分をコンポーネントにしてしまうのもあり。

非同期をアクションに書くメリット

しばしば非同期の処理Aが終わってから処理Bをさせたい事がある。コミットは返り値を返さないのだけどアクションは Promise を返してくれます。

アクション

actions: {
  getMessages({ dispatch }) {
    return dispatch('getMessage', ['A', 300])
      .then(val => dispatch('getMessage', [val + 'B', 100]))
      .then(val => dispatch('getMessage', [val + 'C', 200]))
  },
  getMessage({ dispatch }, [count, delay]) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(count)
      }, delay)
    })
  }
}

コンポーネント

this.$store.dispatch('getMessages').then(result => {
  console.log('成功', result)
}).catch(e => {
  console.log('失敗', e)
})

という感じの非同期処理順の管理ができます。これは上から順に終了させていって全部成功したら成功ログが出ます。非同期処理を全部済ませた状態でコミットするといい。

アクションで何も処理して無くてもとりあえずアクションを通しコンポーネントからの命令を全てディスパッチに統一させるというのも分かりやすくていい。

マッピングヘルパー

ストアのプロパティは this.$store.getters などで参照できるのだけど、基本的にコンポーネントのメソッドに登録して使用する事が多いです。ステートやゲッターは computed、算出プロパティに、ミューテーションやアクションは methods に登録します。数が多い場合は mapGettersmapMutations というマッピングヘルパーを使うとシンプルに書く事ができます。

もし ES6 の記述が出来るならスプレッド演算子「...」を使うと簡単にローカルのメソッドと組み合わせる事ができます。

import { mapGetters, mapActions } from 'vuex'
new Vue({
  el: '#app',
  store: store,
  computed: {
    counter() {  },
    ...mapGetters([
      'message'
    ])
  },
  methods: {...mapActions(['getMessage'])}
})

script で簡単に使用する場合は Vuex.mapGetters()Vuex.mapMutations() という感じでも使えます。

new Vue({
  el: '#app',
  store: store,
  computed: Vuex.mapGetters([
    'message'
  ]),
  methods: Vuex.mapActions(['getMessage'])
})

おわりに

今回は少しだけですけどゲッターとミューテーションをかじってみました(•ө•)ノ

データは全部ストアで持たないといけないわけでもなく、ローカルデータと織り交ぜながら使うといいより感じになりそうですね(๑'ᴗ'๑)

一度理解して状態管理のメリットを体感したなら、Vue 単体とコスパはあまり変わらないので規模に関わらずライトに使ってしまって良いんじゃないかなぁと思います。特に将来的な規模がわからない場合は中規模以上になってから導入するのは面倒なので最初から前提で使うのがオススメ。

さて次回はあまり使っていないカスタムディレクティブについても軽く勉強しておきたいと思います。