🥙

Vue.jsの v-model 正しく活用できていますか?

2024/09/27に公開
2

はじめに

こんにちは、からころです。
今回は、Vue.js でよく利用される v-model の記事について保守性などの観点から書いていこうと思います。想定している読者としては、Vue.js を触ったこと、見たことがある方全般です。v-model は利用方法を誤ると痛手を負いがちですが、非常に強力な味方だよということをお伝えできればと思います。また、Vue.js のバージョンは、記事執筆時点で最新の 3.5.8 を想定しています。

v-model とは

さて、v-model とはなんでしょうか?公式ドキュメントには、

コンポーネント上で v-model を使用すると双方向バインディングを実装できます。
https://ja.vuejs.org/guide/components/v-model

と記載されています。もう少し詳しく書くと、props/emit のショートハンド構文です。
例えば、以下のようなチェックボックスの状態をトグルするようなコンポーネントがあったとします。

index.vue
<script setup lang="ts">
import { ref } from 'vue'
const inputValue = ref(false) 
function handleChange(event: Event) {
  const target = event.target as HTMLInputElement
  inputValue.value = target.checked
}
</script>

<template>
  <div>
    <input
      type="checkbox"
      aria-label="チェックする"
      :value="inputValue"
      @change="handleChange"
    >
  </div>
</template>

このコンポーネントは、Changeイベントをフックし、handleChange() 関数を呼び出すことで、inputValue の値をトグルしているごく普通の実装かと思います。

よくある実装ですが、イベントが増えるたびに、イベントフック関数と、リアクティブな変数をそれぞれ別個で定義していくと、記述量が増えて大変です。

このような実装をショートハンドで記述できるのが v-model です。

index.vue
<script setup lang="ts">
import { ref } from 'vue'
const inputValue = ref<boolean>(false)
</script>

<template>
  <div>
    <input
   type="checkbox"
   aria-label="チェックする"
   v-model="inputValue"
   >
  </div>
</template>

v-model アンチパターン集

v-model を利用する場合、保守性の観点からアンチパターンに陥りがちな例がいくつか存在します。以下でアンチパターンの事例をいくつか紹介します。

1. 親コンポーネントから渡された props を子コンポーネントで v-model に設定する

親コンポーネントで定義したリアクティブなデータを子コンポーネントで、v-model に設定するような実装は避けるべきです。

例えば、以下のような親子コンポーネントがあります。

Child.vue
<script setup lang="ts">
export type PropsData = {
  msg: string
}
const props = defineProps<{
  data: PropsData
}>()
</script>

<template>
  <div>
    <input
      type="text"
      aria-label="入力する"
      v-model="props.data.msg"
    >
  </div>
</template>
Parent.vue
<script setup lang="ts">
import { type PropsData } from './Child.vue'
import Child from './Child.vue'

const parentData = defineModel<PropsData>({
  default: {
    msg: 'Hello World',
  },
  required: true,
})
</script>

<template>
  <div>
    <Child :data="parentData" />
  </div>
</template>

初心者がよくしがちなミスですが、この場合、コンポーネント同士は密結合していて、保守性や変更容易性に欠けてしまうことが想像されます。

具体的に説明すると、親コンポーネントで parentData を書き換えると、子コンポーネントのpropsのデータが書き変わること自体は問題ありませんが、子コンポーネントで v-model が設定されている、input要素 にユーザが入力すると、親コンポーネント上のデータまで意図せず書き変わってしまい、データの変更が追いづらくなってしまう問題があります。

2. 外部の Composition API や Store で管理しているリアクティブな値を v-model に設定する

1つ目の例に似ていますが、外部の Composition API や Store に状態を切り出している場合も同様です。コンポーネント外部のリアクティブな値を v-model に定義してしまうと、変更検知がどのタイミングで行われたかがわかりづらくなったり、状態の管理が煩雑になってしまいます。
watch などで、状態の変更検知で処理を行なっている場合などに、状態を参照するコンポーネントが増えれば増えるほど、イベントがどのタイミングで発火するか把握しづらくなりそうですね。
※ 外部の Composition API について、provide/inject などを利用せず、状態のスコープが閉じている場合については、問題ありません。

