Appearance
组件 v-model
v-model 可以在组件上使用以实现双向绑定。
首先让我们回忆一下 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> 组件内部需要做两件事:
- 将内部原生
<input>元素的valueattribute 绑定到modelValueprop - 当原生的
input事件触发时,触发一个携带了新值的update:modelValue自定义事件
这里是相应的代码:
vue
<!-- CustomInput.vue -->
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>现在 v-model 就可以在这个组件上正常工作了:
template
<CustomInput v-model="searchText" />另一种在组件内实现 v-model 的方式是使用一个可写的,同时具有 getter 和 setter 的 computed 属性。get 方法需返回 modelValue prop,而 set 方法需触发相应的事件:
vue
<!-- 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>v-model 的参数
默认情况下,v-model 在组件上都是使用 modelValue 作为 prop,并以 update:modelValue 作为对应的事件。我们可以通过给 v-model 指定一个参数来更改这些名字:
template
<MyComponent v-model:title="bookTitle" />在这个例子中,子组件应该有一个 title prop,并通过触发 update:title 事件更新父组件值:
vue
<!-- MyComponent.vue -->
<script setup>
defineProps(['title'])
defineEmits(['update:title'])
</script>
<template>
<input
type="text"
:value="title"
@input="$emit('update:title', $event.target.value)"
/>
</template>多个 v-model 绑定
利用刚才在 v-model 参数小节中学到的指定参数与事件名的技巧,我们可以在单个组件实例上创建多个 v-model 双向绑定。
组件上的每一个 v-model 都会同步不同的 prop,而无需额外的选项:
template
<UserName
v-model:first-name="first"
v-model:last-name="last"
/>vue
<!-- UserName.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>处理 v-model 修饰符
在学习输入绑定时,我们知道了 v-model 有一些内置的修饰符,例如 .trim,.number 和 .lazy。在某些场景下,你可能想要一个自定义组件的 v-model 支持自定义的修饰符。
我们来创建一个自定义的修饰符 capitalize,它会自动将 v-model 绑定输入的字符串值第一个字母转为大写:
template
<MyComponent v-model.capitalize="myText" />组件的 v-model 上所添加的修饰符,可以通过 modelModifiers prop 在组件内访问到。在下面的组件中,我们声明了 modelModifiers 这个 prop,它的默认值是一个空对象:
vue
<!-- MyComponent.vue -->
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) }
})
defineEmits(['update:modelValue'])
console.log(props.modelModifiers) // { capitalize: true }
</script>
<template>
<input
type="text"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>注意这里组件的 modelModifiers prop 包含了 capitalize 且其值为 true,因为它在模板中的 v-model 绑定 v-model.capitalize="myText" 上被使用了。
有了这个 prop,我们就可以检查 modelModifiers 对象的键,并编写一个处理函数来改变抛出的值。在下面的代码里,我们就是在每次 <input /> 元素触发 input 事件时将值的首字母大写:
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="modelValue" @input="emitValue" />
</template>对于又有参数又有修饰符的 v-model 绑定,生成的 prop 名将是 arg + "Modifiers"。举例来说:
template
<MyComponent v-model:title.capitalize="myText">相应的声明应该是:
js
const props = defineProps(['title', 'titleModifiers'])
defineEmits(['update:title'])
console.log(props.titleModifiers) // { capitalize: true }实际应用示例
基础自定义输入组件
vue
<!-- BaseInput.vue -->
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: String,
placeholder: String,
type: {
type: String,
default: 'text'
},
disabled: Boolean,
readonly: Boolean,
error: String
})
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
const inputValue = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
}
})
const handleFocus = (event) => {
emit('focus', event)
}
const handleBlur = (event) => {
emit('blur', event)
}
</script>
<template>
<div class="base-input">
<input
v-model="inputValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="{ 'has-error': error }"
@focus="handleFocus"
@blur="handleBlur"
/>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
</template>
<style scoped>
.base-input {
margin-bottom: 16px;
}
input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s;
}
input:focus {
outline: none;
border-color: #007bff;
}
input.has-error {
border-color: #dc3545;
}
input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.error-message {
color: #dc3545;
font-size: 12px;
margin-top: 4px;
}
</style>复杂表单组件
vue
<!-- UserForm.vue -->
<script setup>
import { ref, computed } from 'vue'
import BaseInput from './BaseInput.vue'
const props = defineProps({
user: {
type: Object,
default: () => ({
firstName: '',
lastName: '',
email: '',
phone: ''
})
}
})
const emit = defineEmits(['update:user'])
// 使用计算属性实现双向绑定
const firstName = computed({
get() {
return props.user.firstName
},
set(value) {
emit('update:user', { ...props.user, firstName: value })
}
})
const lastName = computed({
get() {
return props.user.lastName
},
set(value) {
emit('update:user', { ...props.user, lastName: value })
}
})
const email = computed({
get() {
return props.user.email
},
set(value) {
emit('update:user', { ...props.user, email: value })
}
})
const phone = computed({
get() {
return props.user.phone
},
set(value) {
emit('update:user', { ...props.user, phone: value })
}
})
// 表单验证
const errors = ref({})
const validateEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
const validatePhone = (phone) => {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
const validate = () => {
errors.value = {}
if (!firstName.value.trim()) {
errors.value.firstName = '请输入姓'
}
if (!lastName.value.trim()) {
errors.value.lastName = '请输入名'
}
if (!email.value.trim()) {
errors.value.email = '请输入邮箱'
} else if (!validateEmail(email.value)) {
errors.value.email = '请输入有效的邮箱地址'
}
if (!phone.value.trim()) {
errors.value.phone = '请输入手机号'
} else if (!validatePhone(phone.value)) {
errors.value.phone = '请输入有效的手机号'
}
return Object.keys(errors.value).length === 0
}
const handleSubmit = () => {
if (validate()) {
emit('submit', props.user)
}
}
defineExpose({
validate,
errors
})
</script>
<template>
<form @submit.prevent="handleSubmit" class="user-form">
<div class="form-row">
<div class="form-col">
<label>姓</label>
<BaseInput
v-model="firstName"
placeholder="请输入姓"
:error="errors.firstName"
/>
</div>
<div class="form-col">
<label>名</label>
<BaseInput
v-model="lastName"
placeholder="请输入名"
:error="errors.lastName"
/>
</div>
</div>
<label>邮箱</label>
<BaseInput
v-model="email"
type="email"
placeholder="请输入邮箱"
:error="errors.email"
/>
<label>手机号</label>
<BaseInput
v-model="phone"
type="tel"
placeholder="请输入手机号"
:error="errors.phone"
/>
<button type="submit" class="submit-btn">提交</button>
</form>
</template>
<style scoped>
.user-form {
max-width: 400px;
margin: 0 auto;
padding: 20px;
}
.form-row {
display: flex;
gap: 16px;
}
.form-col {
flex: 1;
}
label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: #333;
}
.submit-btn {
width: 100%;
padding: 12px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
}
.submit-btn:hover {
background-color: #0056b3;
}
</style>使用示例
vue
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
import BaseInput from './BaseInput.vue'
// 基础输入组件示例
const searchText = ref('')
const message = ref('')
// 复杂表单示例
const user = ref({
firstName: '',
lastName: '',
email: '',
phone: ''
})
const handleUserSubmit = (userData) => {
console.log('用户数据:', userData)
alert('表单提交成功!')
}
// 多个 v-model 示例
const firstName = ref('')
const lastName = ref('')
// 自定义修饰符示例
const capitalizedText = ref('')
</script>
<template>
<div class="app">
<h1>组件 v-model 示例</h1>
<!-- 基础输入组件 -->
<section>
<h2>基础输入组件</h2>
<BaseInput
v-model="searchText"
placeholder="搜索..."
@focus="console.log('输入框获得焦点')"
@blur="console.log('输入框失去焦点')"
/>
<p>搜索内容: {{ searchText }}</p>
<BaseInput
v-model="message"
placeholder="输入消息"
:error="!message ? '消息不能为空' : ''"
/>
<p>消息: {{ message }}</p>
</section>
<!-- 复杂表单组件 -->
<section>
<h2>复杂表单组件</h2>
<UserForm
v-model:user="user"
@submit="handleUserSubmit"
/>
<pre>{{ JSON.stringify(user, null, 2) }}</pre>
</section>
<!-- 多个 v-model -->
<section>
<h2>多个 v-model</h2>
<UserName
v-model:first-name="firstName"
v-model:last-name="lastName"
/>
<p>姓名: {{ firstName }} {{ lastName }}</p>
</section>
</div>
</template>
<style scoped>
.app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
section {
margin-bottom: 40px;
padding: 20px;
border: 1px solid #eee;
border-radius: 8px;
}
h1, h2 {
color: #333;
}
pre {
background-color: #f5f5f5;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
}
</style>最佳实践
1. 命名约定
- 使用
modelValue作为默认的 prop 名 - 使用
update:modelValue作为默认的事件名 - 对于具名的 v-model,使用描述性的参数名
vue
<!-- 好的做法 -->
<CustomInput v-model="value" />
<UserForm v-model:user="userData" />
<DatePicker v-model:start-date="startDate" v-model:end-date="endDate" />2. 类型安全
- 为 props 和 emits 提供类型声明
- 使用 TypeScript 获得更好的类型检查
vue
<script setup lang="ts">
interface User {
name: string
email: string
}
const props = defineProps<{
modelValue: User
}>()
const emit = defineEmits<{
'update:modelValue': [value: User]
}>()
</script>3. 验证和错误处理
- 在组件内部进行数据验证
- 提供清晰的错误信息
- 使用 computed 属性处理复杂的数据转换
vue
<script setup>
const props = defineProps({
modelValue: String,
required: Boolean,
minLength: Number
})
const emit = defineEmits(['update:modelValue'])
const error = computed(() => {
if (props.required && !props.modelValue) {
return '此字段为必填项'
}
if (props.minLength && props.modelValue.length < props.minLength) {
return `最少需要 ${props.minLength} 个字符`
}
return null
})
</script>4. 性能优化
- 避免在每次输入时都触发复杂的计算
- 使用防抖处理频繁的输入事件
- 合理使用
lazy修饰符
vue
<script setup>
import { debounce } from 'lodash-es'
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
// 防抖处理
const debouncedEmit = debounce((value) => {
emit('update:modelValue', value)
}, 300)
const handleInput = (event) => {
debouncedEmit(event.target.value)
}
</script>下一步
- 组件事件
- 组件 Props
- 透传 Attributes
- 插槽