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

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の結果のモック

デモは実際に動かない結果だけ返す静的な JSON になってますが、リアリティを出すために 500ms の遅延を入れてます。エラーのレスポンスはまだ考えてないのでとりあえず status でエラーかどうかだけ判断します。

GET リストの取得の結果

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

POST 追加の結果 新しく登録されたIDとか

{
  "status": true,
  "entry": { "id": 4, "name": "かばんちゃん", "lv": 300, "sort": 0 }
}

PUT 編集の結果 実際には更新日等サーバー側の操作を含めた結果を返したいところ

{
  "status": true,
"entry": {}
}

バリデーションとかでエラーの場合

{
  "status": false,
"message": "エラーだよ"
}

通信成功時の想定している内容はこんな感じです。

API 用モジュールを作る

参考に Vuex のドキュメントにあるアプリケーションの構造化とサンプルのショッピングカートを見ながら作っていきます(•ө•)ノ 折角 Babel を使ってるので覚えるために ES6 の記述方法もなるべく使うようにします。Vuex は this を使うことがほぼ無いのでアロー関数も気にしないで使ってしまって良さそうです。

/src/api/member.js

import axios from 'axios'

// 疑似遅延用タイマー
const demoTimer = () => {
  return new Promise((resolve) => {
    setTimeout(resolve, 500)
  })
}

// 成功の処理
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: () =>
    axios.get('api/item/list.json').then(apiSuccess).catch(apiError),
  putMember: (item) =>
    axios.put('api/item/put.php', { item: item }).then(apiSuccess).catch(apiError),
  postMember: (item) =>
    axios.post('api/item/post.php', { item: item }).then(apiSuccess).catch(apiError)
}

とりあえず一覧取得、編集、追加の3つだけです。デモは個別のファイルですが実際はメソッドで振り分けるとかします。全部同じフォーマットで結果のデータは entry に入っているので、成功時は entry 部分かエラーを Vuex に返すようにしました。

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

小規模だとただ面倒なだけになりそうなので、恩恵を感じるようになるまではミューテーションタイプ定数は使わない方向でいきます。モジュールは商品やカテゴリなどの別の情報が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,
    // 引数の項目でソートして返す
    orderMembers: (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: {
    // 全メンバーを代入
    setMembers(state, members) {
      state.members = members
    },
    // メンバーを更新
    updateMember(state, payload) {
      const idx = findIndex(state.members, o => o.id === payload.id)
      Vue.set(state.members, idx, payload)
    },
    // メンバーを追加
    addMember(state, payload) {
      Vue.set(state.members, state.members.length, payload)
    },
    // メンバーリストを破棄
    destroy(state) {
      state.members = []
    },
    // エラーメッセージをセット
    setError(state, msg) {
      state.error = msg
    },
    // エラーメッセージをリセット
    resetError(state) {
      state.error = null
    }
  },
  actions: {
    // 全メンバーを読み込む
    loaded({ commit }) {
      return api.getMembers()
        .then(entry => {
          commit('setMembers', entry)
        })
    },
    // メンバーを保存する
    saveMember({ commit }, member) {
      // IDが-1なら追加
      const type = member.id === -1 ? api.postMember : api.putMember
      return type()
        .then(entry => {
          // サーバー側で成功したらフロントのデータを更新する
          if (member.id === -1) {
            commit('addMember', entry)
          } else {
            commit('updateMember', member)
          }
        })
        .catch(error => {
          // サーバー側で失敗したらエラーをセット
          commit('setError', error)
        })
    }
  }
}

export default member

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

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

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

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

/src/vuex/index.js

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import member from './modules/member'
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 orderMembers('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> ←かばんちゃんだけ1回のみ正常に追加出来るボタン
    </div>
    <control-member-modal :editid="editid" @close="editid=null"/>
  </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', [
        'orderMembers'
      ])
    },
    created() {
      // 作成時にメンバーリストを取得
      this.$store.dispatch('member/loaded').then(() => {
        this.loading = false
        console.log('取得完了')
      }).catch(e => {
        console.log(e)
      })
    },
    destroyed() {
      this.$store.dispatch('member/destroy')
    },
    components: { ControlMemberModal }
  }

