実例からみるVuexアプリケーションにおけるv-model/computedとの付き合い方

この記事はVue.js アドベントカレンダー #3 24日目の記事です。

みなさん今日はクリスマスイブですね。今年のイブは日曜日ということもあり、一般的には会社は休みですので、恋人と過ごす人、友人と過ごす人、家族と過ごす人と様々ではないでしょうか。私も夜にはホームパーティを控えていたりします。

そんな本日のアドベントカレンダーですが、クリスマスとは全く関係なく、真面目にVuexアプリケーションにおけるv-modelとの付き合い方について考えたいと思います。

VueとVuex間での責務の衝突とジレンマ

JavaScriptでそれなりに凝ったWebアプリケーションを開発する時、特にSPAを開発する時は、Fluxパターンもしくはその派生ライブラリを扱うことが非常に多いかと思います。

Vue.jsにおいても、本格的な状態管理についてはVue.js謹製ライブラリであるVuexを利用することができますし、Vue.jsにおけるFluxパターンの活用はほぼこのVuexにおいて行われているかと思います。

Vuexを利用することで、アプリケーションの状態を一元管理することができ、非常に見通しの良いアプリケーションを開発することができますが、その一方で、Vue.js本体の機能と役割が競合する部分があります。

代表的なところですと、タイトルにも挙げている以下の2つかと思います。

  • v-modelによる双方向バインディングとFluxパターン間での競合
  • 加工したデータを返す役割においてのgettersとcomputedの競合

今回は、この上のv-modelについて、実例を交えた上で、これらの競合をどのように解消していくか、どういったシチュエーションでどちらを利用するかという問題について、一つの参考となる解答を出してみたいと思います。

また、computedについても、最後に少しだけ例を交えて説明いたします。

Vuex上でのv-modelとのうまい付き合い方

まずはv-modelからみていきましょう。
Vuexは勿論Fluxを基本としていますので、Store内のstateに対しては外部からみた際はimmutableであるべき存在となります。
それに対して、v-modelは基本的にVueの双方向バインディングをメインとしており、View層からの変化とドメインからVue Componentに対して送られた変化が相互で紐づくような機能となっています。

勿論どちらも大きな利便性がありますし、できればうまく両方活用したいところですが、愚直に使うと問題が生じます。

基本的なバインディングとFlux Storeの競合

例えば、このような構造のものがあったとします。

デモ: https://codepen.io/potato4d/pen/QaGwxL

<body>
  <div id="app">
    <p>Component:</p>
    <input type="text" v-model="$store.state.foo">
    <p>Store:</p>
    <pre v-html="rawState" />
  </div>
  <script>
const store = new Vuex.Store({
  state: {
    foo: 'initial state'
  }
})

new Vue({
  el: '#app',
  store,
  computed: {
    rawState () {
      return JSON.stringify(this.$store.state, '\t')
    }
  }
})
  </script>
</body>

Screen Shot 2017-12-23 at 19.11.52.png

このようにそのままバインディングをしてしまった場合、勿論v-model側の双方向バインディングが機能し、Vuexストアの中身まで書き換わってしまいます。

次はcomputedに対してgettersをマッピングしてみます。Vuexの一般的な使い方ですとmapGettersを多用することは多いでしょうし、なによりcomputedを利用するとその値はimmutableとなりますので、Storeの上書きを防ぐ目的としては良さそうです。

<body>
  <div id="app">
    <p>Component:</p>
    <input type="text" v-model="foo">
    <p>Local</p>
    <span>{{foo}}</span>
    <p>Store:</p>
    <pre v-html="rawState" />
  </div>
  <script>
const { mapGetters } = Vuex
const store = new Vuex.Store({
  state: {
    foo: '1'
  },
  getters: {
    foo: state => state.foo
  }
})

new Vue({
  el: '#app',
  store,
  computed: {
    rawState () {
      return JSON.stringify(this.$store.state, '\t')
    },
    ...mapGetters(['foo'])
  }
})

  </script>
</body>

Screen Shot 2017-12-23 at 22.30.45.png

当然ですが、こうした場合は上書きはされません。
その上、gettersで取ってきているのでコンポーネント内のローカルスコープ上にも変更したデータも残らないようになってしまいます。

Screen Shot 2017-12-23 at 22.38.22.png

