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

更新日
2017.07.01
作成日
2017.04.24

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

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

出来上がったデモ

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

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

全体のソースコードはここのリポジトリにあります。

使用ライブラリ

Vue 2.x と Vuex の他に便利ユーティリティ lodash と Ajax 用ライブラリに axios を使いました。axios や Vuex では Promise が使われているんですが IE では使えないので ES6-Promise のオートポリフィルも読み込んでおきます。

vue-cli の webpack テンプレートを使ってプロジェクトを作成してフォルダ構成はデフォルトです。

/src/main.js

import 'es6-promise/auto'

WebAPIのモック

デモは公式のショッピングカートと同じで JavaScript 内で擬似的に処理するモックの API です。エラーのレスポンスは考えてないのでとりあえず status だけで判断します。

データベース

{
  "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 を使ってるので覚えるためになるべく ES6 の記述方法も使うようにします。Vuex は this を使うことがほぼ無いのでアロー関数も気にしないで使えます。

/src/api/member.js

// 成功の処理
const apiSuccess = (response) => {
  return demoTimer().then(() => {
    if (response.data.status === true) {
      return response.data.entry
    } else {
      return Promise.reject(response.data)
    }
  })
}

// 失敗の処理
const apiError = (error) => Promise.reject(error.message || 'ERROR')

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

とりあえず一覧取得、編集、追加の3つだけ動作します。レスポンスのフォーマットは全部同じで結果のデータは entry に入っているので、成功時は entry 部分かエラーを Vuex に返すようにしました。demox はデモ用関数なので実際は axios とか使います。

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

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

ストアのモジュール化

コンポーネント化と同じような感じで、ステート、ミューテーション、アクションなどを分離する事ができます。ネームスペースを設定しておくとかぶりを気にしないで名前を使えるので便利。ネストも出来るので表示用と編集用とかに分けても良さそう。

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

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

メンバーデータ操作の処理をモジュールに分離して作ります。

/src/vuex/modules/member.js

import Vue from 'vue'
// 先に作ったAPIモジュールを使う
import api from '@/api/member'
// lodashの特定のメソッドだけ使う
import { orderBy, findIndex, find } from 'lodash'

const member = {
  // ネームスペースを利用する
  namespaced: true,
  state: {
    members: [],
    error: null
  },
  getters: {
    // エラーメッセージを返す
    error: (state) => state.error,
    // メンバーリストを引数の項目でソートして返す
    orderList: (state) => (field) =>
      orderBy(state.members, field, 'asc'),
    // メンバーリストのメンバーIDから配列インデックスを返す
    findIndexById: (state) => (id) =>
      findIndex(state.members, o => o.id === id),
    // メンバーリストのメンバーIDからメンバー内容を返す
    findMemberById: (state) => (id) =>
      find(state.members, o => o.id === id)
  },
  mutations: {
    // メンバーリストを更新
    update(state, payload) {
      const idx = findIndex(state.members, o => o.id === payload.id)
      Vue.set(state.members, idx, payload)
    },
    // メンバーリストをセット
    setList(state, members) {
      state.members = members
    },
    // メンバーリストに追加
    add(state, payload) {
      state.members.push(payload)
    },
    // エラーメッセージをセット
    setError(state, msg) {
      state.error = msg
    },
    // エラーメッセージをリセット
    resetError(state) {
      state.error = null
    },
    // メンバーリストを破棄
    destroy(state) {
      state.members = []
    }
  },
  actions: {
    // 全メンバーを読み込む
    load({ commit }) {
      return api.getMembers().then(entry => {
        commit('setList', entry)
      })
    },
    // メンバーを保存
    save({ commit }, member) {
      // IDが-1なら追加
      const type = member.id === -1 ? api.postMember : api.putMember
      return type(member.id, member).then(entry => {
        // サーバー側で成功したらフロント側のデータを更新
        if (member.id === -1) {
          commit('add', entry)
        } else {
          commit('update', entry)
        }
      }).catch(error => {
        // サーバー側で失敗したらエラーをセット
        commit('setError', error)
      })
    }
  }
}
export default member

※ パスの @ マークは src のエイリアスです。

アクションには API の選択や受け取り方法を書いて、ストアに反映させる内容をアクションからミューテーションにコミットします。

ゲッターは関数を返すことで引数を持たせてステートの検索など結果の操作が出来るので、コンポーネントでは必要なデータを受け取るだけにしておくと同じデータを扱う各コンポーネント側の処理を減らせます。

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

/src/vuex/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/components/Control/MemberList.vue

<template>
  <div id="control-member-list">
    <h1>Members</h1>
    <ul>
      <li v-for="val in orderList('lv')">
        <div class="name">{{ val.name }}</div>
        <div class="lv">Lv.{{ val.lv }}</div>
        <div class="control">
          <button @click="editid=val.id">編集</button>
        </div>
      </li>
      <li class="loading" v-if="loading">loading</li>
    </ul>
    <div class="add">
      <button @click="editid=-1">追加</button>
    </div>
    <control-member-modal :editid="editid" @close="editid=null"></control-member-modal>
  </div>
</template>
<script>
import { mapGetters } from 'vuex'
import ControlMemberModal from './MemberModal'
export default {
  name: 'control-member-list',
  data() {
    return {
      loading: true,
      editid: null
    }
  },
  computed: {
    ...mapGetters('member', [
      'orderList'
    ])
  },
  created() {
    // 作成時にメンバーリストを取得
    this.$store.dispatch('member/load').then(() => {
      this.loading = false
      console.log('取得完了')
    }).catch(e => {
      console.log(e)
    })
  },
  destroyed() {
    this.$store.dispatch('member/destroy')
  },
  components: { ControlMemberModal }
}
</script>

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

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

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

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

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

this.orderList('name')

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

/src/components/Control/MemberModal.vue

<template>
  <transition name="modal">
    <div class="modal" v-if="editid!=null" @click.self="$emit('close')">
      <div class="body">
        <h1>{{ title }}</h1>
        <button type="submit" style="display:none"></button>
        <transition name="modal">
          <div v-if="error" class="error" @click.self="$store.commit('item/resetError')">{{ error }}</div>
        </transition>
        <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'
export default {
  name: 'control-member-modal',
  props: { editid: Number },
  data() {
    return {
      intarnal: {}
    }
  },
  computed: {
    title() { return this.editid === -1 ? '追加' : '編集' },
    ...mapGetters('member', [
      'findMemberById',
      'error'
    ])
  },
  watch: {
    editid() {
      // IDがnull以外ならメンバーデータをクローンする
      if (this.editid != null) {
        // デフォルトデータの作成はストアや別モジュールに書いたほうが良さげかも?
        this.intarnal = Object.assign({
          id: -1,
          name: '',
          lv: ''
        }, this.findMemberById(this.editid))
      }
    }
  },
  methods: {
    // 保存ボタン&サブミットで内部データをストアに送る
    onSaveMember() {
      this.$store.dispatch('member/save', this.intarnal).then(() => {
        // 結果にエラーが無ければウィンドウを閉じる
        // エラーがあればメッセージとして表示
        if (!this.error) this.$emit('close')
      })
    }
  }
}
</script>

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

親のリスト用のコンポーネントを適当なページにつっこんでおわりです\(*⁰▿⁰*)/

まとめ

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

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

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