Vue.js と Vuex を使った簡単なメンバー管理フォームを作る

最終更新日
2018.03.05

Vuex と WebAPI を使ってメンバー管理フォームのコンポーネントを作ります。

以前 Vuex を軽く触ってみましたが、もう少し実践的な Vuex のお勉強をしていこうと思います。

出来上がったデモ

ギルド管理ツールのメンバー操作画面なイメージです。 メンバーの一覧表示と、入力フォームはモーダルウィンドウで開くようにしました。

※このデモはサーバー処理していないので固定したデータしか返せません。

全体のソースコードはここのリポジトリにあります。(ちょっと古いのでリファクタリングしたい) Vue Router を使ったリポジトリと統合してしまったので、若干ディレクトリの構造やファイル名が違う部分があります。

使用ライブラリ

  • Vue.js 2.5.13
  • Vuex 2.5.0
  • Lodash 4.17.5
  • es6-promise 4.2.4

Vue CLI の webpack テンプレートを使ってプロジェクトを作成して基本的にデフォルトのままです。 Vuex や axios では ES2015 の Promise を使用するため、残念な IE のためにポリフィルを読み込みます。

src/main.js

import 'es6-promise/auto'

WebAPI のモック

デモは JavaScript で擬似的に処理するモックの API です。 実際には axios とかを使うことになると思います。 エラーのレスポンスはあまり考えてないのでとりあえず status だけで判断しています。

データベース(JSON)

{
  "members": [
    { "id": 1, "name": "サーバル", "lv": 30, "sort": 2 },
    { "id": 2, "name": "フェネック", "lv": 20, "sort": 3 },
    { "id": 3, "name": "アライさん", "lv": 10, "sort": 1 }
  ]
}

データの構造はだいたいこんな感じです。

API 用のモジュールを作る

参考に Vuex のドキュメントにあるアプリケーションの構造化とサンプルのショッピングカートを見ながら作っていきます(•ө•)ノ

折角 Babel を使ってるので、覚えるためになるべく ES2015 も使うようにしました。 Vuex は this を使うことが無いので、メソッドの定義にもアロー関数を使えます。 とりあえず一覧取得、編集、追加、削除を実装しました。 だいぶ省略していますがこんな感じです。

export default {
  getMembers: () =>
    demox.get('/vue-test/api/member/list').then(apiSuccess).catch(apiError),
  postMember: (id, item) =>
    demox.post('/vue-test/api/member', { item }).then(apiSuccess).catch(apiError),
  putMember: (id, item) =>
    demox.put(`/vue-test/api/member/${id}`, { item }).then(apiSuccess).catch(apiError),
  deleteMember: (id) =>
    demox.delete(`/vue-test/api/member/${id}`, { id }).then(apiSuccess).catch(apiError)
}

こういった実装は抽象化させることで、突然「やっぱり別のストレージ使おう!」となった場合でも修正コストが減らせるようです。

ストアのモジュールを作る

小規模だとただ面倒なだけになりそうなので、恩恵を感じるようになるまではミューテーションタイプ定数は使わない方向でいきます。 モジュールは商品やカテゴリなどの別の情報が2~3個増えただけでも分けたほうが良さそうに思えたのでどんどん使ってこうと思います。

コンポーネントと同じような感じで、ステート、ミューテーション、アクションなどを分離する事ができます。 ネームスペースを設定しておくと名前のかぶりを気にせずに使えるので便利。

store.dispatch('item/update')
store.dispatch('user/update')

メンバーデータ用の Vuex モジュール

メンバーデータ操作の処理をモジュールに分離して作ります。 結構長くなってしまいましたスミマセン。

src/store/modules/member.js

// 先に作ったAPIモジュールを使う
import api from '@/api/member'
// Lodash
import orderBy from 'lodash/orderBy'
import find from 'lodash/find'