こういったシチュエーションが発生した場合、大きく2つの解決方法があるかと思います。

Vuexに寄せるか、v-modelによせるか

その二つの解決方法ですが、 全てをVuex wayに乗せた上でv-modelを使用しない もしくは、 Vuexの担当領域とコンポーネントのローカルスコープを明確に定義し、Vuexには最終的なエンティティのみを定義する があります。

両者ともメリット・デメリットがありますが、結論としては私は、Vueを用いた開発であれば Vuexの担当領域とコンポーネントのローカルスコープを明確に定義し、Vuexには最終的なエンティティのみを定義する を支持します。

以下に、具体的な実例を基にしたそれぞれのメリット・デメリットをご紹介します。

実際のフォーム実装を例に、それぞれで書き比べる

Vuexに全てを寄せて管理する

まずはこちらから。いわゆるFluxパターンを完全に遵守するとこういう形になるかと思います。
全ての状態が統一的に管理されるため、比較的堅牢な設計ができるため好むかたは多い印象を受けます。

Vuexに全てを寄せる方式は、Storeベースで管理したい場合、Vue Devtoolsを存分に活用したい場合には良い手法です。
この場合、v-modelは全く利用せずに、inputやtextareaの @change イベントや @input イベントで取得し、反映する形となります。

デモ: https://codepen.io/potato4d/pen/GyNprN?editors=1011

Screen Shot 2017-12-23 at 22.38.22.png

このようにView側では全く処理をもたないで綺麗に記述ができます。

しかしながら、例えば 入力のモードをもった状態で、一度編集モードにしたものをキャンセルしたい というシチュエーションがでるとどうでしょう。

「名前の変更」のようなボタンを押したけれど名前の変更を打ち消したい場合ですね。

ひとまず愚直に実装します。

Vuexで愚直に実装した例
  <div id="app">
    <p>Component:</p>
    <template v-if="isEdit">
      <input type="text" :value="foo" @input="onInput">
      <button type="button" @click="toggleEdit">Cancel</button>
    </template>
    <template v-else>
      <span>{{foo}}</span>
      <button type="button" @click="toggleEdit">Edit</button>
    </template>
    <p>Local</p>
    <span>{{foo}}</span>
    <p>Store:</p>
    <pre v-html="rawState" />
  </div>
  <script>
const { mapGetters, mapActions } = Vuex
const store = new Vuex.Store({
  state: {
    foo: '1',
    isEdit: false
  },
  getters: {
    isEdit: state => state.isEdit,
    foo: state => state.foo
  },
  mutations: {
    updateFoo (state, { foo }) {
      state.foo = foo
    },
    toggleEdit (state) {
      state.isEdit = !state.isEdit
    }
  },
  actions: {
    UPDATE_FOO ({ commit }, { foo }) {
      commit('updateFoo', { foo })
    },
    TOGGLE_EDIT ({ commit }) {
      commit('toggleEdit')
    }
  }
})
new Vue({
  el: '#app',
  store,
  methods: {
    onInput (event) {
      this.doUpdate({ foo: event.target.value })
    },
    ...mapActions({
      doUpdate: 'UPDATE_FOO',
      toggleEdit: 'TOGGLE_EDIT'
    })
  },
  computed: {
    rawState () {
      return JSON.stringify(this.$store.state, '\t')
    },
    ...mapGetters(['isEdit', 'foo'])
  }
})
  </script>

このコードですと、一見問題なさそうに見えますが、実際はキャンセル時の処理が考慮されていないので、キャンセルしようが何しようが確実に反映されることとなります。

https://gyazo.com/5a60594240d7c4ec77848446c3fc9f3f

対処するとなると、 編集に入る時点では一次変数に保存をしたうえで、保存とキャンセルを明確に分けてキャンセル時に取り消してもらう必要があります。

Vuexの最終サンプル
  <div id="app">
    <p>Component:</p>
    <template v-if="isEdit">
      <input type="text" :value="tmpFoo" @input="onInput">
      <button type="button" @click="save">Save</button>
      <button type="button" @click="toggleEdit">Cancel</button>
    </template>
    <template v-else>
      <span>{{foo}}</span>
      <button type="button" @click="toggleEdit">Edit</button>
    </template>
    <p>Local</p>
    <span>{{foo}}</span>
    <p>Store:</p>
    <pre v-html="rawState" />
  </div>
  <script>
