问题场景
同事小张从 Vue 2 迁移到 Vue 3 + Composition API,遇到一个常见需求:一个表单组件需要同时绑定「标题」「内容」「开关状态」三个数据。
Vue 2 时代他这样写:
vue
<!-- 父组件 -->
<MyForm
:title.sync="title"
:content.sync="content"
:visible.sync="visible"
/>
Vue 3 里 .sync 被废了,官方文档说用 v-model 传多个绑定。小张开写:
vue
<!-- 父组件 -->
<MyForm
v-model:title="title"
v-model:content="content"
v-model:visible="visible"
/>
子组件直接套用旧写法:
vue
<script setup>
const props = defineProps({
title: String,
content: String,
visible: Boolean,
})
const emit = defineEmits(['update:title', 'update:content', 'update:visible'])
</script>
结果跑起来没问题,但被 Code Review 打回来 ------ 你在 defineProps 里声明了默认值吗?数据流清晰吗?这里有个潜在的双向绑定「黑洞」问题。
原因分析
1. defineModel 的正确用法
Vue 3.4+ 推出了 defineModel 宏,专治 v-model 多绑定场景。先看一个简单版:
vue
<script setup>
// 默认 v-model
const model = defineModel()
// 等价于:
// const props = defineProps({ modelValue: String })
// const emit = defineEmits(['update:modelValue'])
</script>
但大多数人不知道的是:defineModel 返回的是个 ref,可以加配置,还能定义多个绑定。
2. 没有使用 defineModel 的隐患
小张那种手动 defineProps + emit 写法在简单场景没问题,但在这些场景下会翻车:
- 双向绑定的默认值 :
defineProps声明default后,父组件首次没传值时,子组件直接改props会触发「不期望的突变」警告 - 深层对象双向绑定 :
v-model:config="config"时,子组件改了嵌套属性,父组件不感知 - TS 推导丢失:手动声明类型容易和运行时行为不一致
解决方案:使用 defineModel 重构
第一步 :用 defineModel 替代手动 defineProps + emit
vue
<!-- MyForm.vue -->
<script setup lang="ts">
// 每个 v-model 对应一个 defineModel
const title = defineModel<string>('title', { required: true })
const content = defineModel<string>('content', { default: '' })
const visible = defineModel<boolean>('visible', { default: false })
</script>
<template>
<div>
<input v-model="title" />
<textarea v-model="content" />
<Switch :model-value="visible" @update:model-value="val => visible = val" />
</div>
</template>
第二步 :在模板里直接用 v-model 绑定 defineModel 返回的 ref
特别注意:defineModel 返回的是可写的 ref ,在模板里可以直接写 v-model="title",不需要再写 :value="title" @input="..."。
第三步:给每个 defineModel 指定类型和默认值
typescript
// 带类型 + 转换器
const count = defineModel<number>('count', {
default: 0,
// 自定义转换:始终读成 number
set(value: string | number) {
return Number(value) || 0
}
})
避坑指南
🔴 坑 1:defineModel 的名字要和 v-model 绑定对上
父组件写了 v-model:title="title",子组件里必须用 defineModel('title'),名字完全一致。少一个字母都不行。
🔴 坑 2:不要混用 defineModel 和手动 props
vue
<script setup>
// ❌ 这样会冲突
const props = defineProps({ title: String })
const titleModel = defineModel('title')
// ✅ 选一个:全用 defineModel
const title = defineModel('title')
</script>
🔴 坑 3:复杂对象的深拷贝问题
当 v-model 绑定一个对象时,子组件直接改对象属性不会触发父组件更新。正确做法:
vue
<script setup>
const config = defineModel<{ theme: string; fontSize: number }>('config', {
default: () => ({ theme: 'light', fontSize: 14 }),
})
function updateTheme(theme: string) {
// ✅ 必须重新赋值(触发 setter)
config.value = { ...config.value, theme }
}
</script>
🔴 坑 4:类型定义用泛型
typescript
// ✅ 推荐:显式类型
const title = defineModel<string>('title')
// ❌ 不推荐:类型推断有时不准
const title = defineModel('title')
要点总结
| 场景 | 推荐写法 |
|---|---|
| 单个 v-model | const model = defineModel() |
| 多个 v-model | const title = defineModel('title') |
| 带默认值 | defineModel('x', { default: 0 }) |
| 带类型 | defineModel<string>('x') |
| 自定义 setter | defineModel('x', { set(val) { ... } }) |
一句话记忆 :Vue 3.4+ 写子组件双向绑定,别再手写 props + emit,defineModel 一键搞定,还自带类型推导和默认值支持。