さて、今回はVuexによるデータ・キャッシュの実装だ。一度データベースから読み込んだデータをフロントエンド側で再利用可能なストアに格納することで、データベースへの接続回数を減らしてシステム負荷を下げるとともに、UX的なパフォーマンス向上もあわせて実現していく。
 まぁ、本来Vuexはコンポーネント間でのデータのやり取りを簡素化するための状態管理の仕組みで、ユーザーのセッション情報などを引き回す際に有効なものだ。今回のアプリでも、ユーザー毎に装備や装飾品の設定状態を持ち回る必要があるので、それに追加してマスターデータもキャッシュしてしまおうと考えている。

Vuexの導入

 では早速、Vuexをインストールしよう。

yarn add vuex

 次に、Vuexのストアを作成する。プロジェクトルート下にsrc/storeディレクトリを新たに作成して、設定ファイルを作る。

mkdir src/store
cd src/store
touch index.js

 設定ファイルsrc/store/index.jsの中身はとりあえず下記のようにする。これは、前章で作成したスキル一覧でバックエンドから取得したスキルデータをストアできる項目skillsを定義してみたものだ。

import Vue from "vue"
import Vuex from "vuex"

Vue.use(Vuex)

const state = {
  skills: []
}

const store = new Vuex.Store({
  state,
})

export default store

 最後に、Vuexのストア設定をVue.jsに読み込む。src/main.jsへ下記のように追加する。

import Vue       from 'vue'
import App       from './App.vue'
import router    from './router'
import store     from './store'// <- 追加
import axios     from 'axios'
import VueAxios  from 'vue-axios'
import vuetify   from './plugins/vuetify'
import functions from './plugins/functions'
import './styles/mhrss.scss'
import './styles/mhrssi.scss'

Vue.config.productionTip = false

Vue.use(VueAxios, axios)

Vue.mixin(functions)

new Vue({
  router,
  store,// <- 追加
  vuetify,
  render: h => h(App)
}).$mount('#app')

 これで、Vuexの導入は完了だ。

Vuexストアを使う

 早速、Vuexストアを使ってデータキャッシュを実装してみよう。前章で作ったスキル一覧のコンポーネントSkillList.vueで、DBから取得した全スキルデータをストアに格納してみる。
 まず、ストアにデータを登録するミューテーションと、そのミューテーションを起動するアクションをsrc/store/index.jsに追加する。ミューテーションとは、ストアのデータを同期的に更新するための仕組みで、アクションはミューテーションを非同期的に外部処理と連携させられる仕組みだ。

const mutations = {
  setItems: (state, payload) => {
    state[payload.property] = payload.data
  }
}
const actions = {
  initData: ({ commit }, payload) => {
    commit('setItems', payload)
  }
}
const store = new Vuex.Store({
  state,
  mutations,
  actions,
})

 そして、コンポーネントSkillList.vue側で、ストアのアクションをディスパッチする。

export default {
  name: 'SkillList',

  data: () => ({
    labels: {
      title: 'スキル一覧',
    },
    skills: null,
    loading: true,
  }),

  created() {
    if (this.$store.state.skills.length == 0) {
      // ストアにデータがなければDBからデータを取得する
      this.getData('index.php?tbl=skills')
    } else {
      // ストアにデータがあればそのデータを使う
      this.skills = this.$store.state.skills
      this.loading = false
    }
  },

  methods: {
    getData: async function(path) {
      const instance = this.createAxios()
      await instance.get(path)
      .then(response => {
        response.data.sort((a, b) => {
          return a.ruby_name.localeCompare(b.ruby_name, 'ja')
        })
        this.$store.dispatch('initData', {property: 'skills', data: response.data})
      })
      .catch(error => {
        console.error(`Failure to retrieve skill data. (${error})`)
      })
      .finally(() => {
        this.sleep(100).then(() => {
          this.skills  = this.$store.state.skills
          this.loading = false
        }).then(() => {
          this.adjustCellHeight()
        })
      })
    },
    adjustCellHeight: function() {
      document.querySelectorAll('.skill-levels table').forEach((elm) => {
        let parentHeight = elm.closest('td').clientHeight,
            selfHeight = elm.clientHeight
        if (parentHeight > selfHeight) {
          elm.style.height = `${parentHeight}px`
        }
      })
    },
  },
}

 これで、スキルデータがストアにキャッシュされていない時だけ、DBから取得するようになり、スキル一覧を表示した時のパフォーマンスが劇的に良くなる。
 だが、これはまだ試験的な実装で、あくまでデータ・キャッシュができることの確認のためだけの処理である。最終的にはストアのデータ管理は共通処理化していくつもりなのだ。

