Vue 3 v-model 在自定义组件上的双向绑定
等价于:
<input
:value="text"
@input="text = $event.target.value"
/>
在自定义组件上,v-model 同样是语法糖,它会被展开为一个 属性 和一个 事件。默认情况下:
- 属性名:
modelValue - 事件名:
update:modelValue
这意味着:
<MyComponent v-model="msg" />
底层等价于:
<MyComponent
:modelValue="msg"
@update:modelValue="msg = $event"
/>
因此,要让自定义组件支持 v-model,你需要在组件内部:
- 声明
modelValue这个 prop - 在适当的时候触发
update:modelValue事件,将新值作为参数传递
2. 实现最简单的自定义 v-model 组件
下面以一个计数器组件为例,它展示当前数值并提供加减按钮。
2.1 子组件 BaseCounter.vue
<template>
<div class="counter">
<button @click="decrement">-</button>
<span>{{ modelValue }}</span>
<button @click="increment">+</button>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
modelValue: {
type: Number,
required: true
}
})
const emit = defineEmits(['update:modelValue'])
function decrement() {
emit('update:modelValue', props.modelValue - 1)
}
function increment() {
emit('update:modelValue', props.modelValue + 1)
}
</script>
关键点:
- 使用
defineProps声明modelValue(建议明确类型和必要性,提升健壮性) - 使用
defineEmits声明update:modelValue事件 - 子组件不直接修改
modelValue(prop 是只读的),而是通过触发事件,让父组件更新数据
2.2 父组件中使用
<template>
<div>
<p>当前计数:{{ count }}</p>
<BaseCounter v-model="count" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import BaseCounter from './BaseCounter.vue'
const count = ref(0)
</script>
当点击 + / - 按钮时,父组件的 count 会同步更新,形成干净的双向绑定。
3. 自定义 v-model 的参数:支持多个双向绑定
Vue 3 允许通过参数为 v-model 指定不同的 prop 和事件名,从而让单个组件拥有多个 v-model 绑定。
语法:
<MyComponent v-model:firstName="first" v-model:lastName="last" />
v-model:firstName使用 propfirstName和事件update:firstNamev-model:lastName使用 proplastName和事件update:lastName
3.1 示例:用户姓名组件 UserNameEditor.vue
<template>
<div>
<label>姓:<input :value="firstName" @input="handleFirst" /></label>
<label>名:<input :value="lastName" @input="handleLast" /></label>
</div>
</template>
<script setup>
const props = defineProps({
firstName: String,
lastName: String
})
const emit = defineEmits(['update:firstName', 'update:lastName'])
function handleFirst(e) {
emit('update:firstName', e.target.value)
}
function handleLast(e) {
emit('update:lastName', e.target.value)
}
</script>
3.2 父组件调用
<template>
<UserNameEditor
v-model:firstName="user.first"
v-model:lastName="user.last"
/>
</template>
<script setup>
import { reactive } from 'vue'
const user = reactive({ first: '张', last: '三' })
</script>
- 无需额外的复杂配置,组件清晰可复用。
- 参数
firstName和lastName完全由你定义,语义化更强。
4. v-model 的自定义修饰符
Vue 3 内置了 trim、number、lazy 三个修饰符,但在自定义组件中你也可以创建自己的修饰符,例如将首字母大写、去除全部空格等。
修饰符会通过 modelModifiers prop 传入组件(如果使用参数则会是 ${arg}Modifiers)。
4.1 创建一个支持 capitalize 修饰符的输入组件
<template>
<input :value="modelValue" @input="handleInput" />
</template>
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['update:modelValue'])
function handleInput(e) {
let value = e.target.value
// 如果父组件使用了 capitalize 修饰符,将首字母大写
if (props.modelModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
emit('update:modelValue', value)
}
</script>
父组件使用:
<MyInput v-model.capitalize="name" />
当用户输入时,组件内部会检测 modelModifiers.capitalize 并自动将首字母转换为大写,然后再向上传递新值。
4.2 带参数的修饰符
如果使用 v-model:content,则修饰符对象会是 contentModifiers prop。
示例:
<TextArea v-model:content.capitalize="text" />
子组件中需要声明:
defineProps({
content: String,
contentModifiers: { type: Object, default: () => ({}) }
})
并在 emit('update:content', ...) 前根据修饰符处理值。
5. 使用组合式函数封装 v-model 逻辑
当多个组件需要相同的双向绑定逻辑时,可以将 v-model 的 prop 和事件封装为可复用的组合式函数(composable)。
示例:useModelWrapper.ts
import { computed, getCurrentInstance } from 'vue'
export function useModelWrapper(props, emit, name = 'modelValue') {
return computed({
get: () => props[name],
set: (value) => emit(`update:${name}`, value)
})
}
在组件中使用:
<template>
<input v-model="valueProxy" />
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { useModelWrapper } from './useModelWrapper'
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
const valueProxy = useModelWrapper(props, emit)
</script>
这样通过 v-model 绑定的计算属性代理,可以让你在模板中继续使用 v-model="valueProxy",内部自动完成 prop 到事件的同步。
6. 对比 Vue 2 与 Vue 3 的 v-model 变化
了解演变有助于更深入理解新特性:
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 默认 prop/event | value / input |
modelValue / update:modelValue |
| 自定义名称 | 需通过组件选项 model { prop: 'xxx', event: 'yyy' } |
直接使用 v-model:xxx 即可 |
| 多重 v-model | 不支持,需要用 .sync 修饰符 |
原生支持多个 v-model 绑定 |
| 自定义修饰符 | 不易处理,通常需要单独写逻辑 | 官方提供 modelModifiers 机制,结构清晰 |
Vue 3 的改进让组件的接口更加自动化和显式,减少了样板代码。
7. 常见陷阱与最佳实践
- prop 不可直接修改:永远通过
emit('update:xxx')通知父组件更新,保证单向数据流。 - 缺失事件声明导致更新失效:务必在
defineEmits中列出所有update:propName事件。 - Ref 与 Reactive 的自动解包:当父组件传递的是一个 ref 时,
v-model能直接更新 ref 的值;如果传递的是 reactive 对象的属性,同样可以正常工作。 - TypeScript 加持:使用 TypeScript 时,可以利用类型参数让 props 更安全。
示例类型定义:
defineProps<{
modelValue: string
modelModifiers?: { capitalize?: boolean }
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
- 性能优化:避免在
@input中频繁触发事件,可以考虑使用防抖。不过若只是更新父组件变量,Vue 的批量更新机制一般无需额外优化。 - 可访问性:确保你封装的组件保留原生元素属性,例如
<input>的type、disabled等可以通过$attrs透传。
8. 完整示例:一个支持 search 和 filters 双向绑定的搜索栏
最后,我们综合上述知识,构建一个带有搜索关键词和多个筛选选项的组件。
SearchBar.vue
<template>
<div class="search-bar">
<input
:value="keyword"
@input="onKeywordInput"
placeholder="搜索..."
/>
<label>
<input
type="checkbox"
:checked="onlyInStock"
@change="onStockToggle"
/> 仅显示有货
</label>
<label>
<input
type="checkbox"
:checked="onSale"
@change="onSaleToggle"
/> 促销商品
</label>
</div>
</template>
<script setup>
import { toRef } from 'vue'
const props = defineProps({
keyword: String,
onlyInStock: Boolean,
onSale: Boolean,
// 修饰符对象(如果使用)
keywordModifiers: { type: Object, default: () => ({}) }
})
const emit = defineEmits([
'update:keyword',
'update:onlyInStock',
'update:onSale'
])
function onKeywordInput(e) {
let val = e.target.value
if (props.keywordModifiers?.trim) val = val.trim()
emit('update:keyword', val)
}
function onStockToggle(e) {
emit('update:onlyInStock', e.target.checked)
}
function onSaleToggle(e) {
emit('update:onSale', e.target.checked)
}
</script>
父组件使用:
<SearchBar
v-model:keyword.trim="search.keyword"
v-model:onlyInStock="search.onlyInStock"
v-model:onSale="search.onSale"
/>