Vue.js で <input> などのフォーム部品を v-model を用いて 2-way data binding するのは簡単だし、コンポーネントを作って v-model で 2-way data binding するのも比較的簡単ですが、<select> やチェックボックス、ラジオボタンのカスタムコンポーネントを作って v-model するのは苦労します。

Vue.js の公式ドキュメントでも <input type="text"> の場合しか説明しておらず、それを素直に <select> 等に転用すると、画面上の表示に反映されないとか、更新が保存されないなど、期待した通りに動きません。原理を理解しようとしても、公式ドキュメントにわかりやすい記述はありません。

僕が見た中で、一番良い解説は、こちらでした。 https://www.smashingmagazine.com/2017/08/creating-custom-inputs-vue-js/

以下では、公式ドキュメントとこの記事を参照しながら、Vue.js でカスタムフォーム部品を作る場合の動作原理とサンプルコードを解説します。

フォーム入力部品のデータバインディング

v-model は、コードの記述を簡潔にするための短縮記法で、適用するフォーム部品の種類に応じて、以下の通り動作します。

  • texttextarea:valueプロパティと @inputイベントハンドラが作られる。
  • チェックボックスとラジオボタン::checkedプロパティと @changeイベントハンドラが作られる。
  • select:valueプロパティと @changeイベントハンドラが作られる。

公式ドキュメントにもサンプルが載っているので、ここでつまづくことはないでしょう。

コンポーネントのデータバインディング

コンポーネントには、プロパティを経由してデータを引き渡します。表示用コンポーネントであれば、これで終わりです。ボタンを押すなどの、ユーザ操作系コンポーネントであれば、コンポーネント無いで this.$emit() して、親コンポーネントにイベントを送信します。

問題は、データ入力用コンポーネントを作るときに起こります。こういうことをしたいわけです。

<my-custom-form-field v-model="hoge" />

hoge をフォーム表示に反映させた上で、フォーム上のデータが変更されたら hoge に反映させたい。Vueの公式ドキュメントでは、以下のように解説されています。

<input v-model="searchText" /><input :value="searchText" @input="searchText = $event.target.value" />
と同等です。

カスタムコンポーネントの場合、`v-model` は以下と同等になります。
<custom-input
  :model-value="searchText"
  @update:model-value="searchText = $event"
></custom-input>

<input type="text"> では :value@input だったものが、<custom-input> では :model-value@update:model-value に変わっています。

コンポーネントの実装は以下のようになっています。

app.component('custom-input', {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  template: `
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
    >
  `
})

カスタムフォームコンポーネントの原則

上記のルールを踏まえると、カスタムフォームコンポーネントを作るときの原則は次のようになります。

  • コンポーネントを使う側は <my-custom-form-field v-model="model"> と書く。
  • コンポーネント側では、
    • modelValueプロパティで値を受け取る。
    • update:modelValue$emit して親コンポーネントのモデルに変更を反映させる。

コンポーネント内で実装するときのプロパティとイベントの組み合わせは以下の通り。

  • text<textarea>:value@input
  • <select>:value@change
  • チェックボックスとラジオボタン::checked@change

上記のルールに従いながら、v-model:options を受け取る、選択系フォーム部品をそれぞれ実装してきます。

<select> のカスタムフォームコンポーネント

一番シンプルです。

<template>
  <select :value="modelValue" @change="updateValue">
    <option disabled value="">選択してください</option>
    <option v-for="(name, key) in options" :key="key" :value="key"></option>
  </select>
</template>

<script>
export default {
  props: {
    modelValue: { type: String, require: true, default: null },
    options: { type: Object, require: false, default: () => {} }
  },
  emits: ['update:modelValue'],
  methods: {
    updateValue: function (e) {
      this.$emit('update:modelValue', e.target.value)
    }
  }
}
</script>

ラジオボタンのカスタムフォームコンポーネント

:checked="modelValue == key" の記述がポイントです。

<template>
  <fieldset>
    <label v-for="(name, key) in options" :key="key">
      <input :checked="modelValue == key" type="radio" :value="key" @change="updateValue">
      
    </label>
  </fieldset>
</template>

<script>
export default {
  props: {
    modelValue: { type: String, require: true, default: null },
    options: { type: Object, require: false, default: () => {} }
  },
  emits: ['update:modelValue'],
  methods: {
    updateValue: function (e) {
      this.$emit('update:modelValue', e.target.value)
    }
  }
}
</script>

チェックボックスのカスタムフォームコンポーネント

単一のチェックボックスの場合、v-model には true/false を渡します。複数選択のチェックボックスの場合、以下のように配列をプロパティで渡し、配列を $emit で親に返します。チェックされたとき、チェックが外されたときに、親コンポーネントに返す配列を組み立てるため、他の例よりもイベント処理が若干複雑になっています。

<template>
  <div>
    <label v-for="(name, key) in options" :key="key">
      <input :id="key" :checked="modelValue.includes(key)" type="checkbox" :value="key" @change="updateValue">
      
    </label>
  </div>
</template>

<script>
export default {
  props: {
    modelValue: { type: Array, require: true, default: null },
    options: { type: Object, require: true, default: () => {} }
  },
  emits: ['update:modelValue'],
  methods: {
    updateValue: function (e) {
      const checked = e.target.checked
      const val = e.target.value
      const vals = [...this.modelValue]
      if (checked) {
        vals.push(val)
      } else {
        vals.splice(vals.indexOf(val), 1)
      }
      this.$emit('update:modelValue', vals)
    }
  }
}
</script>

オプションのキーとして数字も受け取る場合

上記の例では、プロパティを以下のように定義しました。

    modelValue: { type: String, require: true, default: null },

オプションのキーが文字列であることを前提としています。数字も受け取る場合は、以下のようになります。

    modelValue: { type: [String, Number], require: true, default: null },

僕が実装した範囲内では、セレクト系の大部分は enum の文字列をやりとりしているのですが、都道府県の選択のときに整数の都道府県コードを渡して警告が出ました。SringNumber の両方を受け付けることで解決しました。