メイン画面のストア化

 メイン画面ではユーザーが選択した装備や装飾品、アイテム設定などをストア化して、それぞれの項目が変更された場合にスキル内訳やステータス情報を計算して表示を更新する必要がある。処理が結構複雑になるので、必要になるストア項目を洗い出してみる。

ストア項目 親項目 格納する値 データ概要
weapon Object 現在装備中の武器データのオブジェクト
data weapon Object weapons.tblから取得した武器データ(カラム名:値のペア)
slots weapon Array スロット1~3に装着した装飾品データ(1~3:装飾品ID)
head Object 現在装備中の頭部防具データのオブジェクト
data head Object armors.tblから取得した防具データ(カラム名:値のペア)
level head Integer 防具のレベル
slots head Array スロット1~3に装着した装飾品データ(1~3:装飾品ID)
chest Object 現在装備中の胸部防具データのオブジェクト
data chest Object armors.tblから取得した防具データ(カラム名:値のペア)
level chest Integer 防具のレベル
slots chest Array スロット1~3に装着した装飾品データ(1~3:装飾品ID)
arms Object 現在装備中の腕部防具データのオブジェクト
data arms Object armors.tblから取得した防具データ(カラム名:値のペア)
level arms Integer 防具のレベル
slots arms Array スロット1~3に装着した装飾品データ(1~3:装飾品ID)
waist Object 現在装備中の腰部防具データのオブジェクト
data waist Object armors.tblから取得した防具データ(カラム名:値のペア)
level waist Integer 防具のレベル
slots waist Array スロット1~3に装着した装飾品データ(1~3:装飾品ID)
legs Object 現在装備中の脚部防具データのオブジェクト
data legs Object armors.tblから取得した防具データ(カラム名:値のペア)
level legs Integer 防具のレベル
slots legs Array スロット1~3に装着した装飾品データ(1~3:装飾品ID)
talisman Object 現在装備中の護石データのオブジェクト
data talisman Object talismans.tblから取得した護石データ(カラム名:値のペア)
slots talisman Array スロット1~3に装着した装飾品データ(1~3:装飾品ID)
player Object ユーザーのプレイヤーデータのオブジェクト
gender player String プレイヤーキャラの性別(”male” or “female”)
items player Object プレイヤーのアイテム効果

──このデータスキームをストア(src/store/index.js)に追加し、さらに武器や防具、護石などのデータもキャッシュできるようにしておく。
 さらに、今後必要になりそうなゲッターも追加しておく。

import Vue from "vue"
import Vuex from "vuex"
Vue.use(Vuex)
const state = {
  weapons: [],
  armors: [],
  talismans: [],
  decorations: [],
  skills: [],
  weapon_meta: [],
  ammo: [],
  weapon: { data: {}, level: 1, slots: [] },
  head: { data: {}, level: 1, slots: [] },
  chest: { data: {}, level: 1, slots: [] },
  arms: { data: {}, level: 1, slots: [] },
  waist: { data: {}, level: 1, slots: [] },
  legs: { data: {}, level: 1, slots: [] },
  talisman: { data: {}, slots: [] },
  player: { gender: 'male', items: {} }
}
const getters = {
  weaponsKindOf: (state) => (kind) => {
    return state.weapons.filter(weapon => weapon.type == kind)
  },
  armorsKindOf: (state) => (part) => {
    return state.armors.filter(armor => armor.part == part)
  },
  itemsById: (state) => (type, id) => {
    return state[type].find(item => item.id === id)
  },
}
const mutations = {
  setItems: (state, payload) => {
    state[payload.property] = payload.data
  }
}
const actions = {
  initData: ({ commit }, payload) => {
    commit('setItems', payload)
  }
}
const store = new Vuex.Store({
  state,
  getters,
  mutations,
  actions,
})
export default store

 まぁ、ストアはこの程度の設計で十分だろう。