export default {
  // ネームスペースを利用する
  namespaced: true,
  state: {
    editId: null, // 編集中のID
    members: []
  },
  getters: {
    // 編集中のIDを返す
    editId: state => state.editId,
    // 編集中の要素を返す
    editMember: state => {
      if (state.editId !== -1) {
        return find(state.members, o => o.id === state.editId)
      } else {
        return {
          id: -1,
          name: 'noname',
          lv: 10
        }
      }
    },
    // メンバーリストを引数の項目でソートして返す
    orderList: state => field => orderBy(state.members, field, 'asc'),
    // メンバーリストのメンバーIDからメンバー内容を返す
    findMemberById: state => id => find(state.members, o => o.id === id)
  },
  mutations: {
    // 編集中のIDをセット
    setEditId(state, { id }) {
      state.editId = id
    },
    // メンバーリストをセット
    setList(state, { members }) {
      state.members = members
    },
    // メンバーを追加
    add(state, { newdata }) {
      state.members.push(newdata)
    },
    // メンバーを更新
    update(state, { member, newdata }) {
      member.name = newdata.name
      member.lv = newdata.lv
    },
    // メンバーを削除
    delete(state, { id }) {
      state.members = state.members.filter(el => el.id !== id)
    },
    // メンバーリストを破棄
    destroy(state) {
      state.members = []
    }
  },
  actions: {
    // 全メンバーを読み込む
    load({ commit }) {
      return api.getMembers().then(members => {
        commit('setList', { members })
      }).catch(error => {
        commit('toast/add', error, { root: true })
      })
    },
    // 編集を開始
    doEdit({ commit, getters }, id) {
      commit('setEditId', { id })
    },
    // メンバーを保存
    doSave({ commit, getters }, newdata) {
      // IDが-1なら追加
      if (newdata.id === -1) {
        return api.postMember(newdata.id, newdata).then(newdata => {
          commit('add', { newdata })
        }).catch(error => {
          commit('toast/add', error, { root: true })
        })
      } else {
        return api.putMember(newdata.id, newdata).then(newdata => {
          const member = getters.findMemberById(newdata.id)
          commit('update', { member, newdata })
        }).catch(error => {
          commit('toast/add', error, { root: true })
        })
      }
    },
    // メンバーを削除
    doDelete({ commit, dispatch }, id) {
      api.deleteMember(id).then(entry => {
        commit('delete', { id })
      })
    }
  }
}

アクションではデータをチェックしたり加工したり API の選択や受け取り方法などを書いて、あとはステートに反映させるだけぐらいの勢いでミューテーションにコミットします。 そこまでアクション内で全部済ませる事に拘らなくても良いと思いますが、アクションが Promise を返せる点とゲッターや他のアクションなど使えるものが豊富なのでアクションで作業する方が何かと楽です。

ゲッターは算出プロパティと同じように関数を返すことで、パラメータを持たせてステートの検索など結果の操作ができます。 コンポーネントでは必要なデータを受け取るだけにしておくと同じデータを扱う各コンポーネント側の処理を共通化できます。

モジュールをストアに登録

src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'
import member from './modules/member'
Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    member
  }
})
export default store

これで関係ないデータが増えてきても混沌とせずに済むのですよ~(๑'ᴗ'๑)

リスト表示のコンポーネントを作る

src/views/Member/List.vue

<template>
  <div class="members">
      <ul class="list">
        <li v-for="{ id, name, lv } in orderList" :key="id" class="row">
          <div class="cel name">[{{ id }}] {{ name }}</div>
          <div class="cel lv">Lv.{{ lv }}</div>
          <div class="cel control">
            <button type="button" @click="doDelete(id)">削除</button>
            <button type="button" @click="doEdit(id)">編集</button>
          </div>
        </li>
      </ul>
    <div class="add">
      <button type="button" @click="doEdit(-1)">追加</button>
    </div>
    <MemberModal @close="doEdit(null)" v-if="editId!=null" />
  </div>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'
