Vue双向绑定让数据在组件间的传递和更新变得更加高效,Vue 3.4引入的defineModel宏,更是给双向绑定带来了全新的体验。今天,咱们就深入探究一下defineModel到底是什么,它有哪些优势,以及在实际项目中该怎么用。

一、传统双向绑定

defineModel出现之前,Vue实现双向绑定主要依靠v-model,以及手动管理propsemits。虽说这些方法能完成任务,但在稍微复杂点的场景下,就会暴露出不少问题。

(一)手动管理props和emits的麻烦

先看看手动管理propsemits的情况。在父组件中,不仅要传递数据,还得专门写一个修改数据的方法并传给子组件。比如下面这段代码:

<!-- 父组件 --> <child :carObj="carObj" @carPriceAdd="carPriceAdd" /> <script setup lang="ts"> const carObj = ref<ICarObj>({ brand: 'BMW', price: 100000 }) const carPriceAdd = () => { carObj.value.price += 1000 } </script> 

在子组件这边,接收数据的同时,还得接收父组件传来的事件,然后通过emits触发调用,才能修改父组件的数据,代码如下:

<script setup lang="ts"> const props = defineProps<{ modelValue: IUser, // v-model carObj: ICarObj // v-bind }>() const emits = defineEmits(['carPriceAdd']) const priceAdd = () => { emits('carPriceAdd') console.log(props.carObj.price) } </script> 

这么一套操作下来,代码显得很繁琐,尤其是在处理多个类似的交互时,代码量会大幅增加,维护起来相当麻烦。

(二)v-model的局限性

再来说说v-model。它确实能简化一部分代码,父组件可以直接通过v-model把数据传递给子组件,比如:

<child v-model="user" /> <script setup lang="ts"> const user = ref<IUser>({ name: 'song', age: 18 }) </script> 

但是在子组件中,还是得接收事件,而且这个事件不是父组件直接传递过来的,格式也有点特别。具体代码如下:

<script setup lang="ts"> const props = defineProps<{ modelValue: IUser, // v-model carObj: ICarObj // v-bind }>() const emits = defineEmits(['update:modelValue']) const ageAdd = () => { emits('update:modelValue', { ...props.modelValue, age: props.modelValue.age + 1 }) // console.log(props.modelValue.age) } </script> 

v-model默认传递的参数名是modelValue,默认事件是update:modelValue。要是想修改默认参数名,在父组件里可以写成v-model:name的形式,子组件里接收的数据名和事件名也得跟着改。即便如此,在处理多个双向绑定时,手动管理propsemits的问题依然存在,代码复杂度还是降不下来。

二、defineModel:双向绑定简化

(一)基本使用方法

defineModel是Vue 3.4引入的编译器宏,它本质上是v-model的语法糖,不过语法更简洁、直观。在使用defineModel时,父组件的写法和使用v-model时一样,还是通过v-model传递数据给子组件,例如:

<child v-model="user" /> <script setup lang="ts"> const user = ref<IUser>({ name: 'song', age: 18 }) </script> 

重点在子组件这边,使用defineModel后,就不用再像以前那样显式接收propsemits了。直接通过defineModel返回的ref对象,就能轻松实现双向绑定,代码如下:

<script setup lang="ts"> // 通过defineModel声明父组件传递过来的数据,返回一个ref对象 const user = defineModel<IUser>('user', { default: {} }) // 子组件可以直接修改刚刚通过defineModel声明的数据,不需要通过emits,父组件会自动更新 const ageAdd = () => { user.value.age += 1 } </script> 

这样一来,代码简洁了许多,逻辑也更清晰,大大提升了开发效率。

(二)修饰符与转换器的运用

  1. 修饰符的获取与使用
    在一些特殊场景下,我们会用到v-model的修饰符。比如说,想要清除字符串末尾的空格。在父组件里添加修饰符的代码如下:
<!-- 父组件 --> <child v-model:userName.trim="userName" /> 

在子组件中,通过解构defineModel()的返回值,就能获取父组件添加的修饰符,代码是这样的:

// 通过defineModel声明父组件传递过来的数据,返回一个ref对象 const [user, filters] = defineModel<IUser>({ default: {}, set: (val) => { console.log('set', val) } }) 

修饰符的格式是:第一个参数是props值,第二个参数是对应的修饰符(修饰符可能有多个)。
2. 转换器处理数据
当有修饰符存在时,有时候我们需要对数据进行转换,比如在读取数据或者把数据同步回父组件的时候。这时候可以通过getset转换器选项来实现。看下面这段代码:

const [userName, userNameFilters] = defineModel('userName',{ default: '', set: (val) => { if(userNameFilters.trim) { return val.trim() } return val } }) 

在这个例子里,如果userNameFilters中有trim修饰符,就会对val进行去除末尾空格的操作。

(三)多Model的实现

defineModel还支持在单个组件实例上创建多个v-model的双向绑定。在父组件里可以这样写:

<!-- 父组件 --> <child v-model.trim="user" v-model:userName.trim.number="userName" /> 

子组件接收多个v-model时,代码如下:

// 通过defineModel声明父组件传递过来的数据,返回一个ref对象 const [user, filters] = defineModel<IUser>({ default: {}, set: (val) => { console.log('set', val) } }) const [userName, userNameFilters] = defineModel<string>('userName',{ default: '', set: (val) => { if(userNameFilters.trim) { return val.trim() } return val } }) 

这样就轻松实现了多个双向绑定,代码也不会显得杂乱。

三、defineModel的实现原理

了解了defineModel的用法后,我们来探究一下它的实现原理。其实,defineModel就是v-model的语法糖,我们可以对比一下使用和不使用defineModel时编译结果的差异。

  1. 不使用defineModel的编译结果:不使用defineModel时,最终就是props接收变量,emits接收事件。代码结构相对复杂,需要开发者手动管理的部分较多。
  2. 使用defineModel的编译结果:使用defineModel后,虽然在组件中不用显式接收propsemits,但Vue还是会帮我们生成这部分内容。而且,使用defineModel会多一个修饰符的接收。defineModel会被编译成一个_useModel方法,这是实现双向绑定的关键。它会接收父组件传递的propsemits,用props的值进行初始化。当数据有更新需求时,就调用emits里注册的事件通知父组件。在实际开发中,我们一般通过defineModel返回的ref值来操作数据,_useModel的主要作用就是保证这个ref值和父组件传递的props值保持同步,进而实现数据的双向绑定。

通过这篇文章,相信大家对defineModel已经有了比较全面的了解。在实际项目开发中,大家不妨试试defineModel,感受一下。