前言
vue3.4增加了defineModel宏函数,在子组件内修改了defineModel的返回值,父组件上v-model绑定的变量就会被更新。大家都知道v-model是:modelValue和@update:modelValue的语法糖,但是你知道为什么我们在子组件内没有写任何关于props的定义和emit事件触发的代码吗?还有在template渲染中defineModel的返回值等于父组件v-model绑定的变量值,那么这个返回值是否就是名为modelValue的props呢?直接修改defineModel的返回值就会修改父组件上面绑定的变量,那么这个行为是否相当于子组件直接修改了父组件的变量值,破坏了vue的单向数据流呢?
先说答案
defineModel宏函数经过编译后会给vue组件对象上面增加modelValue的props选项和update:modelValue的emits选项,执行defineModel宏函数的代码会变成执行useModel函数,如下图:
图片
经过编译后defineModel宏函数已经变成了useModel函数,而useModel函数的返回值是一个ref对象。注意这个是ref对象不是props,所以我们才可以在组件内直接修改defineModel的返回值。当我们对这个ref对象进行“读操作”时,会像Proxy一样被拦截到ref对象的get方法。在get方法中会返回本地维护localValue变量,localValue变量依靠watchSyncEffect让localValue变量始终和父组件传递的modelValue的props值一致。
对返回值进行“写操作”会被拦截到ref对象的set方法中,在set方法中会将最新值同步到本地维护localValue变量,调用vue实例上的emit方法抛出update:modelValue事件给父组件,由父组件去更新父组件中v-model绑定的变量。如下图:
图片
所以在子组件内无需写任何关于props的定义和emit事件触发的代码,因为在编译defineModel宏函数的时候已经帮我们生成了modelValue的props选项。在对返回的ref变量进行写操作时会触发set方法,在set方法中会调用vue实例上的emit方法抛出update:modelValue事件给父组件。
defineModel宏函数的返回值是一个ref变量,而不是一个props。所以我们可以直接修改defineModel宏函数的返回值,父组件绑定的变量之所以会改变是因为在底层会抛出update:modelValue事件给父组件,由父组件去更新绑定的变量,这一行为当然满足vue的单向数据流。
什么是vue的单向数据流
vue的单向数据流是指,通过props将父组件的变量传递给子组件,在子组件中是没有权限去修改父组件传递过来的变量。只能通过emit抛出事件给父组件,让父组件在事件回调中去修改props传递的变量,然后通过props将更新后的变量传递给子组件。在这一过程中数据的流动是单向的,由父组件传递给子组件,只有父组件有数据的更改权,子组件不可直接更改数据。
图片
一个defineModel的例子
我在前面的 一文搞懂 Vue3 defineModel 双向绑定:告别繁琐代码!文章中已经讲过了defineModel的各种用法,在这篇文章中我们就不多余赘述了。我们直接来看一个简单的defineModel的例子。
下面这个是父组件的代码:
<template>
<CommonChild v-model="inputValue" />
<p>input value is: {{ inputValue }}</p>
</template>
<script setup lang="ts">
import { ref } from "vue";
import CommonChild from "./child.vue";
const inputValue = ref();
</script>
父组件的代码很简单,使用v-model指令将inputValue变量传递给子组件。然后在父组件上使用p标签渲染出inputValue变量的值。
我们接下来看子组件的代码:
<template>
<input v-model="model" />
<button @click="handelReset">reset</button>
</template>
<script setup lang="ts">
const model = defineModel();
function handelReset() {
model.value = "init";
}
</script>
子组件内的代码也很简单,将defineModel的返回值赋值给model变量。然后使用v-model指令将model变量绑定到子组件的input输入框上面。并且还在按钮的click事件时使用model.value = "init"将绑定的值重置为init字符串。请注意在子组件中我们没有任何定义props的代码,也没有抛出emit事件的代码。而是通过defineModel宏函数的返回值来接收父组件传过来的名为modelValue的prop,并且在子组件中是直接通过给defineModel宏函数的返回值进行赋值来修改父组件绑定的inputValue变量的值。
defineModel编译后的样子
要回答前面提的几个问题,我们还是得从编译后的子组件代码说起。下面这个是经过简化编译后的子组件代码:
import {
defineComponent as _defineComponent,
useModel as _useModel
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";
const _sfc_main = _defineComponent({
__name: "child",
props: {
modelValue: {},
modelModifiers: {},
},
emits: ["update:modelValue"],
setup(__props) {
const model = _useModel(__props, "modelValue");
function handelReset() {
model.value = "init";
}
const __returned__ = { model, handelReset };
return __returned__;
},
});
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return (
// ... 省略
);
}
_sfc_main.render = _sfc_render;
export default _sfc_main;
从上面我们可以看到编译后主要有_sfc_main和_sfc_render这两块,其中_sfc_render为render函数,不是我们这篇文章关注的重点。我们来主要看_sfc_main对象,看这个对象的样子有name、props、emits、setup属性,我想你也能够猜出来他就是vue的组件对象。从组件对象中我们可以看到已经有了一个modelValue的props属性,还有使用emits选项声明了update:modelValue事件。我们在源代码中没有任何地方有定义props和emits选项,很明显这两个是通过编译defineModel宏函数而来的。
我们接着来看里面的setup函数,可以看到经过编译后的setup函数中代码和我们的源代码很相似。只有defineModel不在了,取而代之的是一个useModel函数。
// 编译前的代码
const model = defineModel();
// 编译后的代码
const model = _useModel(__props, "modelValue");
还是同样的套路,在浏览器的sources面板上面找到编译后的js文件,然后给这个useModel打个断点。至于如何找到编译后的js文件我们在前面的文章中已经讲了很多遍了,这里就不赘述了。刷新浏览器我们看到断点已经走到了使用useModel函数的地方,我们这里给useModel函数传了两个参数。第一个参数为子组件接收的props对象,第二个参数是写死的字符串modelValue。进入到useModel函数内部,简化后的useModel函数是这样的:
function useModel(props, name) {
const i = getCurrentInstance();
const res = customRef((track2, trigger2) => {
watchSyncEffect(() => {
// 省略
});
});
return res;
}
从上面的代码中我们可以看到useModel中使用到的函数没有一个是vue内部源码专用的函数,全都是调用的vue暴露出来的API。这意味着我们可以参考defineModel的实现源码,也就是useModel函数,然后根据自己实际情况改良一个适合自己项目的defineModel函数。
我们先来简单介绍一下useModel函数中使用到的API,分别是getCurrentInstance、customRef、watchSyncEffect,这三个API都是从vue中import导入的。
getCurrentInstance函数
首先来看看getCurrentInstance函数,他的作用是返回当前的vue实例。为什么要调用这个函数呢?因为在setup中this是拿不到vue实例的,后面对值进行写操作时会调用vue实例上面的emit方法抛出update事件。
watchSyncEffect函数
接着我们来看watchSyncEffect函数,这个API大家平时应该比较熟悉了。他的作用是立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时立即重新执行这个函数。
比如下面这段代码,会立即执行console,当count变量的值改变后,也会立即执行console。
const count = ref(0)
watchSyncEffect(() => console.log(count.value))
// -> 输出 0
customRef函数
最后我们来看customRef函数,他是useModel函数的核心。这个函数小伙伴们应该用的比较少,我们这篇文章只简单讲讲他的用法即可。如果小伙伴们对customRef函数感兴趣可以留言或者给我发消息,关注的小伙伴们多了我后面会安排一篇文章来专门讲customRef函数。官方的解释为:
“
创建一个自定义的 ref,显式声明对其依赖追踪和更新触发的控制方式。customRef() 预期接收一个工厂函数作为参数,这个工厂函数接受 track 和 trigger 两个函数作为参数,并返回一个带有 get 和 set 方法的对象。
这句话的意思是customRef函数的返回值是一个ref对象。当我们对返回值ref对象进行“读操作”时,会被拦截到ref对象的get方法中。当我们对返回值ref对象进行“写操作”时,会被拦截到ref对象的set方法中。和Promise相似同样接收一个工厂函数作为参数,Promise的工厂函数是接收的resolve和reject两个函数作为参数,customRef的工厂函数是接收的track和trigger两个函数作为参数。track用于手动进行依赖收集,trigger函数用于手动进行依赖触发。
我们知道vue的响应式原理是由依赖收集和依赖触发的方式实现的,比如我们在template中使用一个ref变量。当template被编译为render函数后,在浏览器中执行render函数时,就会对ref变量进行读操作。读操作会被拦截到Proxy的get方法中,由于此时在执行render函数,所以当前的依赖就是render函数。在get方法中会进行依赖收集,将当前的render函数作为依赖收集起来。注意这里的依赖收集是vue内部自动完成的,在我们的代码中无需手动去进行依赖收集。
当我们对ref变量进行写操作时,此时会被拦截到Proxy的set方法,在set方法中会将收集到的依赖依次取出来执行,我们前面收集的依赖是render函数。所以render函数就会重新执行,执行render函数生成虚拟DOM,再生成真实DOM,这样浏览器中渲染的就是最新的ref变量的值。同样这里依赖触发也是在vue内部自动完成的,在我们的代码中无需手动去触发依赖。
搞清楚了依赖收集和依赖触发现在来讲track和trigger两个函数你应该就能很容易理解了,track和trigger两个函数可以让我们手动控制什么时候进行依赖收集和依赖触发。执行track函数就会手动收集依赖,执行trigger函数就会手动触发依赖,进行页面刷新。在defineModel这个场景中track手动收集的依赖就是render函数,trigger手动触发会导致render函数重新执行,进而完成页面刷新。
useModel函数
现在我们可以来看useModel函数了,简化后的代码如下:
function useModel(props, name) {
const i = getCurrentInstance();
const res = customRef((track2, trigger2) => {
let localValue;
watchSyncEffect(() => {
const propValue = props[name];
if (hasChanged(localValue, propValue)) {
localValue = propValue;
trigger2();
}
});
return {
get() {
track2();
return localValue;
},
set(value) {
if (hasChanged(value, localValue)) {
localValue = value;
trigger2();
}
i.emit(`update:${name}`, value);
},
};
});
return res;
}
从上面我们可以看到useModel函数的代码其实很简单,useModel的返回值就是customRef函数的返回值,也就是一个ref变量对象。我们看到返回值对象中有get和set方法,还有在customRef函数中使用了watchSyncEffect函数。
get方法
在前面的demo中,我们在子组件的template中使用v-model将defineModel的返回值绑定到一个input输入框中。代码如下:
<input v-model="model" />
在第一次执行render函数时会对model变量进行读操作,而model变量是defineModel宏函数的返回值。编译后我们看到defineModel宏函数变成了useModel函数。所以对model变量进行读操作,其实就是对useModel函数的返回值进行读操作。我们看到useModel函数的返回值是一个自定义ref,在自定义ref中有get和set方法,当对自定义ref进行读操作时会被拦截到ref对象中的get方法。这里在get方法中会手动执行track2方法进行依赖收集。因为此时是在执行render函数,所以收集到的依赖就是render函数,然后将本地维护的localValue的值进行拦截返回。
set方法
在我们前面的demo中,子组件reset按钮的click事件中会对defineModel的返回值model变量进行写操作,代码如下:
function handelReset() {
model.value = "init";
}
和对model变量“读操作”同理,对model变量进行“写操作”也会被拦截到返回值ref对象的set方法中。在set方法中会先判断新的值和本地维护的localValue的值比起来是否有修改。如果有修改那就将更新后的值同步更新到本地维护的localValue变量,这样就保证了本地维护的localValue始终是最新的值。然后执行trigger2函数手动触发收集的依赖,在前面get的时候收集的依赖是render函数,所以这里触发依赖会重新执行render函数,然后将最新的值渲染到浏览器上面。
在set方法中接着会调用vue实例上面的emit方法进行抛出事件,代码如下:
i.emit(`update:${name}`, value)
这里的i就是getCurrentInstance函数的返回值。前面我们讲过了getCurrentInstance函数的返回值是当前vue实例,所以这里就是调用vue实例上面的emit方法向父组件抛出事件。这里的name也就是调用useModel函数时传入的第二个参数,我们来回忆一下前面是怎样调用useModel函数的 ,代码如下:
const model = _useModel(__props, "modelValue")
传入的第一个参数为当前的props对象,第二个参数是写死的字符串"modelValue"。那这里调用emit抛出的事件就是update:modelValue,传递的参数为最新的value的值。这就是为什么不需要在子组件中使用使用emit抛出事件,因为在defineModel宏函数编译成的useModel函数中已经帮我们使用emit抛出事件了。
watchSyncEffect函数
我们接着来看子组件中怎么接收父组件传递过来的props呢,答案就在watchSyncEffect函数中。回忆一下前面讲过的useModel函数中的watchSyncEffect代码如下:
function useModel(props, name) {
const res = customRef((track2, trigger2) => {
let localValue;
watchSyncEffect(() => {
const propValue = props[name];
if (hasChanged(localValue, propValue)) {
localValue = propValue;
trigger2();
}
});
return {
// ...省略
};
});
return res;
}
这个name也就是调用useModel函数时传过来的第二个参数,我们前面已经讲过了是一个写死的字符串"modelValue"。那这里的const propValue = props[name]就是取父组件传递过来的名为modelValue的prop,我们知道v-model就是:modelValue的语法糖,所以这个propValue就是取的是父组件v-model绑定的变量值。如果本地维护的localValue变量的值不等于父组件传递过来的值,那么就将本地维护的localValue变量更新,让localValue变量始终和父组件传递过来的值一样。并且触发依赖重新执行子组件的render函数,将子组件的最新变量的值更新到浏览器中。为什么要调用trigger2函数呢?原因是可以在子组件的template中渲染defineModel函数的返回值,也就是父组件传递过来的prop变量。如果父组件传递过来的prop变量值改变后不重新调用trigger2函数以重新执行render函数,那么子组件中的渲染的变量值就一直都是旧的值了。因为这个是在watchSyncEffect内执行的,所以每次父组件传过来的props值变化后都会再执行一次,让本地维护的localValue变量的值始终等于父组件传递过来的值,并且子组件页面上也始终渲染的是最新的变量值。
这就是为什么在子组件中没有任何props定义了,因为在defineModel宏函数编译后会给vue组件对象塞一个modelValue的prop,并且在useModel函数中会维护一个名为localValue的本地变量接收父组件传递过来的props.modelValue,并且让localValue变量和props.modelValue的值始终保持一致。
总结
现在我们可以回答前面提的几个问题了:
- 使用defineModel宏函数后,为什么我们在子组件内没有写任何关于props定义的代码?答案是本地会维护一个localValue变量接收父组件传递过来的名为modelValue的props。调用defineModel函数的代码经过编译后会变成一个调用useModel函数的代码,useModel函数的返回值是一个ref对象。当我们对defineModel的返回值进行“读操作”时,类似于Proxy的get方法一样会对读操作进行拦截到返回值ref对象的get方法中。而get方法的返回值为本地维护的localValue变量,在watchSyncEffect的回调中将父组件传递过来的名为modelValue的props赋值给本地维护的localValue变量。并且由于是在watchSyncEffect中,所以每次props改变都会执行这个回调,所以本地维护的localValue变量始终是等于父组件传递过来的modelValue。也正是因为defineModel宏函数的返回值是一个ref对象而不是一个prop,所以我们可以在子组件内直接将defineModel的返回值使用v-model绑定到子组件input输入框上面。
- 使用defineModel宏函数后,为什么我们在子组件内没有写任何关于emit事件触发的代码?答案是因为调用defineModel函数的代码经过编译后会变成一个调用useModel函数的代码,useModel函数的返回值是一个ref对象。当我们直接修改defineModel的返回值,也就是修改useModel函数的返回值。类似于Proxy的set方法一样会对写行为进行拦截到ref对象中的set方法中。在set方法中会手动触发依赖,render函数就会重新执行,浏览器上就会渲染最新的变量值。然后调用vue实例上的emit方法,向父组件抛出update:modelValue事件。并且将最新的值随着事件一起传递给父组件,由父组件在update:modelValue事件回调中将父组件中v-model绑定的变量更新为最新值。
- 在template渲染中defineModel的返回值等于父组件v-model绑定的变量值,那么这个返回值是否就是名为modelValue的props呢?从第一个回答中我们知道defineModel的返回值不是props,而是一个ref对象。
- 直接修改defineModel的返回值就会修改父组件上面绑定的变量,那么这个行为是否相当于子组件直接修改了父组件的变量值,破坏了vue的单向数据流呢?修改defineModel的返回值,就会更新父组件中v-model绑定的变量值。看着就像是子组件中直接修改了父组件的变量值,从表面上看着像是打破了vue的单向数据流。实则并不是那样的,虽然我们在代码中没有写过emit抛出事件的代码,但是在defineModel函数编译成的useModel函数中已经帮我们使用emit抛出事件了。所以并没有打破vue的单向数据流