3. コンポーネント間でやりとりするオブジェクトを v-model に設定する

そもそも、コンポーネント間でやりとりするオブジェクトの場合、 v-model に設定すること自体避けた方がいいという話があります。Vue.js 3.4 で導入された、defineModel を使うと、子コンポーネントに対して明示的に v-model のデータを扱うことができるように設定できます。

下記のようなコンポーネントがある場合、

App.vue
<script setup lang="ts">
import Child from './Child.vue'

const text = defineModel<string>({ default: "Hello" })
</script>

<template>
  <Child :text="text"/>
</template>
Child.vue
<script setup lang="ts">
const props = defineProps<{ text: string }>()

props.text = "aaa"
</script>

<template>
  <input
  type="text"
   aria-label="入力する"
   :value="text"
  >
</template>

defineModelで定義した値が、プリミティブな値であれば子コンポーネントで書き換えようとした際には、readonly として扱われエラーになります。

Playground

しかし、オブジェクトを定義して参照を渡した場合は readonly として扱われなくなってしまいます。
そのため、v-model に設定する値は、基本的にプリミティブな値に限定した方がよさそうです。

また、defineModel() を利用していない場合でも、プリミティブな値であればエラーとして出力されます。

Playground

結局、v-model と props/emit どっちを使えばいいの?

ここまでアンチパターンを紹介してきましたが、「じゃあ、v-model つかわなきゃいいじゃん!」
「v-model使わない方がいいの?」という意見をもった方もいるかと思います。
先に結論だけいうと、v-model はできる限り使った方がいいです。
理由ですが v-model は、ただの props/emit のショートハンドではなく、パフォーマンスの最適化なども肩代わりしてくれているからです。

興味がある方は以下のソースをみると、実際、v-model は中でどういった最適化が行われているのかを確認することができます。

changeイベント時のv-modelの内部実装
https://github.com/vuejs/core/blob/a177092754642af2f98c33a4feffe8f198c3c950/packages/runtime-dom/src/directives/vModel.ts#L125-L151

おわりに

今回は、Vue.js の v-model を最大限に活用するためのノウハウを共有しました。個人的には、v-model は、「Simple is not easy」を色濃く体現している機能だと思います。より Vue.js の効果を高めていくためには知っておいた方がいいでしょう。

また、これは記事を書いている最中に気付いたことですが、10月19日(土)に開催される、Vue Fes Japan 2024 で v-model についてのLTがあるようなので そちらも要チェックです!

https://vuefes.jp/2024/sessions/tsuno

Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion

nuts.uedanuts.ueda
  1. 外部の Composition API や Store で管理しているリアクティブな値を v-model に設定する

の箇所に関して質問です。
「外部の Composition API」というのは以下のように宣言されたリアクティブな値という認識であっていますでしょうか?

export const reactiveValue = ref(0)

この場合このreactiveValueはシングルトンとして共有されるかと思うので、これを多くの箇所でv-modelにより更新されると、記事内の指摘の通り、変更を行った箇所の特定が難しくなるかと思います。

それに対して、「外部の Composition API」でも以下のように特定の処理などを共有する目的で作成した関数の内部でリアクティブな値を作成するような実装だと、これをimportしているコンポーネントが複数あっても、それぞれが独立したreactiveValueを持つことになると思います。

export function useFormValue {
  const reactiveValue = ref(0)

  // 共有したい処理とか....

  return {
    reactiveValue,
  }
}

この場合は、「外部の Composition API」のリアクティブな値をv-modelに設定することは問題無い認識ですが、理解は合ってますでしょうか?

からころ / karacoroからころ / karacoro

ご指摘ありがとうございます
そのパターンはprovide/injectで状態を共有している場合を除いて問題ないかと思います!