父组件明明使用了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变量依靠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原理文章,我会根据大家的反馈进行创作。


热门相关:娘娘每天都在洗白   我在镇夜司打开地狱之门   鬼喊抓鬼   田园晚色:肥妇三嫁良夫   前夫有毒:1000万夺子契约