はじめに

Vue.jsはデータの双方向バインディングを実現するモダンなフロントエンドフレームワークです。その中でも、v-modelディレクティブは、特にフォーム要素の値とVueインスタンスのデータをリンクさせる便利な機能として広く使われています。しかし、v-modelディレクティブには初期値の取扱いと糖衣構文特有の挙動の2点で注意すべきポイントがあり、私も先日実装時に見事ハマってしまいました。本記事では、v-modelディレクティブの難しいポイントとその使い所について書いていきます。

v-modelディレクティブの概要と難所

  1. v-modelディレクティブの概要
    v-modelディレクティブは、Vue.jsのフォーム要素において双方向バインディングを提供します。フォーム要素の値をVueインスタンスのデータとリンクさせることができるため、フォームの入力値の変更が自動的にVueのデータに反映され、また逆方向にVueのデータの変更がフォーム要素に反映することもできる仕組みです。これにより、ユーザーの入力とアプリケーションの状態を簡単に同期させることができます。
<input v-model="text">
  1. 糖衣構文の落とし穴
    v-modelディレクティブは基本的には糖衣構文です。例えば、v-model="message"は、:value="message"@input="message = $event.target.value"という意味になります。糖衣構文は、コードの見た目を簡潔にするというメリットがありますが、糖衣構文によって省略された部分が意図した通りの挙動となっているかには注意が必要です。
<!-- v-modelとv-bind / v-onは基本的に同じことをしている -->
<template>
  <div>
    <!-- v-bindとv-onの記法 -->
    <input :value="message" @input="updateMessage" />
    <p>入力されたテキスト: {{ message }}</p>
  </div>
  <div>
    <!-- v-modelで双方向バインディング -->
    <input v-model="message" />
    <p>入力されたテキスト: {{ message }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: ""
    };
  },
  methods: {
    updateMessage(event) {
      this.message = event.target.value;
    }
  }
};
</script>

しかし以下のようなケースではどうでしょうか。

<template>
  <div>
    <!-- v-bindとv-onの組み合わせでカスタムな処理を行う場合 -->
    <input :value="formattedMessage" @input="onInput" />
    <button @click="onClick">Submit</button>
    <p>入力されたテキスト: {{ message }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: "",
      maxLength: 10
    };
  },
  computed: {
    formattedMessage() {
      // 入力されたテキストを一定の文字数でカットするカスタム処理
      return this.message.slice(0, this.maxLength);
    }
  },
  methods: {
    onInput(event) {
      // フォームの入力があった際にカスタム処理を行う
      this.message = event.target.value;
    },
    onClick() {
      // Submitボタンが押された際の処理
      console.log("Submitボタンが押されました");
    }
  }
};
</script>

上記の例では、フォームの入力値を一定の文字数でカットして表示するカスタム処理が行われています。v-bindを使ってinput要素のvalue属性にformattedMessageをバインドし、表示する値をカットした文字列として定義しています。また、v-onを使ってinputイベントをリッスンし、onInputメソッドを呼び出してフォームの値を更新しています。

このようなケースでは、v-bindとv-onを別々に使用してカスタムな処理を行う方が柔軟と言えるかもしれません。
こういったどちらを使うべきか悩むケースの判断は難しく、慣れと経験が必要であると感じました。

  1. 初期値の無視による問題点
    v-modelディレクティブは便利な機能ですが、例えば、フォームの初期値をVueのデータから取得する場合に、v-modelを使うだけではうまく動作しません。
    フォーム要素にはvaluecheckedselectedといった属性があり、これらの初期値はv-modelディレクティブによって無視されます。つまり、Vueインスタンスのデータとフォーム要素の初期値が異なる場合に問題が発生します。
    [Vue warn]: Template compilation error: Unnecessary value binding used alongside v-model. というエラーメッセージが出ます。
//?‍♀️これだとchecked属性の初期値が取れない
<FormCheckbox v-for="(item, index) in items" 
  :key="index" v-model="item.checked" :label="item.label" />

Vue.js公式ドキュメントにもこのような記述があります。

v-model はフォーム要素にある value、checked、selected 属性の初期値を無視します。 v-model は常に現在バインドされた JavaScript の状態を信頼できるソースとして扱います。初期値の宣言は JavaScript 側で、リアクティビティー API を使用して行ってください。

こうなる理由はプロパティは親コンポーネントからのデータの受け口であり、読み取り専用であるからです。

Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. といったエラーメッセージが出ます。

この問題を解決するためには、mountedフックやwatchプロパティを活用して初期値とVueのデータを一致させる方法もありますが、少々手間がかかるというデメリットがあります。

それならば以下のようにv-modelを使わずに、:valueで書く方がシンプルに解決するというケースが多いものと思われます。

//?‍♀️checked属性の初期値が取れる!
<FormCheckbox v-for="(item, index) in items" 
  :key="index" :value="item.checked" :label="item.label" />

私がハマったポイントはまさにここでした?

まとめ

v-modelディレクティブはVue.jsにおいて重要な機能であり、双方向バインディングを簡単に実現できる強力なツールです。しかし初期値の取り扱いや糖衣構文による挙動には若干のクセがあるため、使い所と適切な活用には注意が必要だと感じました。初期値を適切に取り扱いながら、v-modelディレクティブを上手に活用して効率的なフォーム処理を行っていきたいと思います。