</script>
<style scoped>
  #control-member-list {
    margin: auto;
    max-width: 500px;
  }
  h1 {
    font-family: 'Georgia';
  }
  .loading {
    display: block;
    background: #eee;
    text-align: center;
  }
  ul {
    position: relative;
    overflow: hidden;
    margin: 0;
    padding: 0;
    border: 2px solid #ddd;
    border-radius: 4px;
    list-style: none;
  }
  li {
    display: flex;
    padding: 10px;
    justify-content: space-between;
    align-items: center;
  }
  li:hover {
    background: #f8f8f8;
  }
  li:not(:first-child) {
    border-top: 1px solid #ddd;
  }
  .name {
    flex: 1;
  }
  .lv {
    width: 50px;
  }
  .control {
    width: 100px;
    text-align: right;
  }
  .add {
    margin-top: 10px;
  }

</style>

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

マッピングオブジェクトを使ってストアのデータを使う

特に複数のデータをピックアップして使いたい場合はマッピングオブジェクトを使うと便利!ネームスペースを使用している場合は引数1に指定します。

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

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

this.orderMembers('name')

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

/src/components/Control/MemberModal.vue

<template>
  <transition name="modal">
    <div id="control-modal" v-if="editid!=null" @click.self="$emit('close')">
      <div class="body">
        <h1>{{ title }}</h1>
        <form @submit.prevent="onSaveMember">
          <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>
        </form>
        <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/saveMember', this.intarnal).then(() => {
          // 結果にエラーが無ければウィンドウを閉じる
          // エラーがあればメッセージとして表示
          if (!this.error) this.$emit('close')
        })
      }
    }
  }

</script>
<style scoped>
  #control-modal {
    position: fixed;
    top: 0;
    left: 0;
    display: flex;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, .4);
    justify-content: center;
    align-items: center;
  }
  h1 {
    margin: 0;
    padding: 5px 10px;
    background: #e2f1f3;
    font-size: 14px;
  }
  footer {
    display: flex;
    margin: 0;
    padding: 10px;
    background: #f5f5f5;
    justify-content: space-between;
  }
  dl {
    margin: 0;
  }
  dl::after {
    display: table;
    clear: both;
    content: " ";
  }
  dt {
    float: left;
    clear: left;
    margin: 0;
    padding: 5px;
    width: 20%;
  }
  dd {
    float: left;
    margin: 0;
    padding: 5px;
  }
  .error {
    padding: 6px 10px;
    border-radius: 2px;
    background: #ffe4e4;
    color: #d40000;
  }
  .body {
    width: 400px;
    background: #fff;
  }
  form {
    padding: 10px;
  }
  .modal-enter-active,
  .modal-leave-active {
    transition: opacity .4s;
  }
  .modal-enter,
  .modal-leave-to {
    opacity: 0;
  }
  .modal-enter-active .body,
  .modal-leave-active .body {
    transition: opacity .3s ease, transform .3s ease;
  }
  .modal-enter .body,
  .modal-leave-to .body {
    opacity: 0;
    transform: translateY(-100px);
  }

</style>

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

モーダルのフッター部分に削除ボタンも含める予定なのでフォーム外にサブミットボタンを置こうとしたんですが、IEでは form 属性が使えないようなのでボタンとフォーム両方にイベントを設定してます。そしてサブミットボタンがないとエンターを押したときにサブミット判定にならないので display:none でダミーのボタンを仕込んでます。(他にいい方法あれば教えてください(´•ω•`))

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

まとめ

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

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

ずっとロジック関係の勉強ばかりなので、次回はトランジションや UI キットを使ってデザイン面のあれやこれやしたいと思います(๑'ロ')۶