Vuex アンチョコ

最近意を決して Vuex を使いはじめたら使ってこなかったことを後悔しました。とりあえず自分用メモを残します。

Store 側

new Vuex.Store({
  state: {
    a: {
      name: 'state A',
    },
    b: [
      { id: 1, active: true },
      { id: 2, active: false },
    ],
    c: 0,
  },
  // その名の通り getter を定義する
  // 第一引数には state 第二引数には getters が入る
  // return 前提なのでアロー関数を使うと楽
  //
  //     name: (state, getters) => state.a.name
  //
  // 関数を返すことで引数を取る getters とすることができる
  //
  //     store.getters['findActiveB'](1) //=> { id: 1, active: true }
  //
  getters: {
    a: state => state.a,
    activeB: state => state.b.filter(({ active }) => active),
    findActiveB: (state, getters) => v => getters.activeB.find(({ id }) => id === v),
  },
  // mutations は state を変更する同期的な処理を定義する
  // (非同期な処理を伴う場合は非同期処理を actions に定義し、actions から mutations を呼ぶ)
  // 第二引数は payload と呼ばれ、Object にすることが推奨されている
  //
  //     increment (state, payload) {
  //       state.c += payload.value
  //     }
  //
  // 呼び出し方には 2 種類ある特に `type` を使う方式の場合自動的に payload が Object になるため、
  // payload は Object に統一しておいたほうがよいだろう
  //
  //     store.commit('increment', { value: 3 })
  //     store.commit(( type: 'increment', value: 3 })
  //
  mutations: {
    // state 上の値の変更なので、state 側は分割代入してはならない
    increment (state, { value }) {
      state.c += value
      console.log('root c increment', state.c)
    },
    zero (state) {
      console.log('root zero')
      state.c = 0
    },
    step (state) {
      state.c += 1
    },
    a (state, { value }) {
      // 後述の submodules 配下の同名 mutations 呼び出し検証用
      console.log('root a mutation', value)
      state.a = value
    },
    rename (state, { value }) {
      state.a.name = value
    },
    b: (state, { value }) => state.b = value,
    c: (state, { value }) => state.c = value,
  },
  // getters を用いる場合や非同期な変更を行う action を定義する
  // 第一引数は context、第二引数は呼び出し側から渡される引数となる
  // context は state, getters, commit, dispatch などを持つが store そのものではない
  //
  // action は Promise 的に処理される
  // await を使う、もしくは非同期処理の Promise を返すことにより、処理の終了を呼び出し側で適切に検知できる
  //
  //     async save ({ state, getters }, { fileName }) {
  //       await api.updateData(fileName, { name: state.a.name, content: getters.activeB })
  //     }
  //
  //     save ({ state, getters }, { fileName }) {
  //       return api.updateData(fileName, { name: state.a.name, content: getters.activeB })
  //     }
  //
  // 呼び出し側では
  //
  //     await store.dispatch('save', { fileName: 'new data' })
  //     console.log('saved')
  //
  //     store.dispatch('save', { fileName: 'new data' }).then(() => console.log('saved'))
  //
  actions: {
    async save ({ state, getters, commit }, { fileName }) {
      await api.updateData(fileName, { name: state.a.name, content: getters.activeB })

      // 後述の submodules 配下の同名 actions 呼び出し検証用
      await new Promise(resolve => setTimeout(resolve, 2000))
      console.log('root saved')
      commit('increment', { value: 1 })
    },
    async resetAfterSave ({ commit, dispatch }, arg) {
      await dispatch('save', arg)

      commit({ type: 'a', value: { name: '' } })
      commit({ type: 'b', value: [] })
      commit({ type: 'c', value: 0 })
    },
    async retrieve ({ commit }, { fileName }) {
      const { name, content } = await api.fetchData(fileName)
      commit({ type: 'a', value: { name } })
      commit({ type: 'b', value: content })
      commit({ type: 'c', value: 0 })
    },
  },
  modules: {
    // namespaced はデフォルトで false
    // false の場合でも state は名前空間付きで展開される
    // false の場合 getters, mutations, actions はグローバルに展開される
    // getters の名前かぶりは警告が出る一方で、
    // mutations, actions の名前かぶりは **全てが呼び出される** という動作をする
    // 事故の原因になるので namespaced は常に有効にした方が良いのではないか (私見)
    submodule: {
      namespaced: false,

      // state は常に名前空間付きでしかアクセスできない
      //
      //     store.state.a //=> { name: "state A" }
      //     store.state.submodule.a //=> sub a
      //
      state: {
        a: 'sub a',
      },
      getters: {
        // namespaced: false の場合、名前かぶりはエラーとなる
        //
        //     a: state => state.a
        //     //=> [vuex] duplicate getter key: a
        //
        subA: state => state.a,

        // module では第三引数に rootState が入る
        // namespaced: false の場合、getters の基準点は root となる
        // (namespaced: true の場合はモジュールローカル基準となる、後述)
        rootInformation: (state, getters, rootState) =>
          ({ name: rootState.a.name, content: getters.activeB }),
      },
      mutations: {
        // 同一名称の mutations が存在する場合、全てが呼ばれる
        //
        //     store.commit('a', { value: 'next value' })
        //     //=> root A mutation next value
        //     //=> submodule A mutation next value
        //
        a (state, { value }) {
          console.log('submodule A mutation', value)
          state.a = value
        },
      },
      actions: {
        // 同一名称の actions が存在する場合、全てが呼ばれ、全ての解決を待って resolve される
        //
        //     store.dispatch('save', { fileName: 'new' }).then(() => console.log('done'))
        //     //=> submodule saved
        //     //=> root c increment 1
        //     //=> root saved
        //     //=> root c increment 2
        //     //=> done
        //
        async save ({ state, getters, commit }, { fileName }) {
          await api.updateData(fileName, { name: state.a.name, content: getters.activeB })
          await new Promise(resolve => setTimeout(resolve, 1000))
          console.log('submodule saved')

          // namespaced: false の場合、commit や dispatch の基準点も root となる
          // (namespaced: true の場合はモジュールローカル基準となる、後述)
          commit('increment', { value: 1 })
        },
      },
    },
    // namespaced: true にすると getters, mutations, actions も名前空間を持つようになる
    // アクセス方法は "moduleName/foo" という感じになる
    // getters もドットアクセスではなく getters["moduleName/foo"] となるので注意
    //
    // また getters, commit, dispatch の基準点がモジュールローカルとなるので、
    // root や配下ではない module へは従来とはちがう方法でアクセスしなくてはならない
    namespacedSubmodule: {
      namespaced: true,

      // state は常に名前空間付きでしかアクセスできない
      //
      //     store.state.a //=> { name: "state A" }
      //     store.state.namespacedSubmodule.a //=> namespaced a
      //
      state: {
        a: 'namespaced a',
        b: 'namespaced b',
      },
      getters: {
        // ドットアクセスではなくスラッシュ区切りの文字列を key として渡す
        //
        //     store.getters['namespacedSubmodule/a']
        //     //=> namespaced a
        //
        a: state => state.a,
        namedA: state => state.a,
        namedB: state => state.b,

        // namespaced: true の場合、getters の基準点はモジュールローカルとなる
        //
        //     rootInformation: (state, getters, rootState) =>
        //       ({ name: rootState.a, content: getters.activeB })
        //
        //     store.getters['namespacedSubmodule/rootInformation']
        //     //=> { name: "state A", content: undefined }
        //
        // そのため、root 基準でアクセスするために第四引数に rootGetters が入る
        rootInformation: (state, getters, rootState, rootGetters) =>
          ({ name: rootState.a.name, content: rootGetters.activeB }),
      },
      mutations: {
        //
        //     store.commit('namespacedSubmodule/a', { value: 'next value' })
        //     //=> namespaced A mutation next value
        //
        a (state, { value }) {
          console.log('namespaced A mutation', value)
          state.a = value
        },
      },
      actions: {
        //
        //     store.dispatch('namespacedSubmodule/save', { fileName: 'new' }).then(() => console.log('done'))
        //     //=> namespaced saved
        //     //=> done
        //
        async save ({ state, getters, commit, rootGetters }, { fileName }) {
          await api.updateData(fileName, { name: state.a.name, content: getters.activeB })
          await new Promise(resolve => setTimeout(resolve, 1000))
          console.log('namespaced saved')

          // namespaced: true の場合は getters, commit, dispatch の名前空間はモジュールローカル基準となる
          //
          //     commit('increment', { value: 1 })
          //     //=> [vuex] unknown local mutation type: increment, global type: namespacedSubmodule/increment
          //
          // 第三引数に root: true を渡すことにより root 基準で呼び出せる
          commit('increment', { value: 10 }, { root: true }) //=> root c increment 10
          //
          // getters では getters 定義と同じく rootGetters を使える
          console.log(getters.activeB)     //=> undefined
          console.log(rootGetters.activeB) //=> [{ id: 1, active: true }]
        },
      },
    },
  },
  strict: debug,
  plugins: debug ? [createLogger()] : [],
})