const { mapGetters, mapActions } = Vuex
const store = new Vuex.Store({
  state: {
    foo: '1',
    tmpFoo: '',
    isEdit: false
  },
  getters: {
    isEdit: state => state.isEdit,
    tmpFoo: state => state.tmpFoo,
    foo: state => state.foo
  },
  mutations: {
    updateFoo (state, { foo }) {
      state.foo = foo
    },
    updateTmpFoo (state, { foo }) {
      state.tmpFoo = foo
    },
    toggleEdit (state) {
      state.isEdit = !state.isEdit
    }
  },
  actions: {
    UPDATE_FOO ({ commit }, { foo }) {
      commit('updateFoo', { foo })
      commit('updateTmpFoo', { foo })
    },
    UPDATE_TMP_FOO ({ commit }, { foo }) {
      commit('updateTmpFoo', { foo })
    },
    TOGGLE_EDIT ({ commit }) {
      commit('toggleEdit')
    }
  }
})
new Vue({
  el: '#app',
  store,
  methods: {
    onInput (event) {
      this.doSaveTmp({ foo: event.target.value })
    },
    save () {
      this.doUpdate({ foo: this.tmpFoo })
      this.toggleEdit()
    },
    ...mapActions({
      doUpdate: 'UPDATE_FOO',
      doSaveTmp: 'UPDATE_TMP_FOO',
      toggleEdit: 'TOGGLE_EDIT'
    })
  },
  computed: {
    rawState () {
      return JSON.stringify(this.$store.state, '\t')
    },
    ...mapGetters(['isEdit', 'foo', 'tmpFoo'])
  }
})
  </script>

これで正しい挙動ができました。ただ一次変数の管理にまだ問題がありますし、良いとはいえない状態です。

https://gyazo.com/7d0383a074319809ab1104fd5fa22a4d

最終的な保存先としてのVuexはどのアプリケーションでも有効にワークしますが、一時的にデータを退避する必要がある場合、Vuexにフルで役割をもたせるとうまみのない状態となることがあります。

Vuexとv-modelを共存させる

次にこちらのアプローチです。v-modelを使って進めてみます。
v-modelを利用し、上記と同等のソースコードを記述してみます。

v-modelを利用する場合は、以下が状態管理のキモとなります。

  • 基本的にコンポーネントの親子構造をうまく使う
  • 子コンポーネントはv-ifの対象から外れるたびに中身が破棄される
  • 子コンポーネント側でv-model本体を扱い、親コンポーネントでは編集状態の管理のみを行う

実際にコードで表現してみましょう。

Vuexのコードをv-modelに置き換えた例
  <div id="app">
    <p>Component:</p>
    <template v-if="isEdit">
      <the-edit-form :foo="foo" @done="toggleEdit" />
    </template>
    <template v-else>
      <span>{{foo}}</span>
      <button type="button" @click="toggleEdit">Edit</button>
    </template>
    <p>Store:</p>
    <pre v-html="rawState" />
  </div>

  <script type="text/x-template" id="the-edit-form">
<div>
  <input type="text" v-model="newFoo">
  <button type="button" @click="doUpdate">Save</button>
  <button type="button" @click="doEmitDone">Cancel</button>
</div>
  </script>
  <script>
const $ = (e) => document.querySelector(e)
const { mapGetters, mapActions } = Vuex
const store = new Vuex.Store({
  state: {
    foo: '1'
  },
  getters: {
    foo: state => state.foo
  },
  mutations: {
    updateFoo (state, { foo }) {
      state.foo = foo
    }
  },
  actions: {
    UPDATE_FOO ({ commit }, { foo }) {
      commit('updateFoo', { foo })
    }
  }
})

Vue.component('the-edit-form', {
  props: { foo: String },
  template: $('#the-edit-form'),
  data () {
    return { newFoo: null }
  },
  created () {
    this.newFoo = this.foo
  },
  methods: {
    doUpdate () {
      this.updateFoo({ foo: this.newFoo })
      this.doEmitDone()
    },
    doEmitDone () {
      this.$emit('done')
    },
    ...mapActions({
      updateFoo: 'UPDATE_FOO'
    })
  }
})