import MemberModal from '@/components/Member/Modal.vue'
export default {
  name: 'MemberList',
  components: { MemberModal },
  computed: {
    ...mapGetters('member', ['editId']),
    orderList() {
      return this.$store.getters['member/orderList']('sort')
    }
  },
  methods: {
    ...mapActions('member', ['doEdit', 'doDelete'])
  }
}
</script>

今回はリストとモーダルウィンドウは親子関係のコンポーネントなので、編集IDは親のリストコンポーネントが所持して props で子のモーダルウィンドウに渡す事にします。コンポーネントでは編集中のIDとローディング中のフラグデータのみ所持して、それ以外のメンバーデータはストアに格納しています。

マッピングヘルパーを使ってストアとコンポーネントのデータを同期する

特に複数のステートをピックアップして使いたい場合はマッピングヘルパーを使うと便利です。 ネームスペースを使用している場合は1つ目の引数として指定します。 ゲッターだけじゃなくアクションやミューテーション用のヘルパーもあります。

computed: {
  ...mapGetters('member', [
    'orderList'
  ])
}

orderList はソート項目の引数付きのゲッターなので、コンポーネント内で次のように使用できるようになります。

this.orderList('name')

編集モーダルウィンドウのコンポーネントを作る

src/components/Member/Modal.vue

<template>
  <transition name="modal">
    <div id="member-modal" @click.self="$emit('close')">
      <div class="body">
        <h1>{{ title }}</h1>
        <dl>
          <dt>名前</dt>
          <dd><input v-model="intarnal.name"></dd>
          <dt>レベル</dt>
          <dd><input v-model.number="intarnal.lv" size="3"></dd>
        </dl>
        <footer>
          <div class="left">
            <button type="button" @click="$emit('close')">閉じる</button>
          </div>
          <div class="right">
            <button type="button" @click="onSaveMember">保存する</button>
          </div>
        </footer>
      </div>
    </div>
  </transition>
</template>
<script>
import { mapGetters } from 'vuex'
import cloneDeep from 'lodash/cloneDeep'
export default {
  name: 'MemberModal',
  props: { editid: Number },
  data() {
    return {
      intarnal: {}
    }
  },
  computed: {
    title() {
      return this.editid === -1 ? '追加' : 'クイック編集'
    },
    ...mapGetters('member', ['editMember'])
  },
  methods: {
    // 保存ボタン&サブミットで内部データをストアに送る
    onSaveMember() {
      this.$store.dispatch('member/doSave', this.intarnal).then(() => {
        // 結果にエラーが無ければウィンドウを閉じる
        // エラーがあればメッセージとして表示
        if (!this.error) this.$emit('close')
      })
    }
  },
  created() {
    // 初期化で状態を一時的にコピーする
    this.intarnal = cloneDeep(this.editMember)
  }
}
</script>

リストの編集ボタンを押すとモーダルウィンドウが開いてそこから内容を追加&編集出来るようにしました。 編集画面は別ページに飛ばすパターンも作ってみたい。

キャンセルボタンを押した時にストアに反映しないようにするために、一時データとしてストアのデータからコピーするようにしました。

親のリスト用のコンポーネントを適当なページに組み込んでおわりです(๑'ᴗ'๑)

おわりに

Vuex の例の図を最初に見た時は凄く大変そうだなぁーと思ってましたが、書いてくるうちに Vuex を使うのにもだんだん馴染めてきた感じがします。 たのしいですね!

今回は非同期処理があるのでアクション機能も結構いましたが、このへんまで来るとサーバー側の処理も絡めて書いたほうが良くなってきたかもです。 私はサーバーサイドの開発は PHP しか出来なくて普段はフレームワークに FuelPHP を使っているんですけど、Vue が標準搭載されているのでブログ的にも Laravel を一緒に勉強していこうかなーと考えています。

次回は Vue Router を使って SPA とページ遷移アニメーションを試します(๑'ロ')۶