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

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

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

ここへ来ると良くかわからない言葉が沢山出てきてつらいですね。

私の場合 Vuex の基本となっている Flux という概念もよく分かっていないためいきなりガッツリしたものを作るのは無理なので徐々に取り入れながら Vue の勉強をしていきたいと思います。

ただ理解するまでは結構だるいので必要と思うまではやらなくていいと思います。共有プロパティを何個か作りたいだけならプラグインを使った方がスマートに出来たりします。

Vuex の一番最初の説明にもこう書いてあるのです。

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

多分このページを見ている jQuery 難民のフレンズもここら辺が辛くなってくると思いますが頑張りましょう。

状態管理について

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

参照先を固定しておくとこの苦行が楽になるというのが状態管理です。簡単に言えば Vue アプリ上に仮想のデータベース(ストア/データ保管庫)を作り、データが必要な時はそれを参照してね、ただしデータを操作する時はストア専用のルートを通ってね!という感じです。

Vuex について

Vuex でよく見る図です。この類の図を見ると吐きそうになるんですけど落ち着いて一個づつ見ていきましょう。

コンポーネントからディスパッチという命令を送るとアクションに繋がってますね。API 等からデータを受け取ったりするのはこの部分みたいです。アクションの結果をコミットという命令でミューテーションに送ってストアが持っているデータを実際に書き換えるのはミューテーションという事みたいです。うぅん初めてだととてもややこしい。

でも Vue 同様に Vuex のマニュアルも翻訳されているのでひととおり読むだけでもだいぶ分かってきました。

ステート State

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

ミューテーション Miutation

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

コミット Commit

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

アクション Action

非同期処理を行うロジック。API との通信など。
ステートを操作したい場合はここからミューテーションにコミットする。
同期処理だけならアクションを通さずに直接コミットでもOK。アクションで何も処理して無くてもとりあえずアクションを通しコンポーネントからの命令を全てディスパッチに統一させるというのもOK。

ディスパッチ Dispatch

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

ゲッター Getter

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

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

マッピングヘルパー

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

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

new Vue({
  el: '#app',
  store: store,
  computed: Vuex.mapGetters([
    'list'
  ]),
  methods: Vuex.mapMutations(['updateItems']),
  components: { 'cmp-editor': cmpEditor },
})

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

import { mapState, mapMutations } from 'vuex'
new Vue({
  el: '#app',
  store: store,
  computed: {
    counter() {  },
    ...mapState([
      'list'
    ])
  },
  methods: {...mapMutations(['updateItems'])},
  components: { 'cmp-editor': cmpEditor },
})

ストアのデータをフォームで編集する

Vuex は ES6 の Promise を使っているので IE では Polyfill が必要です。

<div id="app">
  <cmp-view></cmp-view>
  <cmp-edit></cmp-edit>
</div>
<script type="text/x-template" id="template-view">
  <div>
    <p>{{ categoryName }}</p>
    <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, id) in items">
        名前:{{ item.name }}
        価格:<input type="text" @input="edit($event, id)" :value="item.price" size="3">
      </li>
    </ul>
  </div>