new Vue({
  el: '#app',
  store,
  data () {
    return {
      isEdit: false
    }
  },
  methods: {
    toggleEdit () {
      this.isEdit = !this.isEdit
    }
  },
  computed: {
    rawState () {
      return JSON.stringify(this.$store.state, '\t')
    },
    ...mapGetters(['foo'])
  }
})
  </script>

このように、正しい挙動が再現できました。キャンセル時の処理も明確になりましたし、Vuex側がアプリケーションのコアなデータだけを持つようになり、一元管理とは別の統制のとり方が行われています。

isEditのような一次データをもつこともなく進めると、非常に明確でわかりやすいですが、もしキャンセルした場合にもう一度編集に入る時に前回の編集中の状態を取得したい場合は、ひと工夫必要なことわかります。

https://gyazo.com/8e95f2019bec890917b36edb3c9cfda3

双方を比較して

双方を比べてみた印象はいかがでしょうか。
勿論、どちらも一長一短あり、原理主義に近いかたほど、苦労があってもVuexに全てを寄せたくなるかもしれません。

しかしながら、「編集のキャンセル」という行為は、「何かを追加しようとした状態で別のページに遷移する」といった場合でも発生し、非常に現れる機会が大きい課題でもあります。
その課題にたいして、都度都度キャンセルを書くのはコストのかかる行為でありますし、そのコンポーネントとその子・孫となるコンポーネント以外に影響を及ぼさないv-modelの利用は、アプリケーション全体を破壊することは基本的にないでしょう。

それらを加味した上で、Vuexとv-modelを双方利用する方向で折り合いをつけていくのが一番良い形ではないでしょうか。

私としては、Vue.js で Vuexを全く使わないもの / Vuexを一部のみ使うもの / Vuexを中心に使うものを全て書いてきた上で、上記のv-modelとの並行利用を強く推奨いたしますが、チームの特性を判断しつつ、高速に、かつ長期的にワークする形を模索するのが一番でしょう。

番外編:computedとmapGettersのどちらに寄せるか

最後に、はじめに少し触れたcomputedについてもメモ程度にご紹介したいと思います。
プレーンなVue.jsでは、そのコンポーネントごとに必要なデータの加工は基本的にcomputedを用いて算出プロパティとして作り出すかと思いますが、Vuexには類似した機能として、加工やフィルタリングをしたものに対して別名をつけてアクセスできるゲッター機能があります。

どちらを使っても勿論使用感上では特に大きな違いはないですし、両方を使うと破綻するということもありませんが、ある程度どちらを使うかの指標は決めておいたほうが良いでしょう。

私としては、複数のページにまたがって取得されるものや、ドメイン上で役割を持つ概念やエンティティについてはゲッターに、 特定のページやコンポーネントにおいて都合よく整形されたデータがほしいとき は、computedが良いと考えています。

Qiitaのようなサービスの場合を想定して、それぞれの例を挙げるとすると、以下のような分類となります。

  • ゲッターに入るべきもの
    1. 状態がdraftである投稿(postsの中からdraft==trueであるもの)
    2. 現在取得している投稿リストにユーザー情報が紐付いたもの(個別で取っている情報をフロントエンドでアソシエーションしたもの)
  • computedに入るべきもの
    1. ゲッターにおける1を Array から postid: Object 形式のオブジェクトへと変化させたもの
    2. コンポーネント内で投稿をインクリメンタルサーチした結果

基本的には影響範囲を考えた上で、「アプリケーションとして必要」なのか「Viewとして必要」なのかを中心に選択することが、肥大化せずかつ扱いやすいアプリケーション開発には重要です。

おわりに

Vue.jsは敷居が低いこと、様々な記法でアプリケーションを開発することができることから、統一性のない記述が多くなりがちです。
もちろんそのままでアプリケーション開発を進めることは可能ですし、管理ができているならば特に大きな問題にはならないかと思いますが、もちろん明確なルールが、ソフトなルールであれあったほうがわかりやすいことは明白です。

ディレクトリ構造や技術スタックについては、vuejs-templatesやNuxt.jsなどによって強く提唱されてきたものが増えていますが、VuexとVue.jsの機能との間にはそのような明確なものがこれまでなかったように見られます。

この記事が、Vue.jsでの設計について考え、悩んでいる人のもとに届けば幸いです。