本文へジャンプ

コンポーネントの v-model

基本的な使い方

コンポーネント上で v-model を使用すると双方向バインディングを実装できます。

Vue 3.4 以降は、defineModel() マクロを使うことが推奨されています:

vue
<!-- Child.vue -->
<script setup>
const model = defineModel()

function update() {
  model.value++
}
</script>

<template>
  <div>Parent bound v-model is: {{ model }}</div>
  <button @click="update">Increment</button>
</template>

親は v-model で値をバインドできます:

template
<!-- Parent.vue -->
<Child v-model="countModel" />

defineModel() が返す値は ref です。他の ref と同じようにアクセスしたり変更したりできますが、親の値とローカルの値の双方向バインディングとして動作する点が異なります:

  • その .value は親の v-model にバインドされた値と同期される。
  • 子が .value を変更すると、親にバインドされている値も更新される。

つまり、v-model を使ってネイティブの入力要素にこの ref をバインドすることもでき、同じ v-model の使い方を提供しながら、ネイティブの入力要素をラップするのが簡単になります:

vue
<script setup>
const model = defineModel()
</script>

<template>
  <input v-model="model" />
</template>

Playground で試す

内部の仕組み

defineModel は便利なマクロです。コンパイラーはこれを次のように展開します:

  • modelValue という名前の props: ローカル ref の値が同期されます。
  • update:modelValue という名前のイベント: ローカル ref の値が変更された時に発行されます。

3.4 以前は、上記の子コンポーネントはこのように実装されていました:

vue
<!-- Child.vue -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

すると、親コンポーネントの v-model="foo" は次のようにコンパイルされます:

template
<!-- Parent.vue -->
<Child
  :modelValue="foo"
  @update:modelValue="$event => (foo = $event)"
/>

見ての通り、かなり冗長です。ただ、内部で何が起こっているのかを理解するのに役立ちます。

defineModel は props を宣言するので、元となる props のオプションを defineModel に渡して宣言できます:

js
// v-model を必須にする
const model = defineModel({ required: true })

// デフォルト値を提供する
const model = defineModel({ default: 0 })

WARNING

もし defineModel props に default 値を指定し、親コンポーネントからこの props に何も値を与えなかった場合、親と子のコンポーネント間で同期が取れなくなる可能性があります。以下の例では、親コンポーネントの myRef は undefined ですが、子コンポーネントの model は 1 です:

子コンポーネント:

js
const model = defineModel({ default: 1 })

親コンポーネント:

js
const myRef = ref()
html
<Child v-model="myRef"></Child>

最初に、ネイティブ要素で v-model がどのように使われるかを再確認してみましょう:

template
<input v-model="searchText" />

テンプレートコンパイラーはその内部で、 v-model を冗長な同じ内容に展開してくれます。つまり、上のコードは以下と同じことをするわけです:

template
<input
  :value="searchText"
  @input="searchText = $event.target.value"
/>

コンポーネントで使用する場合はその代わり、v-model は以下のように展開されます:

template
<CustomInput
  :model-value="searchText"
  @update:model-value="newValue => searchText = newValue"
/>

しかし、これを実際に動作させるためには、<CustomInput> コンポーネントは次の 2 つのことをしなければなりません:

  1. ネイティブの <input> 要素の value 属性を、modelValue props にバインドする
  2. ネイティブの input イベントがトリガーされたら、新しい値で update:modelValue カスタムイベントを発行する

実際には次のようになります:

vue
<!-- CustomInput.vue -->
<script>
export default {
  props: ['modelValue'],
  emits: ['update:modelValue']
}
</script>

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

これで v-model はこのコンポーネントで完全に動作するはずです:

template
<CustomInput v-model="searchText" />

Playground で試す

このコンポーネントで v-model を実装するもう 1 つの方法は、getter と setter の両方を持つ、書き込み可能な computed プロパティを使用することです。get メソッドは modelValue プロパティを返し、set メソッドは対応するイベントを発行する必要があります:

vue
<!-- CustomInput.vue -->
<script>
export default {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  computed: {
    value: {
      get() {
        return this.modelValue
      },
      set(value) {
        this.$emit('update:modelValue', value)
      }
    }
  }
}
</script>

<template>
  <input v-model="value" />
</template>

v-model の引数

コンポーネントの v-model にも引数を指定できます:

template
<MyComponent v-model:title="bookTitle" />

子コンポーネントでは、defineModel() の第一引数に文字列を渡すことで、対応する引数をサポートできます:

vue
<!-- MyComponent.vue -->
<script setup>
const title = defineModel('title')
</script>

<template>
  <input type="text" v-model="title" />
</template>

Playground で試す

props のオプションも必要な場合は、モデル名の後に渡します:

js
const title = defineModel('title', { required: true })
3.4 以前の使用法
vue
<!-- MyComponent.vue -->
<script setup>
defineProps({
  title: {
    required: true
  }
})
defineEmits(['update:title'])
</script>

<template>
  <input
    type="text"
    :value="title"
    @input="$emit('update:title', $event.target.value)"
  />
</template>

Playground で試す

この場合、デフォルトの modelValue props と update:modelValue イベントの代わりに、子コンポーネントは title props を受け取り、親コンポーネントの値を更新するためには update:title イベントを発行します:

vue
<!-- MyComponent.vue -->
<script>
export default {
  props: ['title'],
  emits: ['update:title']
}
</script>

<template>
  <input
    type="text"
    :value="title"
    @input="$emit('update:title', $event.target.value)"
  />
</template>

Playground で試す

複数の v-model のバインディング

先ほど v-model の引数で学んだように、特定の props とイベントをターゲットにする機能を活用することで、1 つのコンポーネントインスタンスに複数の v-model バインディングを作成できるようになりました。

