父组件明明使用了v-model,子组件竟然可以不用定义props和emit抛出事件,快来看看吧
前言
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的单向数据流
关注公众号:前端欧阳
,解锁我更多vue
干货文章。还可以加我微信,私信我想看哪些vue
原理文章,我会根据大家的反馈进行创作。