Component 側

Vuex には template 側のメソッドなどに展開するための便利なヘルパー関数が用意されているので活用すると良いと思います。commitdispatch などといった Vuex 由来のメソッドを Component 側に直接書くことが多い場合、それは store 側に移動できる処理が漏れだしている兆候かもしれません。

<script>
  import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'

  module.exports = {
        data () {
      // ここの値を store で扱うか component で扱うかは特に判断がついていない
      // 後述する store.state との双方向バインディングのわずわらしさを考えると、
      // 可変の値は data で扱ってしまうのも一つの手である
      return {
        saveName: '',
        saveNamespacedSubmoduleName: '',
        namespacedSubmoduleName: '',
      }
    },
    computed: {
      // # mapState

      ...mapState(['a']),

      // module の state を map するためのショートハンドは特にない
      ...mapState({
        submoduleA: state => state.submodule.a,
      }),
      // あるいは
      ...mapState('namespacedSubmodule', {
        namespacedSubmoduleA: state => state.a,
      }),

      // # mapGetters
      //
      // mapGetters は元の名前空間にかかわらず名前空間抜きの名前で展開される

      ...mapGetters(['activeB', 'findActiveB']),

      // namespaced: false の場合、名前空間の指定は不要
      ...mapGetters(['subA']),

      // namespaced: true の場合、名前空間の指定が必要
      // 以下の 2 行は同じ getters をマップするが、
      // 前者は this['namespacedSubmodule/namedA'] でのアクセス、
      // 後者は this['namedA'] (this.namedA) でのアクセスとなる
      ...mapGetters(['namespacedSubmodule/namedA', 'namespacedSubmodule/namedB']),
      ...mapGetters('namespacedSubmodule', ['namedA', 'namedB']),

      // mapGetters は元の名前空間にかかわらず名前空間抜きの名前で展開されるため、名前かぶりは上書きされる
      // namespaced 以下に名前のかぶった getters がある場合などはこのように rename して map できる
      ...mapGetters({
        renamedA: 'namespacedSubmodule/a',
      }),
      ...mapGetters('namespacedSubmodule', {
        renamedB: 'namedB',
      }),

      // v-model で state の値をバインドする場合、処理が非対称になるので以下のようにする必要がある
      // コンポーネント内で完全に完結する場合は data で扱う方がお手軽な場合もある
      name: {
        get () {
          return this.$store.state.a.name
        },
        set (value) {
          this.$store.commit('rename', { value })
        },
      },
    },
    methods: {
      // mapMutations, mapActions の名前展開のルールは mapGetters と同じ
      ...mapMutations(['increment']),
      ...mapActions(['save']),
      ...mapActions({
        saveNamespacedSubmodule: 'namespacedSubmodule/save',
      }),
    },
  }
