Vue v-model 元件綁定

v-model 不只可以用在原生表單元素上,也可以用在自訂元件上,實現父子元件之間的雙向資料綁定。

基本原理

在元件上使用 v-model 時,Vue 會做以下轉換:

<!-- 這個 -->
<CustomInput v-model="text" />

<!-- 等同於 -->
<CustomInput
  :modelValue="text"
  @update:modelValue="text = $event"
/>

所以子元件需要:

  1. 接收 modelValue prop
  2. 在值變化時發送 update:modelValue 事件

實作 v-model 元件

傳統方式

<!-- CustomInput.vue -->
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

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

使用:

<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'

const text = ref('')
</script>

<template>
  <CustomInput v-model="text" />
  <p>輸入的值:{{ text }}</p>
</template>

使用 computed(推薦)

使用 computed 的 getter/setter 可以更優雅地處理:

<!-- CustomInput.vue -->
<script setup>
import { computed } from 'vue'

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

const value = computed({
  get() {
    return props.modelValue
  },
  set(value) {
    emit('update:modelValue', value)
  }
})
</script>

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

使用 defineModel(Vue 3.4+)

Vue 3.4 引入了 defineModel,大幅簡化了 v-model 的實作:

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

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

就這麼簡單!defineModel 返回一個 ref,可以直接讀寫。

defineModel 的選項

<script setup>
// 設定預設值
const model = defineModel({ default: '' })

// 設定型別(執行時驗證)
const model = defineModel({ type: String, required: true })

// TypeScript 型別
const model = defineModel<string>()
const model = defineModel<string>({ required: true })
</script>

具名 v-model

有時候元件需要多個 v-model,可以使用具名綁定:

<!-- UserForm.vue -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<template>
  <input v-model="firstName" placeholder="名字">
  <input v-model="lastName" placeholder="姓氏">
</template>

使用:

<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'

const first = ref('John')
const last = ref('Doe')
</script>

<template>
  <UserForm v-model:firstName="first" v-model:lastName="last" />
  <p>全名:{{ first }} {{ last }}</p>
</template>

不使用 defineModel 的寫法

<!-- UserForm.vue -->
<script setup>
defineProps({
  firstName: String,
  lastName: String
})

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

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

v-model 修飾符

內建修飾符

<template>
  <!-- .lazy:在 change 事件後更新 -->
  <CustomInput v-model.lazy="text" />

  <!-- .number:轉為數字 -->
  <CustomInput v-model.number="age" />

  <!-- .trim:去除首尾空白 -->
  <CustomInput v-model.trim="name" />
</template>

自訂修飾符

可以建立自訂的修飾符:

<!-- CustomInput.vue -->
<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    // 如果有 capitalize 修飾符,首字母大寫
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})
</script>

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

使用:

<template>
  <CustomInput v-model.capitalize="name" />
</template>

具名 v-model 的修飾符

<script setup>
const [firstName, firstNameModifiers] = defineModel('firstName')
const [lastName, lastNameModifiers] = defineModel('lastName')

// 檢查修飾符
console.log(firstNameModifiers.capitalize)  // true/false
</script>
<template>
  <UserForm
    v-model:firstName.capitalize="first"
    v-model:lastName.uppercase="last"
  />
</template>

實際範例

自訂選擇器

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

defineProps({
  options: {
    type: Array,
    required: true
  },
  placeholder: {
    type: String,
    default: '請選擇'
  }
})
</script>

<template>
  <select v-model="model">
    <option value="" disabled>{{ placeholder }}</option>
    <option
      v-for="option in options"
      :key="option.value"
      :value="option.value"
    >
      {{ option.label }}
    </option>
  </select>
</template>

自訂核取方塊

<!-- CustomCheckbox.vue -->
<script setup>
const model = defineModel({ type: Boolean, default: false })

defineProps({
  label: String
})
</script>

<template>
  <label class="checkbox">
    <input type="checkbox" v-model="model">
    <span class="checkmark"></span>
    <span class="label">{{ label }}</span>
  </label>
