Vue 3 v-model 在自定义组件上的双向绑定

FreeGuideOnline 最新 2026-07-05
```

等价于:

<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 使用 prop firstName 和事件 update:firstName
  • v-model:lastName 使用 prop lastName 和事件 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>
  • 无需额外的复杂配置,组件清晰可复用。
  • 参数 firstNamelastName 完全由你定义,语义化更强。

4. v-model 的自定义修饰符

Vue 3 内置了 trimnumberlazy 三个修饰符,但在自定义组件中你也可以创建自己的修饰符,例如将首字母大写、去除全部空格等。

修饰符会通过 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>typedisabled 等可以通过 $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"
/>