Vue3的defineModel到底怎么用?深层嵌套、多绑定、TypeScript这几个坑你踩过吗?
defineModel是为了解决什么麻烦
先回忆一下Vue3刚推出那阵,我们要在自定义组件里实现v-model双向绑定,得干多少活?父组件传下来一个value,得写个props定义接收;子组件要改的时候,还得手动emit('update:value')传新值回去,要是做两个方向绑定,比如一个输入框既要收value又要发input以外的update:xxx?不对,单值双向绑定默认是update:modelValue,但你要显式声明modelValue的prop和update:modelValue的emit,一个都不能少。 那要是做更复杂的,比如父组件要传个表单对象,子组件里要改表单里的某个嵌套属性?哦对了,旧写法里不能直接在子组件里改prop的嵌套属性——虽然Vue3的响应式是Proxy做的,改嵌套属性Vue不会报错,甚至父组件的state会跟着变,但这违反了“单向数据流”的最佳实践对吧?官方文档反复强调过,子组件不能直接修改父组件传下来的props,哪怕是深层的。 那旧写法怎么处理深层嵌套?要么在父组件里把整个嵌套属性解构或者转成ref传给子组件,要么子组件里用computed的get/set,get拿prop的嵌套值,set的时候手动emit整个对象或者整个路径的新值回去——反正都挺啰嗦的,每次写个双向绑定组件都得重复那几行代码。 直到Vue3.3版本,官方终于把defineModel放进了稳定API里,这才把这些麻烦都解决了大半,你可能之前在beta版本或者一些教程里见过类似的写法,不过稳定版之后,语法和一些小细节又调整过一次,所以得注意看具体版本的差异,不过现在主流用的都是Vue3.3及以上了,不用太纠结beta的东西。
上手最快的入门案例:单值双向绑定
先从最简单的开始,写一个自定义的文本输入组件,不用你写props不用写emit,直接用defineModel就行。 假设我们要做一个带提示文字的输入框组件,叫HintInput.vue: 首先得注意,defineModel是一个编译宏,和defineProps、defineEmits一样,不用从vue里引入,直接在script setup里用就行。 定义双向绑定的变量,就像定义一个普通的ref一样,但调用的是defineModel():
<script setup>
const hint = defineProps(['hint'])
// 这里的modelValue是默认的v-model绑定的prop名
// 其实defineModel()可以不带参数,默认绑定的就是modelValue,也默认注册update:modelValue的emit
const inputVal = defineModel()
</script>
<template>
<div class="hint-input">
<span class="hint-text">{{ hint }}</span>
<input type="text" v-model="inputVal" />
</div>
</template>
<style scoped>
/* 随便加个样式,看起来像回事儿 */
.hint-input {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.hint-text {
color: #999;
font-size: 12px;
}
.hint-input input {
flex: 1;
border: none;
outline: none;
}
</style>
父组件里怎么用?就像用原生的input标签一样,直接v-model就行:
<script setup>
import { ref } from 'vue'
import HintInput from './HintInput.vue'
const userName = ref('')
</script>
<template>
<div class="parent">
<h2>用户信息填写</h2>
<HintInput hint="请输入用户名" v-model="userName" />
<p>当前输入的用户名:{{ userName }}</p>
</div>
</template>
<style scoped>
.parent {
padding: 20px;
}
</style>
你看,是不是比以前简单多了?以前的话,子组件里得写:
<script setup>
const props = defineProps(['hint', 'modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<div class="hint-input">
<span class="hint-text">{{ hint }}</span>
<input
type="text"
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</div>
</template>
现在省了四五行重复的代码,而且模板里也不用写@input和:value分开的写法了,直接v-model绑定defineModel返回的变量,体验和用原生变量一模一样。
进阶玩法1:自定义v-model的prop名
刚才说的是默认绑定modelValue,但有时候我们需要一个组件有多个v-model绑定对吧?或者默认的modelValue这个名字不符合我们的业务逻辑?比如做一个日期选择器组件,我们可能需要绑定“开始日期”和“结束日期”两个值,这时候就可以自定义每个v-model的prop名。 先看自定义单个v-model的prop名的例子,比如还是刚才的HintInput,但我们不想用modelValue,想用userName这个更贴合业务的名字: 子组件里defineModel要传第一个参数,就是自定义的prop名:
<script setup>
const hint = defineProps(['hint'])
// 第一个参数是prop名,第二个是可选的配置项
const userName = defineModel('userName')
</script>
<template>
<div class="hint-input">
<span class="hint-text">{{ hint }}</span>
<input type="text" v-model="userName" />
</div>
</template>
父组件里用的时候,就得用v-model:userName,冒号后面跟自定义的prop名:
<script setup>
import { ref } from 'vue'
import HintInput from './HintInput.vue'
const userName = ref('')
</script>
<template>
<div class="parent">
<h2>用户信息填写</h2>
<HintInput hint="请输入用户名" v-model:userName="userName" />
<p>当前输入的用户名:{{ userName }}</p>
</div>
</template>
这个功能其实以前也有,但以前得显式在props里定义userName,显式在emit里定义update:userName,现在defineModel传个参数就搞定了。
进阶玩法2:一个组件绑定多个defineModel
这个就更实用了,比如刚才说的日期选择器组件,我们可以绑定开始日期和结束日期两个v-model: 子组件DateRangePicker.vue:
<script setup>
// 第一个defineModel绑定开始日期,默认prop名可以用startDate
const startDate = defineModel('startDate', {
// 这里第二个参数可以加配置项,比如类型、默认值
type: String,
default: ''
})
// 第二个defineModel绑定结束日期,prop名用endDate
const endDate = defineModel('endDate', {
type: String,
default: ''
})
</script>
<template>
<div class="date-range-picker">
<span>开始日期:</span>
<input type="date" v-model="startDate" />
<span class="separator">-</span>
<span>结束日期:</span>
<input type="date" v-model="endDate" />
</div>
</template>
<style scoped>
.date-range-picker {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.separator {
color: #666;
}
.date-range-picker input {
border: none;
outline: none;
padding: 2px 4px;
border-radius: 2px;
cursor: pointer;
}
</style>
父组件里用的时候,就可以同时绑定两个v-model:
<script setup>
import { ref } from 'vue'
import DateRangePicker from './DateRangePicker.vue'
const start = ref('2024-01-01')
const end = ref('2024-12-31')
</script>
<template>
<div class="parent">
<h2>日期范围选择</h2>
<DateRangePicker
v-model:startDate="start"
v-model:endDate="end"
/>
<p>选择的范围:{{ start }} 至 {{ end }}</p>
</div>
</template>
以前要实现这个功能,得写两个prop、两个emit、两个computed的get/set,现在只要两行defineModel,是不是太香了?
进阶玩法3:配置defineModel的细节
刚才在多绑定的例子里,我们给defineModel加了第二个参数,是配置项,这个配置项和defineProps里的配置项几乎一模一样,对吧?因为defineModel本质上还是生成了prop和emit,只是帮你封装了而已。 那配置项里都可以加什么?除了刚才的type、default,还有required、validator这些,和defineProps完全一样。 不过这里有个小细节要注意:default如果是一个对象或者数组的话,必须用函数返回,就像defineProps里那样,避免多个组件实例共享同一个对象或数组。 比如刚才的HintInput组件,如果我们要绑定的是一个对象而不是单值: 子组件UserFormItem.vue:
<script setup>
const label = defineProps(['label'])
// 配置项里的default是函数,返回一个空的user对象
const user = defineModel('user', {
type: Object,
required: true,
// 这里default必须是函数!
default: () => ({
name: '',
age: 0
})
})
</script>
<template>
<div class="user-form-item">
<span class="form-label">{{ label }}:</span>
<div class="form-inputs">
<input
type="text"
placeholder="姓名"
v-model="user.name"
/>
<input
type="number"
placeholder="年龄"
v-model.number="user.age"
/>
</div>
</div>
</template>
<style scoped>
/* 简单的表单样式 */
.user-form-item {
margin-bottom: 12px;
display: flex;
align-items: flex-start;
gap: 8px;
}
.form-label {
min-width: 60px;
text-align: right;
color: #333;
font-size: 14px;
}
.form-inputs {
display: flex;
gap: 8px;
}
.form-inputs input {
padding: 4px 6px;
border: 1px solid #e0e0e0;
border-radius: 4px;
outline: none;
transition: border-color 0.2s;
}
.form-inputs input:focus {
border-color: #42b983;
}
</style>
父组件里用的时候:
<script setup>
import { ref } from 'vue'
import UserFormItem from './UserFormItem.vue'
const userInfo = ref({
name: '张三',
age: 25
})
</script>
<template>
<div class="parent">
<h2>填写用户信息</h2>
<UserFormItem label="用户信息" v-model:user="userInfo" />
<p>当前用户信息:{{ userInfo.name }},{{ userInfo.age }}岁</p>
</div>
</template>
这个时候,我们可以直接在子组件里修改user.name或者user.age,父组件的userInfo也会跟着变,而且完全符合单向数据流的最佳实践!哦?为什么?刚才不是说不能直接改prop的嵌套属性吗? 这里要讲一下defineModel的底层原理了:defineModel返回的不是原来的prop的引用,而是一个带get/set的响应式ref(或者reactive,如果配置了的话),get的时候,它会返回父组件传下来的prop的当前值;set的时候,如果是修改整个ref的值,它会自动emit('update:propName', newValue);如果是修改ref的嵌套属性(比如user.name),它会自动emit('update:propName', {...oldValue, name: newValue})吗?不对,等一下,刚才我们的例子里,修改user.name,父组件的userInfo是直接变的,因为Vue3的响应式是Proxy,父组件的userInfo是ref包裹的reactive对象对吧?哦对了,如果父组件传下来的是一个响应式的引用类型,不管是ref还是reactive,那修改嵌套属性本身就会触发父组件的更新,但是defineModel的封装保证了这个操作是“安全的”——虽然看起来是直接改,但defineModel的底层还是会做一些处理吗?或者说,官方之所以允许这样的写法,是因为defineModel帮你把这个操作合法化了? 不管底层原理怎么复杂,至少从我们开发者的角度来看,现在可以放心大胆地在子组件里用defineModel绑定嵌套的对象或数组,直接修改嵌套属性,父组件也会跟着更新,而且官方也认可这种写法,不用再手动emit整个对象了,这解决了以前最大的一个痛点。
进阶玩法4:结合TypeScript使用
如果你用TypeScript开发Vue3项目,defineModel也有很好的类型支持。 有两种写法可以给defineModel加类型: 第一种是用泛型,比如刚才的单值文本输入组件:
<script setup lang="ts">
const hint = defineProps<{ hint: string }>()
// 泛型里传string,说明inputVal是string类型的
const inputVal = defineModel<string>()
</script>
第二种是用类型断言,不过泛型更推荐,因为更清晰。 如果要加配置项的话,也可以结合泛型:
<script setup lang="ts">
const label = defineProps<{ label: string }>()
// 泛型里传{ name: string; age: number },配置项里可以加validator之类的
const user = defineModel<{ name: string; age: number }>('user', {
required: true,
validator: (val) => val.age >= 0 && val.age <= 150
})
</script>
这里的validator的参数val的类型,会自动从泛型里推断出来,不用你手动写any或者再断言一次,非常方便。
必须注意的几个踩坑点
虽然defineModel很方便,但也有几个坑要注意,不然可能会出问题: 第一个坑:不要和defineProps、defineEmits重复声明prop和emit,比如你在defineModel里绑定了modelValue,就不要再在defineProps里写modelValue,也不要再在defineEmits里写update:modelValue,不然会报错或者出现不可预期的行为。 第二个坑:如果父组件传下来的是一个非响应式的引用类型(比如直接传一个{ name: '张三', age: 25 }),那在子组件里修改嵌套属性的话,父组件不会跟着更新,因为defineModel的set只在修改整个ref的值的时候才会emit?不对,等一下,刚才的例子里父组件传的是ref包裹的reactive对象,所以修改嵌套属性会触发更新;如果父组件传的是普通对象的话,defineModel的get返回的是这个普通对象的副本吗?或者是直接返回?这里要自己试一下,最好的做法是,不管父组件传的是什么,子组件里都尽量避免直接修改嵌套属性吗?不对,刚才官方的例子里好像是允许的?哦对了,刚才我去翻了一下官方的文档,官方说如果defineModel绑定的是一个引用类型,子组件里可以直接修改嵌套属性,因为Vue3的响应式系统会自动跟踪这些变化,但前提是父组件传下来的是一个响应式的对象或数组,如果父组件传的是非响应式的,那子组件修改嵌套属性不会触发父组件的更新,这时候最好还是修改整个ref的值,或者父组件传的时候用ref包裹一下。 第三个坑:Vue3.4及以上版本,defineModel的配置项里加了一个local选项,默认是false,如果local设为true的话,defineModel会先在子组件内部创建一个本地的响应式变量,当父组件没有传prop的时候,子组件用本地变量;当父组件传了prop的时候,子组件用prop的值,但修改本地变量不会自动同步到父组件?不对,等一下,Vue3.4的local选项是用来做“本地优先”或者“受控/非受控”切换的吗?哦对了,Vue3.4之前,defineModel是完全受控的,也就是子组件的状态完全由父组件的prop决定;Vue3.4之后,加了local选项,设为true的话,子组件可以有自己的本地状态,当父组件没有传prop的时候,用本地状态,修改本地状态会触发本地更新;当父组件传了prop的时候,子组件的状态会同步到prop的值,但修改子组件的状态还是会自动emit到父组件?不对,具体的细节可能要看具体的版本,但不管怎么说,如果你不需要本地状态,就不要随便设local为true,不然可能会出现状态不同步的问题。 第四个坑:defineModel只能在script setup里用,不能在普通的script标签里用,也不能在Composition API的setup()函数里用,因为它是一个编译宏,依赖script setup的编译环境。
defineModel和旧写法的性能对比
很多人可能会担心,defineModel封装了这么多东西,会不会比旧写法的性能差? 其实完全不用担心,因为defineModel是一个编译宏,它在编译的时候就会把代码转换成旧写法的样子,也就是转换成defineProps、defineEmits、computed的get/set,所以运行的时候的性能和旧写法是一模一样的,没有任何额外的开销。 那既然性能一样,为什么不用defineModel呢?写起来更简洁,代码更易读,何乐而不为?
Vue3的defineModel是Vue3.3推出的一个非常实用的稳定API,它解决了旧写法中自定义组件v-model双向绑定的很多麻烦,比如不用重复写props和emit、可以直接修改嵌套的响应式对象或数组、可以同时绑定多个v-model、有很好的TypeScript支持。 不过在使用的时候,也要注意几个踩坑点:不要重复声明prop和emit、父组件尽量传响应式的引用类型、Vue3.4及以上版本不要随便设local为true、只能在script setup里用。 如果你还在旧写法里挣扎,赶紧试试defineModel吧,它会让你的代码变得更加简洁和优雅。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


