前言

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变量依靠watchSyncEffectlocalValue变量始终和父组件传递的modelValueprops值一致。

对返回值进行“写操作”会被拦截到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_renderrender函数,不是我们这篇文章关注的重点。我们来主要看_sfc_main对象,看这个对象的样子有name、props、emits、setup属性,我想你也能够猜出来他就是vue的组件对象。从组件对象中我们可以看到已经有了一个modelValueprops属性,还有使用emits选项声明了update:modelValue事件。我们在源代码中没有任何地方有定义propsemits选项,很明显这两个是通过编译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,分别是getCurrentInstancecustomRefwatchSyncEffect,这三个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的工厂函数是接收的resolvereject两个函数作为参数,customRef的工厂函数是接收的tracktrigger两个函数作为参数。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内部自动完成的,在我们的代码中无需手动去触发依赖。

搞清楚了依赖收集和依赖触发现在来讲tracktrigger两个函数你应该就能很容易理解了,tracktrigger两个函数可以让我们手动控制什么时候进行依赖收集和依赖触发。执行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变量对象。我们看到返回值对象中有getset方法,还有在customRef函数中使用了watchSyncEffect函数。

get方法

在前面的demo中,我们在子组件的template中使用v-modeldefineModel的返回值绑定到一个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]就是取父组件传递过来的名为modelValueprop,我们知道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的返回值进行“读操作”时,类似于Proxyget方法一样会对读操作进行拦截到返回值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函数的返回值。类似于Proxyset方法一样会对写行为进行拦截到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原理文章,我会根据大家的反馈进行创作。



父组件明明使用了v-model,子组件竟然可以不用定义props和emit抛出事件,快来看看吧的更多相关文章

  1. vue父组件引用多个相同的子组件传值

    没有什么问题是for 解决不了的,我一直深信这句话,当然这句话也是我说的 父组件引用多个相同的子组件传值问题 (这种情况很少遇到) 1 <template> 2 <div> 3 ...

  2. vue中如何在子组件添加类似于watch属性监听父组件数据,数据变化时子组件做出相应的动作

    首先:我们需要在父组件中标签中定义一个 ref="parentObjVue" 其次:我们在子组件中,通过  var tmp=this.$refs.parentObjVue找到父组件 ...

  3. vue 简单实现父组件向子组件传值,简单来说就是子组件肆意妄为的调用父组件里后台返回的值

    首先在于父子组件传值的方法很多,本人在这里只是简单描述一下一个组件里面引用了子组件,那么子组件如何才能获取父组件中后台返回的值呢? 首先调用组件相信大家都应该明白了(不明白的自己撸撸文档), < ...

  4. Vue 父组件方法和参数传给子组件的方法

    <template> <div class="content-item"> <!-- openWnd是父组件自身的方法,openDutyWnd是子组件 ...

  5. Vue 父组件ajax异步更新数据,子组件props获取不到

    转载 https://blog.csdn.net/d295968572/article/details/80810349 当父组件 axjos 获取数据,子组件使用 props 接收数据时,执行 mo ...

  6. vue 父组件给子组件传值 Vue父组件给子组件传方法 Vue父组件把整个实例传给子组件

    Home.vue <template> <!-- 所有的内容要被根节点包含起来 --> <div id="home"> <v-header ...

  7. vue.js 父组件主动获取子组件的数据和方法、子组件主动获取父组件的数据和方法

    父组件主动获取子组件的数据和方法 1.调用子组件的时候 定义一个ref <headerchild ref="headerChild"></headerchild& ...

  8. React 克隆组件 -- React.cloneElement(可以用来修改子组件属性值,复制子组件,添加子组件)

    项目要求实现按钮级权限,简单来说就是需要通过后台数据绑定来控制前端页面哪些操作按钮需要渲染,哪些操作按钮不需要渲染, 大体的方案是: 在原有的按钮标签外再套一层按钮权限控制标签,然后每个具体的按钮对照 ...

  9. VUE 父组件与子组件交互

    1. 概述 1.1 说明 在项目过程中,会有很多重复功能在多个页面中处理,此时则需要把这些重复的功能进行单独拎出,编写公用组件(控件)进行引用.在VUE中,组件是可复用的VUE实例,此时组件中的dat ...

  10. react中父组件调用子组件的方法

    1.直接使用ref进行获取 import React, {Component} from 'react'; export default class Parent extends Component ...

随机推荐

  1. 问题:django.template.exceptions.TemplateSyntaxError: 'staticfiles' is not a registered tag library. Must be one of: admin_list admin_modify admin_urls cache i18n l10n log rest_framework static tz

    django使用swagger自动生成API文档时,报错 解决方法 在settings.py里面配置一下以下代码 'libraries': { 'staticfiles': 'django.templ ...

  2. git开发规范

  3. 【Azure 应用服务】App Service for Linux环境中,如何解决字体文件缺失的情况

    问题描述 部署在App Service for Linux环境中的Web App.出现了字体文件缺失的问题,页面显示本来时中文的地方,区别变为方框占位. 问题分析 在应用中,通常涉及到显示问题的有两个 ...

  4. 关于 LLM 和知识图谱、图数据库,大家都关注哪些问题呢?

    自 LLM 系列文章<知识图谱驱动的大语言模型 Llama Index>.<Text2Cypher:大语言模型驱动的图查询生成>.<Graph RAG: 知识图谱结合 L ...

  5. Java 多线程------多线程的创建,方式一:继承于Thread类

    1 package com.bytezero.thread; 2 3 /** 4 * 多线程的创建,方式一:继承于Thread类 5 * 1.创建一个继承于Thread类的子类 6 * 2.重写Thr ...

  6. 前端css阴影画图

    在线演示地址:css阴影画图 一,在css中有一个box-shadow属性,可以设置元素的阴影. .item{ width: 50px; height: 50px; background: #0096 ...

  7. Linux环境下动态库的生成与使用

    一.动态库的生成 定义 a.h.a.c 如下: a.h #include <stdio.h> #include <stdlib.h> void FuncA(); a.c #in ...

  8. vscode 合并分支 举例 master merge dev

    举例 将 dev 开发线 合并到 master 1 确定你在dev线,将dev代码改动全部提交 2 切换master,确定是最新代码,不确定就pull下,选择合并分支,见上图 3 在下拉的提示框中选择 ...

  9. 日常办公——Word中重复标题的设置

    在Word中,遇到表格分页时,可以设置重复标题,如下图所示:

  10. 32位数字电位器AD5228使用及调试总结

    一 概念 什么是数字电位计? 数字电位器(Digital Potentiometer)亦称数控可编程电阻器,是一种代替传统机械电位器(模拟电位器)的新型CMOS数字.模拟混合信号处理的集成电路.数字电 ...