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

最終更新日
2018.03.03

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

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

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

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

私は Vuex の基本となっている Flux という概念もよく分かっていなかったのでゼロからでしたが、学習コスト的には恐らくコンポーネントと親子関係の範囲と同じぐらいです。 基本的な扱い方にだけならそこまで大きくありません。 言い換えれば、$emitを理解できていればVuexについてもスムーズに理解できると思います。

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

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

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

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

状態管理について

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

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

Vuex について

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

コンポーネントから 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 は使用できません。 双方向データバインディングするには、次のように算出プロパティのセッターを使うとシンプルに書くことができます。

<input type="text" v-model="message" size="10">
Vue.component('cmp-edit', {
  computed: {
    message: {
      get() { return this.$store.getters.message },
      // セッターで入力時に自動的にコミットする
      set(val) { this.$store.commit('setMessage', val) }
    }
  }
})

これを使ってもうちょっと複雑な状態管理をしてみましょう。

<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: parseInt(e.target.value,10) })
    }
  }
})

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

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

v-for を使ったリスト内では v-model の代わりに @input を使うか、要素をコンポーネントにしてしまえば v-model を使用できます。

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

たとえば「APIの応答を待って成功したらモーダルウィンドウを閉じたい」といった非同期の処理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 というマッピングヘルパーを使うとシンプルに書くことができます。

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

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 単体とコスパはあまり変わらないので規模に関わらずライトに使ってしまって良いんじゃないかなぁと思います。 特に将来的な規模がわからない場合は中規模以上になってから導入するのは面倒なので最初から前提で使うのがオススメ。

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