</script>
// ストアを作成
var store = new Vuex.Store({
  state: {
    // 単純なテキストデータ
    categoryName: '果物',
    // リストのオブジェクトデータ
    items: {
      1: { name: 'りんご', price: 100 },
      2: { name: 'ばなな', price: 200 },
      3: { name: 'いちご', price: 300 }
    }
  },
  mutations: {
    // リストの価格を更新
    updatePriceById: function(state, payload) {
      state.items[payload.id].price = payload.price
    },
    // カテゴリ名を更新
    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'
})

フォームの値を双方向バインディングさせたい場合は入力したタイミングでストアに commit してデータを更新します。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 を使うと良いです。

<li v-for="(item, id) in items">
  名前:{{ item.name }}
  価格:<input type="text" @input="edit($event, id)" :value="item.price" size="3">
</li>
Vue.component('cmp-edit', {
  methods: {
    edit: function(e, id) {
      this.$store.commit('updatePriceById', { id: id, price: e.target.value })
    }
  }
})

どーしても v-model が使いたい場合は色々試してみましたがコンポーネントに分けるのが一番手っ取り早そうでした。

<script type="text/x-template" id="template-edit">
  <ul>
    <cmp-edit-item v-for="(item, id) in items" :id="id" :item="item" :key="id"></cmp-edit-item>
  </ul>
</script>
<script type="text/x-template" id="template-edit-item">
  <li>
    名前:{{ item.name }}
    価格:<input type="text" v-model="price" size="3">
  </li>
</script>
var store = new Vuex.Store({
  state: {
    items: {
      1: { name: 'りんご', price: 100 },
      2: { name: 'ばなな', price: 200 },
      3: { name: 'いちご', price: 300 }
    }
  },
  mutations: {
    // リストの価格を更新
    updatePriceById: function(state, payload) {
      state.items[payload.id].price = payload.price
    }
  },
  getters: {
    items: function(state) { return state.items }
  }
})

// 編集フォームのアイテムコンポーネント
Vue.component('cmp-edit-item', {
  props: { item: Object, id: String },
  template: '#template-edit-item',
  computed: {
    price: {
      get: function() { return this.item.price },
      set: function(val) { this.$store.commit('updatePriceById', { id: this.id, price: val }) }
    }
  }
})

// 編集フォームのコンポーネント
Vue.component('cmp-edit', {
  template: '#template-edit',
  computed: Vuex.mapState(['items'])
})

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

new Vue({
  store: store,
  el: '#app'
})

更新を遅延するフォーム

今日書いていた「遅延して反映させるフォーム」というのを Vuex を使って書き直してみました。カテゴリ → 子アイテムリストという2階層のデータを持っていて、カテゴリ編集はカテゴリ内のアイテムを一度に入力でき、決定ボタンを押したタイミングで反映する動きにしたいと思います。

var store = new Vuex.Store({
  state: {
    list: [
      {
        key: 'fruits',
        name: '果物',
        items: [
          { id: 1, name: 'りんご', price: 100 },
          { id: 2, name: 'ばなな', price: 200 },
          { id: 3, name: 'いちご', price: 300 },
        ]
      },
      {
        key: 'vegetables',
        name: '野菜',
        items: [
          { id: 1, name: 'とまと', price: 10 },
          { id: 2, name: 'きゅうり', price: 20 },
        ]
      }
    ]
  },
  mutations: {
    updateItems(state, catdata) {
      var idx = _.findIndex(state.list, function(o) {
        return o.key === catdata.key
      })
      if (idx !== -1) {
        state.list[idx].items = _.cloneDeep(catdata.items)
      }
    }
  },
  getters: {
    category: function(state) {
      return function(key) {
        var idx = _.findIndex(state.list, function(o) {
          return o.key === key
        })
        if (idx !== -1) return state.list[idx]
      }
    }
  }
})

// 編集フォームアイテム
var cmpEditorItem = Vue.extend({
  template: '#cmp-editor-item',
  props: { 'item': Object },
  computed: {
    name: {
      get: function() { return this.item.name },
      set: function(val) { this.$emit('update-name', val) }
    },
    price: {
      get: function() { return this.item.price },
      set: function(val) { this.$emit('update-price', val) }
    }
  }
})
// エディター
Vue.component('cmp-editor', {
  template: '#cmp-editor',
  props: ['editid'],
  data: function() {
    return {
      intarnal: null
    }
  },
  created() {
    if (this.editid !== null) {
      this.intarnal = _.cloneDeep(store.getters.category(this.editid))
    }
  },
  components: { 'cmp-editor-item': cmpEditorItem }
})

// ルート
new Vue({
  el: '#app',
  store: store,
  data: {
    editid: null
  },
  computed: Vuex.mapState(['list']),
  methods: {
    updateItems: function(category) {
      // 確定したデータをスコアにコミット
      this.$store.commit('updateItems', category)
      // 編集ウィンドウを閉じる
      this.editid = null
    }
  }
})
<!-- app -->
<div id="app">
  <div class="rows">
    <div>
      <div class="cmpname">cmp-list</div>
      <ul>
        <li v-for="cat in list" :cat="cat" :key="cat.id">
          カテゴリ: {{ cat.name }} <button @click="editid=cat.key">編集</button>
          <ul>
            <li v-for="item in cat.items" :key="item.id">{{ item.name }} {{ item.price }} 円</li>
          </ul>
        </li>
      </ul>
    </div>
    <div>
      <cmp-editor @update="updateItems" @close="editid=null" :editid="editid" :key="editid"></cmp-editor>
    </div>
  </div>
</div>
<!-- cmp-editor テンプレート -->
<script type="text/x-template" id="cmp-editor">
  <div class="cmp-editor" v-if="intarnal">
    <div class="cmpname">cmp-editor</div>
    <div>編集: {{ intarnal.name }}</div>
    <ul>
      <li v-for="item in intarnal.items">{{ item.name }} / {{ item.price }}</li>
    </ul>
    <ul>
      <cmp-editor-item v-for="item in intarnal.items" :item="item" :key='item.id' @update-name='item.name=$event' @update-price='item.price=$event'></cmp-editor-item>
    </ul>
    <button @click="$emit('update', intarnal)">決定</button> <button type="button" @click="$emit('close')">キャンセル</button>
  </div>
</script>
<!-- cmp-editor-item テンプレート -->
<script type="text/x-template" id="cmp-editor-item">
  <li>
    <div class="cmpname">cmp-editor-item</div>
    名前 <input type="text" size="10" v-model="name"> 価格 <input type="text" size="3" v-model.number="price">
  </li>
</script>

cmp-editor-item をコンポーネントにしないで cmp-editor のスコープ内に収めるともう少しシンプルになるのですが、基本的にこう言う場合ってもっとごちゃごちゃしてきてコンポーネント化したい!となるはずなのでコンポーネント前提で作っています。

cmp-editor 以下は editid が更新されたタイミングで内部データでクローンを作りその内部データを子の cmp-ediitor-item に渡しています。cmp-ediitor-itemからは $emit を使っています。実際この範囲なら全部 $emit だけで十分ですが、簡単なコードで色んなパターンを書くのも勉強になります。

実際にサーバーのデータベースに保存したい場合は、決定ボタンをディスパッチに変えてバックエンドが成功したらフロントにコミットしてウィンドウを閉じるという感じになると思います。

まとめ

今回は少しだけですけどゲッターとミューテーションをかじってみました(•ө•)ノ ステートを参照する場合は単純に返す場合もゲッターを通した方が良いんでしょうか?

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

次はアクションも使ってきたいなーと思います。