Vue.jsでリアクティブなプロパティを動的に追加する


Vue.jsでの双方向バインディング

Vue.jsでは、v-modelを使って双方向バインディングを実現できる。
例として、3つの選択肢のチェックボックスを考えます。この例は、Vue.jsの単一コンポーネントでソースを記述しています。

v-modelを使った双方向バインディング

  • inputタグを3つ書く
  • scriptのdata()内で、それぞれのinputの状態に対応するプロパティを定義する
  • inputタグにv-model属性を追加して、対応するプロパティを記述する
sample.vue
<template>
  <ul>
    <li>
      <input type="checkbox" v-model="option1" />
      <label>選択肢1</label>
    </li>
    <li>
      <input type="checkbox" v-model="option2" />
      <label>選択肢2</label>
    </li>
    <li>
      <input type="checkbox" v-model="option3" />
      <label>選択肢3</label>
    </li>
  </ul>
</template>

<script>
export default {
  data () {
    return {
      option1: false,
      option2: false,
      option3: false
    }
  }
}
</script>

<style scoped>
ul {
  list-style: none;
}
</style>


選択肢がいっぱい問題 & 複数のUI要素を1つのプロパティに対応させたい問題

上記の例のように、選択肢が3とかであれば、それぞれの選択肢に対応するプロパティを定義してv-modelで対応付けてやれば事足ります。
では、例えば選択肢が100個あるような状況ではどうでしょう。100個分のプロパティを予め定義するのは不可能ではありませんが、書いてるうちにバカバカしくなってしまいそうです。仕様次第ですが、おそらく100個のうち数個しか実際には使われないでしょう。
「ユーザが触れたチェックボックスだけ」状態を管理したいというニーズが発生します。

また、チェックボックスを作った時に、行全体をクリッカブルにしたい場合もあります。この時問題になるのは下記です。
- ullabel要素などはv-modelを指定できない
- 1つのプロパティに対して2つのトリガ(チェックボックス自体のクリックとラベルのクリック)がある

基本的な方針としては、次のような考え方ができそうです。

  • チェックボックスに対応するプロパティを1つ1つ定義するのはやめる
  • checkedのようなオブジェクトを用意して、ユーザが触ったチェックボックスに対応するプロパティをcheckedの中に追加する
  • 「ユーザが触ったかどうか」はv-on:clickのようなイベントリスナを使って判定する
  • v-on:clickは、特定のプロパティに対応するUI要素にそれぞれ指定する

方針が決まったところでいざ実践。

動的にリアクティブなプロパティを追加する(ダメな例)

僕がハマった例です。
単純にチェックボックスをクリックしたらselected[option] = trueのようにすれば良いと思ってやってみました。

  • inputlabelにそれぞれクリックイベントを追加
  • クリック時にselectedオブジェクトの中にoption1のような要素を追加し、選択されているか否かの判定に用いる
sample.vue
<template>
  <ul>
    <li>
      <input
        type="checkbox"
        v-model="selected.option1"
        v-on:click="select('option1')" />
      <label
        v-on:click="select('option1')">
        選択肢1
      </label>
    </li>
    <li>
      <input
        type="checkbox"
        v-model="selected.option2"
        v-on:click="select('option2')" />
      <label
        v-on:click="select('option2')">
        選択肢2
      </label>
    </li>
    <li>
      <input
        type="checkbox"
        v-model="selected.option3"
        v-on:click="select('option3')" />
      <label
        v-on:click="select('option3')">
        選択肢3
      </label>
    </li>
  </ul>
</template>

<script>
export default {
  data () {
    return {
      selected: {}
    }
  },
  methods: {
    select (option) {
      console.log(option)
      if (!this.selected[option]) {
        this.selected[option] = true
      } else {
        this.selected[option] = !this.selected[option]
      }
    }
  }
}
</script>

<style scoped>
ul {
  list-style: none;
}
</style>


ポイント

クリックイベントを拾った時に動くのは下記のコード。なんだか良さげに見えますね。

if (!this.selected[option]) {
  this.selected[option] = true
} else {
  this.selected[option] = !this.selected[option]
}

結果

  • ラベルはクリックできるようになった!が、チェックボックスチェックが入らない...
  • チェックボックスクリック時にはチェック状態が変わる!
  • あれ?でもプロパティにoptionの値が追加されない...

なぜうまくいかないか

調べてみると、ドキュメントに次のようにありました。

「モダンな JavaScript の制限(そして Object.observe の断念)のため、Vue.js はプロパティの追加または削除を検出できません。Vue.js はインスタンスの初期化中に、getter/setter 変換処理を実行するため、プロパティは、Vue がそれを変換しそしてそれをリアクティブにするために、data オブジェクトに存在しなければなりません」

「ええー?詰んだ...」と思って読み進めると、

Vue はすでに作成されたインスタンスに対して動的に新しいルートレベルのリアクティブなプロパティを追加することはできません。しかしながら Vue.set(object, key, value) メソッドを使うことで、ネストしたオブジェクトにリアクティブなプロパティを追加することができます

とありました。動的にリアクティブなオブジェクトを追加するにはVue.setを使う必要があります。

動的にリアクティブなプロパティを追加する(良い例)

ツンデレドキュメントから救いを得たのでVue.setを使ってみましょう。
this.selected[option] = trueの部分を書き換えてみます。単一ファイルコンポーネントの場合、this.$setで呼び出せます。

sample.vue
<template>
  <ul>
    <li>
      <input
        type="checkbox"
        v-model="selected.option1"
        v-on:click="select('option1')" />
      <label
        v-on:click="select('option1')">
        選択肢1
      </label>
    </li>
    <li>
      <input
        type="checkbox"
        v-model="selected.option2"
        v-on:click="select('option2')" />
      <label
        v-on:click="select('option2')">
        選択肢2
      </label>
    </li>
    <li>
      <input
        type="checkbox"
        v-model="selected.option3"
        v-on:click="select('option3')" />
      <label
        v-on:click="select('option3')">
        選択肢3
      </label>
    </li>
  </ul>
</template>

<script>
export default {
  data () {
    return {
      selected: {}
    }
  },
  methods: {
    select (option) {
      console.log(option)
      if (!this.selected[option]) {
        this.$set(this.selected, option, true)
      } else {
        this.selected[option] = !this.selected[option]
      }
    }
  }
}
</script>

<style scoped>
ul {
  list-style: none;
}
</style>

結果

うまくいきました〜!

参考

チェックボックスなどのバインディングについては良い感じのドキュメントがありました。
labelタグにfor属性を追加すれば今回みたいなややこしいことしなくてよかったのか〜。

まぁでも考え方はどっかで役に立つはず...!