</script>

<template lang="pug">
  article
    h1 vuex
    section
      h1 root state a
      p {{ a }}
    section
      h1 root submodule a
      p {{ submoduleA }}
    section
      h1 root namespacedSubmodule a
      p {{ namespacedSubmoduleA }}
    section
      h1 root getters activeB
      ul
        li(v-for="v in activeB", :key="v.id") {{ v.id }}
    section
      h1 root getters findActiveB
      p {{ findActiveB(1) }}
    section
      h1 submodule getters subA
      p {{ subA }}
    section
      h1 namespacedSubmodule getters namedA
      p {{ namedA }}
    section
      h1 namespacedSubmodule getters namedB
      p {{ namedB }}
    section
      h1 namespacedSubmodule getters renamedA
      p {{ renamedA }}
    section
      h1 namespacedSubmodule getters renamedB
      p {{ renamedB }}
    h1 object name
    input(v-model="name")
    section
      input(v-model="saveName")
      button(@click="save({ fileName: saveName })") save
    section
      input(v-model="saveNamespacedSubmoduleName")
      button(@click="saveNamespacedSubmodule({ fileName: saveNamespacedSubmoduleName })") saveNamespacedSubmodule
</template>

余談

https://vuex.vuejs.org/ja/intro.html

もし、あなたが大規模な SPA を構築することなく、Vuex を導入した場合、冗長で恐ろしいと感じるかもしれません。そう感じることは全く普通です。あなたのアプリがシンプルであれば、Vuex なしで問題ないでしょう。単純な グローバルイベントバス が必要なだけかもしれません。

と控えめなことが書いてありますが、開発上の「心がけ (別名俺ルール)」を後進の開発者へ伝達する手間を考えると、なにも考えずに Vuex を導入したほうが圧倒的平和が訪れるのではないかと思っています。

最初に書いた点の他にも、

などの利点があります。SSR にシームレスに移行するには独自クラスの利用を避けるなど他にもキビがありますが、それはまた機会があれば。