ストアデータ初期化処理をミックスインに登録する

 アプリケーションが起動した時の初期処理に、アプリ内で使いまわすマスターデータを全てDBから取得してストアにキャッシュしてしまうと、それ以降、どんなコンポーネントにおいてもデータがあるかないかで悩む必要がなくなる。そこで、アプリのルートコンポーネントであるApp.vueでそのデータ読み込みを行ってしまうのが手っ取り早いだろう。なお、非同期でのデータ読み込みをストアのミューテーションに連繋させるための処理は、ミックスインに定義して共通処理化してしまう。ちゅーわけで、src/plugins/function.jsに下記のメソッドを追加する。

export default {
  methods: {
    ...(中略)
    // Load all master data as initializing application
    getMasterData: async function() {
      const instance = this.createAxios()
      const tables = [ 'weapons', 'armors', 'talismans', 'decorations', 'skills', 'weapon_meta', 'ammo' ]
      let remained = tables.concat(), rate = 0
      for (let table of tables) {
        await instance.get(`index.php?tbl=${table}`)
        .then(response => {
          this.$store.dispatch('initData', {property: table, data: response.data})
        })
        .catch(error => {
          console.error(`Failure to retrieve ${table} data. (${error})`)
        })
        .finally(() => {
          this.sleep(1).then(() => {
            remained.shift()
            rate = Math.ceil((1 - (remained.length / tables.length)) * 100)
            this.progress = rate >= 100 ? 100 : rate
          })
        })
      }
    },
  }
}

 次に、ルートコンポーネントApp.vue側の初期処理として、このgetMasterData()メソッドを呼び出せばよい。なお、データロード中はプログレスバーを表示するようにしてある。

<template>
    ...(中略)
    <v-main>
      <v-container
        fluid
      >
        <template v-if="loaded">
          <router-view />
        </template>
        <template v-else>
          <v-row
            class="fill-height"
            align-content="center"
            justify="center"
            :style="contentHeight"
          >
            <v-col cols="6">
              <v-progress-linear
                v-model="progress"
                color="light-blue"
                height="24"
                striped
              >
                <template v-slot:default="{ value }">
                  <strong>{{ Math.ceil(value) }}%</strong>
                </template>
              </v-progress-linear>
            </v-col>
          </v-row>
        </template>
      </v-container>
    </v-main>
    ...(中略)
</template>
<script>
import SideMenu from './components/SideMenu'
import Footer   from './components/Footer'
export default {
  name: 'App',
  ...(中略)
  data: () => ({
    progress: 0,// <-- 追加
    drawer: false,
    darkTheme: true,
    labels: {
      title: 'MHRise: Skill Simulator',
    },
    loaded: false,// <-- 追加
  }),
  watch: {
    darkTheme (value) {
      this.$vuetify.theme.isDark = value
    },
    // 以下を追加
    progress (value) {
      if (value >= 100) {
        this.sleep(300).then(() => {
          this.loaded = true
        })
      }
    },
  },
  created() {
    this.darkTheme = this.$vuetify.theme.isDark
    this.getMasterData()// <-- 追加
  },
  ...(中略)
}
</script>

 これで、子コンポーネント側ではマスターデータの取得は原則行う必要がなくなる。ただ、ルートコンポーネントとルーティングが異なるコンポーネントの場合に、そのルートへ直接アクセスされると、マスターデータの一括取得が行われないので、前項のスキル一覧コンポーネントSkillList.vueに追加したgetData()メソッドは残しておいた方が良い。
 まぁ、これでアプリの初回起動と同時にDBアクセスが行われてデータがストアにキャッシュされ、それ以降はキャッシュデータを再利用するようになるので、アプリがサクサク動くようになる。

ストア項目の監視と更新

 それでは、今回の本丸であるメイン画面の処理を作って行く。この画面では装備をいつでも変更でき、装飾品の付け替えも自由にできる仕様になっている。で、現在装備中の装備データは専用のストア項目にキャッシュされる建付けだ。それぞれの装備を変更した時には、ストアのデータは更新され、更新されたストアデータに応じてステータスやスキル内訳の表示を変えなければならない。
 ストアへの登録や更新処理はストアのミューテーション(mutations)で、そのミューテーションをコンポーネント側から実行するのはアクション(actions)で、ストアデータを集計してステータス情報を算出するのはゲッター(getters)で行うことになる。
 これらの仕様を実現するためには、ストアデータが変更されたかどうかを監視しなければならない。ストアを監視する方法はいくつかあって、何気にこれは重要なので、ちょいと詳しく説明しておこう。

