父组件使用v-model,子组件竟然不用定义props和emit抛出事件

从 Vue 3.4 开始,Vue 引入了一个新的编译时宏函数 defineModel(),它提供了一种更简洁和直观的方式来使用 v-model 进行双向绑定,特别是在子组件中。这个新特性主要是为了进一步简化和增强 Vue 3.x 中 v-model 的使用体验,让开发者可以更加方便地实现父子组件之间的数据同步。

使用 defineModel() 宏函数

defineModel() 宏允许你在子组件中直接操作一个局部的反应性状态,该状态与父组件通过 v-model 绑定的状态同步。这意味着,当你在子组件内部修改了通过 defineModel() 定义的状态时,父组件中绑定的变量也会相应地被更新,从而实现了双向绑定。

这个宏函数在编译时会被转换为对应的 propsemits 配置,因此不需要开发者手动定义接收的 props 或是手动触发更新事件。

示例

以下是一个使用 defineModel() 宏函数的简单示例:

xml 复制代码
vueCopy code
<!-- 子组件 -->
<script setup>
import { defineModel } from 'vue'

// 使用 defineModel() 宏定义模型,自动处理 v-model 的 prop 和事件
const model = defineModel()

// model.value 现在是与父组件通过 v-model 绑定的响应式状态
function updateValue() {
  model.value = '新值'
}
</script>

<template>
  <input :value="model.value" @input="updateValue">
</template>

在上面的例子中,子组件通过 defineModel() 宏直接与父组件通过 v-model 绑定的变量进行双向绑定。当子组件内部调用 updateValue 方法修改 model.value 时,这个新值会自动反映到父组件上绑定的变量中。

注意事项

  • 使用 defineModel() 需要确保你的项目配置了相应的 Vue 编译器,以支持编译时宏。
  • 尽管 defineModel() 简化了双向绑定的实现,明确和清晰地在组件中声明其预期的 propsemits 仍然是一个好习惯,特别是在构建大型或复杂的应用时。

defineModel() 宏是 Vue 3.x 努力提升开发体验的一个例证,它让双向绑定的实现更加简洁和直观,有助于提高代码的可读性和维护性。

那么这个行为是否相当于子组件直接修改了父组件的变量值,破坏了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的例子。

下面这个是父组件的代码:

xml 复制代码
<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变量的值。

我们接下来看子组件的代码:

ini 复制代码
<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编译后的样子

要回答前面提的几个问题,我们还是得从编译后的子组件代码说起。下面这个是经过简化编译后的子组件代码:

php 复制代码
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函数。

ini 复制代码
// 编译前的代码
const model = defineModel();

// 编译后的代码
const model = _useModel(__props, "modelValue");

还是同样的套路,在浏览器的sources面板上面找到编译后的js文件,然后给这个useModel打个断点。至于如何找到编译后的js文件我们在前面的文章中已经讲了很多遍了,这里就不赘述了。刷新浏览器我们看到断点已经走到了使用useModel函数的地方,我们这里给useModel函数传了两个参数。第一个参数为子组件接收的props对象,第二个参数是写死的字符串modelValue。进入到useModel函数内部,简化后的useModel函数是这样的:

javascript 复制代码
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。

scss 复制代码
const count = ref(0)

watchSyncEffect(() => console.log(count.value))
// -> 输出 0

customRef函数

最后我们来看customRef函数,他是useModel函数的核心。这个函数小伙伴们应该用的比较少,我们这篇文章只简单讲讲他的用法即可。如果小伙伴们对customRef函数感兴趣可以留言或者给我发消息,关注的小伙伴们多了我后面会安排一篇文章来专门讲customRef函数。官方的解释为:

"

创建一个自定义的 ref,显式声明对其依赖追踪和更新触发的控制方式。customRef() 预期接收一个工厂函数作为参数,这个工厂函数接受 tracktrigger 两个函数作为参数,并返回一个带有 getset 方法的对象。

这句话的意思是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函数了,简化后的代码如下:

ini 复制代码
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输入框中。代码如下:

ini 复制代码
<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变量进行写操作,代码如下:

ini 复制代码
function handelReset() {
  model.value = "init";
}

和对model变量"读操作"同理,对model变量进行"写操作"也会被拦截到返回值ref对象的set方法中。在set方法中会先判断新的值和本地维护的localValue的值比起来是否有修改。如果有修改那就将更新后的值同步更新到本地维护的localValue变量,这样就保证了本地维护的localValue始终是最新的值。然后执行trigger2函数手动触发收集的依赖,在前面get的时候收集的依赖是render函数,所以这里触发依赖会重新执行render函数,然后将最新的值渲染到浏览器上面。

在set方法中接着会调用vue实例上面的emit方法进行抛出事件,代码如下:

css 复制代码
i.emit(`update:${name}`, value)

这里的i就是getCurrentInstance函数的返回值。前面我们讲过了getCurrentInstance函数的返回值是当前vue实例,所以这里就是调用vue实例上面的emit方法向父组件抛出事件。这里的name也就是调用useModel函数时传入的第二个参数,我们来回忆一下前面是怎样调用useModel函数的 ,代码如下:

ini 复制代码
const model = _useModel(__props, "modelValue")

传入的第一个参数为当前的props对象,第二个参数是写死的字符串"modelValue"。那这里调用emit抛出的事件就是update:modelValue,传递的参数为最新的value的值。这就是为什么不需要在子组件中使用使用emit抛出事件,因为在defineModel宏函数编译成的useModel函数中已经帮我们使用emit抛出事件了。

watchSyncEffect函数

我们接着来看子组件中怎么接收父组件传递过来的props呢,答案就在watchSyncEffect函数中。回忆一下前面讲过的useModel函数中的watchSyncEffect代码如下:

ini 复制代码
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的单向数据流

相关推荐
崔庆才丨静觅2 分钟前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅24 分钟前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment1 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅1 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊1 小时前
jwt介绍
前端
爱敲代码的小鱼1 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte2 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc
NEXT062 小时前
前端算法:从 O(n²) 到 O(n),列表转树的极致优化
前端·数据结构·算法
剪刀石头布啊2 小时前
生成随机数,Math.random的使用
前端