</template>

<style scoped>
.checkbox {
  display: flex;
  align-items: center;
  cursor: pointer;
}

.checkbox input {
  display: none;
}

.checkmark {
  width: 20px;
  height: 20px;
  border: 2px solid #ddd;
  border-radius: 4px;
  margin-right: 8px;
}

.checkbox input:checked + .checkmark {
  background-color: #42b883;
  border-color: #42b883;
}

.checkbox input:checked + .checkmark::after {
  content: '✓';
  color: white;
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
}
</style>

評分元件

<!-- StarRating.vue -->
<script setup>
import { computed } from 'vue'

const model = defineModel({ type: Number, default: 0 })

const props = defineProps({
  max: {
    type: Number,
    default: 5
  },
  readonly: {
    type: Boolean,
    default: false
  }
})

const stars = computed(() => {
  return Array.from({ length: props.max }, (_, i) => i + 1)
})

function setRating(value) {
  if (!props.readonly) {
    model.value = value
  }
}
</script>

<template>
  <div class="star-rating" :class="{ readonly }">
    <span
      v-for="star in stars"
      :key="star"
      class="star"
      :class="{ filled: star <= model }"
      @click="setRating(star)"
    >
      ★
    </span>
  </div>
</template>

<style scoped>
.star-rating {
  display: inline-flex;
}

.star {
  font-size: 24px;
  color: #ddd;
  cursor: pointer;
  transition: color 0.2s;
}

.star.filled {
  color: #ffc107;
}

.star-rating:not(.readonly) .star:hover,
.star-rating:not(.readonly) .star:hover ~ .star {
  color: #ffc107;
}

.readonly .star {
  cursor: default;
}
</style>

使用:

<script setup>
import { ref } from 'vue'
import StarRating from './StarRating.vue'

const rating = ref(3)
</script>

<template>
  <StarRating v-model="rating" :max="5" />
  <p>評分:{{ rating }}</p>
</template>

標籤輸入

<!-- TagInput.vue -->
<script setup>
import { ref } from 'vue'

const model = defineModel({ type: Array, default: () => [] })
const inputValue = ref('')

function addTag() {
  const tag = inputValue.value.trim()
  if (tag && !model.value.includes(tag)) {
    model.value = [...model.value, tag]
  }
  inputValue.value = ''
}

function removeTag(index) {
  model.value = model.value.filter((_, i) => i !== index)
}

function handleKeydown(e) {
  if (e.key === 'Enter') {
    e.preventDefault()
    addTag()
  } else if (e.key === 'Backspace' && !inputValue.value && model.value.length) {
    removeTag(model.value.length - 1)
  }
}
</script>

<template>
  <div class="tag-input">
    <span v-for="(tag, index) in model" :key="index" class="tag">
      {{ tag }}
      <button type="button" @click="removeTag(index)">×</button>
    </span>
    <input
      v-model="inputValue"
      @keydown="handleKeydown"
      @blur="addTag"
      placeholder="輸入標籤後按 Enter"
    >
  </div>
</template>

<style scoped>
.tag-input {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.tag {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 4px 8px;
  background-color: #e0f2e9;
  border-radius: 4px;
}

.tag button {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 16px;
  line-height: 1;
}

.tag-input input {
  flex: 1;
  min-width: 100px;
  border: none;
  outline: none;
}
</style>

使用:

<script setup>
import { ref } from 'vue'
import TagInput from './TagInput.vue'

const tags = ref(['Vue', 'JavaScript'])
</script>

<template>
  <TagInput v-model="tags" />
  <p>標籤:{{ tags.join(', ') }}</p>
</template>

TypeScript 支援

<script setup lang="ts">
// 基本型別
const model = defineModel<string>()

// 必填
const model = defineModel<string>({ required: true })

// 預設值
const model = defineModel<number>({ default: 0 })

// 具名 + 型別
const firstName = defineModel<string>('firstName')

// 修飾符
const [model, modifiers] = defineModel<string, 'capitalize' | 'uppercase'>()
</script>