subscribeAction での監視

 この方法では、Vuexのアクション実行時のデータ更新前後(ミューテーションをコミットする前後)に処理をフック(挿入)できる。これを使うと、アクションをイベントハンドラ、subscribeActionをイベントリスナー的に扱えるので、処理を作りやすくなる。
 具体的な使い方は、下記のようになる。

mounted() {
  this.$store.subscribeAction({
    before: (action, state) => {
      if ('setEquipment' === action.type && this.$props.type === action.payload.property) {
        this.oldItem = this.item
        console.log('Change Equipment Item::Before changing %s: %s -> %s', this.$props.type, this.oldItem.name, state[action.payload.property].data.name)
        // Change Equipment Item::Before changing weapon: ハイニンジャソード -> ハイニンジャソード
      }
    },
    after: (action, state) => {
      if ('setEquipment' === action.type && this.$props.type === action.payload.property) {
        this.item = state[action.payload.property].data
        console.log('Change Equipment Item::After changing %s: %s -> %s', this.$props.type, this.oldItem.name, this.item.name)
        // Change Equipment Item::After changing weapon: ハイニンジャソード -> ゴシャガズバァ
      }
    }
  },
}

 ログの例は、武器を「ハイニンジャソード」から「ゴシャガズバァ」に変更した時のものだ。subscribeAction.beforeではインスタンスとストア共に「ハイニンジャソード」のままで、subscribeAction.afterでストア側が「ゴシャガズバァ」に更新されているのがわかる。このsubscribeActionを使う際に注意が必要なのが、beforeとafterを指定せずに使うとbeforeの挙動になることだ。この性質さえ覚えておけば、基本的にストアの監視はこれ一つで事足りるだろう。

subscribe での監視

 次の方法は、Vuexのミューテーション実行時のデータ更新後に処理をフック(挿入)できる。正確なタイミング的には前述したsubscribeAction.afterの直前になる。どストレートにデータが変更された直後に処理をはさめるので、直感的ではあるのだが、変更前の値を参照できないという特徴がある。
 まぁ、subscribeAction.afterで事足りるうえ、subscribeActionと混在するとコードの可読性が落ちるので、今回のアプリでは使わない。

watch での監視

 最後の方法は、Vue.js側のwatchで直接ストアのstateを監視するやり方だ。ちなみにwatchの発火タイミングは、subscribeAction.afterの後となる。また、変更前後の値も参照できる。最大のメリットはミューテーションの内部処理中のストアデータ変更(例えば、受け取ったpayload引数からではなく、条件によって固定値を設定する等)を監視できることだ。つまり、監視性能的にはこのwatchが一番強力なのである。とはいっても、ストアのデータ変更をイベントハンドラ的にリッスンしたい場合などには、watchの処理内で$emit()するなど、回りくどいことをやる必要が出てくる。
 他のコンポーネント群に監視結果を伝播する必要がない自己完結型の処理などの場合は、むしろwatchで完結させてしまう方がシンプルなので、ケースバイケースで使用することになるかと。

Vuexを導入したメイン画面プレビュー

 結構複雑だったが、メイン画面へのVuex導入の第一弾が完了した。詳しい処理内容などが気になる人は、GitHubリポジトリのソースを参照してほしい。出来上がったアプリのプレビューは下記の通りだ。

 メイン画面から装備の変更ができるようになっている(護石をDBに登録していないからなのか、護石を装備しようとするとバグったりするが……w)。今のところDBに登録してある装備系データが、私が実際にプレイ中のゲーム画面見ながら追加したものだけなので、まだまだ少ない。でもまぁ、だいぶ使いやすいUIになっているんじゃないかな(モバイルでのスキル内訳の表示にはまだまだ難があるものの)。
 この記事中のインライン表示だと解像度的に見づらいので、ブラウザの全画面で確認できるようにリンクも貼っておこうかね。

MHRise: Skill Simulator Ver.0.1.4

──ちゅーわけで、今回はここまで。

 次回は、護石を登録する機能を作るぜ!