Vue v-model 元件綁定
v-model 不只可以用在原生表單元素上,也可以用在自訂元件上,實現父子元件之間的雙向資料綁定。
基本原理
在元件上使用 v-model 時,Vue 會做以下轉換:
<!-- 這個 -->
<CustomInput v-model="text" />
<!-- 等同於 -->
<CustomInput
:modelValue="text"
@update:modelValue="text = $event"
/>
所以子元件需要:
- 接收
modelValueprop - 在值變化時發送
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>