v-model は、コンポーネントで追加のオプションを必要とせずに、別の props に同期します:

template
<UserName
  v-model:first-name="first"
  v-model:last-name="last"
/>
vue
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<template>
  <input type="text" v-model="firstName" />
  <input type="text" v-model="lastName" />
</template>

Playground で試す

3.4 以前の使用法
vue
<script setup>
defineProps({
  firstName: String,
  lastName: String
})

defineEmits(['update:firstName', 'update:lastName'])
</script>

<template>
  <input
    type="text"
    :value="firstName"
    @input="$emit('update:firstName', $event.target.value)"
  />
  <input
    type="text"
    :value="lastName"
    @input="$emit('update:lastName', $event.target.value)"
  />
</template>

Playground で試す

vue
<script>
export default {
  props: {
    firstName: String,
    lastName: String
  },
  emits: ['update:firstName', 'update:lastName']
}
</script>

<template>
  <input
    type="text"
    :value="firstName"
    @input="$emit('update:firstName', $event.target.value)"
  />
  <input
    type="text"
    :value="lastName"
    @input="$emit('update:lastName', $event.target.value)"
  />
</template>

Playground で試す

v-model 修飾子の処理

フォームの入力バインディングについて学習しているときに、v-model には 組み込みの修飾子.trim, .number, .lazy)があることを確認しました。場合によっては、カスタム入力コンポーネントの v-model でカスタム修飾子をサポートしたいかもしれません。

カスタム修飾子の例として、v-model バインディングによって提供される文字列の最初の文字を大文字にする capitalize を作成してみましょう:

template
<MyComponent v-model.capitalize="myText" />

コンポーネントの v-model に追加された修飾子は、defineModel() の戻り値を次のように分割代入することで、子コンポーネント内でアクセスできます:

vue
<script setup>
const [model, modifiers] = defineModel()

console.log(modifiers) // { capitalize: true }
</script>

<template>
  <input type="text" v-model="model" />
</template>

修飾子に基づいて値の読み書きを条件付きで調整するために、defineModel()getset オプションを渡すことができます。これら 2 つのオプションは、モデルの ref の読み取り・設定時に値を受け取り、変換された値を返す必要があります。以下は set オプションを使って capitalize 修飾子を実装する方法です:

vue
<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})
</script>

<template>
  <input type="text" v-model="model" />
</template>

Playground で試す

3.4 以前の使用法
vue
<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})

const emit = defineEmits(['update:modelValue'])

function emitValue(e) {
  let value = e.target.value
  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  emit('update:modelValue', value)
}
</script>

<template>
  <input type="text" :value="props.modelValue" @input="emitValue" />
</template>

Playground で試す

コンポーネント v-model に追加された修飾子は、modelModifiers props を通じてコンポーネントに提供されます。以下の例では、modelModifiers props を含むコンポーネントを作成しています。これはデフォルトでは空のオブジェクトです:

vue
<script>
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:modelValue'],
  created() {
    console.log(this.modelModifiers) // { capitalize: true }
  }
}
</script>

<template>
  <input
    type="text"
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

コンポーネントの modelModifiers props に capitalize が含まれており、その値が true であることに注目してください。これは、v-model バインディングに v-model.capitalize="myText" が設定されているためです。

これで props の設定ができたので、modelModifiers オブジェクトのキーをチェックして、発行された値を変更するハンドラーを書くことができます。以下のコードでは、<input /> 要素が input イベントを発火するたびに、文字列の最初を大文字にしています。

vue
<script>
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:modelValue'],
  methods: {
    emitValue(e) {
      let value = e.target.value
      if (this.modelModifiers.capitalize) {
        value = value.charAt(0).toUpperCase() + value.slice(1)
      }
      this.$emit('update:modelValue', value)
    }
  }
}
</script>

<template>
  <input type="text" :value="modelValue" @input="emitValue" />
</template>

Playground で試す

引数を持つ v-model の修飾子

引数と修飾子の両方を持つ v-model バインディングの場合、生成される props の名前は arg + "Modifiers" になります。例えば:

template
<MyComponent v-model:title.capitalize="myText">

対応する宣言は次のとおりです:

js
export default {
  props: ['title', 'titleModifiers'],
  emits: ['update:title'],
  created() {
    console.log(this.titleModifiers) // { capitalize: true }
  }
}

以下は異なる引数を持つ複数の v-model で修飾子を使用するもう 1 つの例です:

template
<UserName
  v-model:first-name.capitalize="first"
  v-model:last-name.uppercase="last"
/>
vue
<script setup>
const [firstName, firstNameModifiers] = defineModel('firstName')
const [lastName, lastNameModifiers] = defineModel('lastName')

console.log(firstNameModifiers) // { capitalize: true }
console.log(lastNameModifiers) // { uppercase: true }
</script>
3.4 以前の使用法
vue
<script setup>
const props = defineProps({
firstName: String,
lastName: String,
firstNameModifiers: { default: () => ({}) },
lastNameModifiers: { default: () => ({}) }
})
defineEmits(['update:firstName', 'update:lastName'])

console.log(props.firstNameModifiers) // { capitalize: true }
console.log(props.lastNameModifiers) // { uppercase: true }
</script>
vue
<script>
export default {
  props: {
    firstName: String,
    lastName: String,
    firstNameModifiers: {
      default: () => ({})
    },
    lastNameModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:firstName', 'update:lastName'],
  created() {
    console.log(this.firstNameModifiers) // { capitalize: true }
    console.log(this.lastNameModifiers) // { uppercase: true }
  }
}
</script>
コンポーネントの v-modelが読み込まれました