vue组件props接收对象保持单向数据流的几种方式

659 阅读5分钟

一、前言

大家好,下文将首先介绍vue单向数据流的概念,之后再通过一个自定义表单组件,引出大部分人在工作中可能会犯的违反单向数据流的反面例子,来加深对其的理解,最后再介绍保持单向数据流的多种解决方式。最后还有vueuse封装的useVModel钩子函数的解决方案及其源码解析。

二、单向数据流介绍(来自vue官方文档)

传送口:vue官方文档——单向数据流

所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。

更改对象 / 数组类型的 props

当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,而对 Vue 来说,禁止这样的改动,虽然可能生效,但有很大的性能损耗,比较得不偿失。

这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。

三、违反单向数据流的表单封装例子:

Parent.vue

<template>
  <Form v-model="form"></Form>
  <div>父组件:{{ form.name }}</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Form from '@/components/Form/Form.vue'

let form = ref({
  name: ''
})
</script>

Form.vue

<template>
  <el-form>
    <el-form-item label="名称">
      <el-input placeholder="名称" v-model="modelValue.name"></el-input>
    </el-form-item>
  </el-form>
  <div>子组件:{{ modelValue.name }}</div>
</template>

<script setup lang="ts">
type TProps = {[key: string]: any}
defineProps<{
  modelValue: TProps,
}>()
</script>

运行以上代码,在输入框中输入值,你会发现父组件的form表单的值也会跟着改变。这就是典型的直接修改props的值,违反了vue单向数据流的原则。

可能大家也想到了用computed拦截整个对象,比如以下示例:

const proxy = computed({
  get: () => props.modelValue,
  set: (value) => {
    console.log(value)  // 不会执行
    emit('update:modelValue', value)
  }
})

but,当你修改表单值时,set方法根本不会执行,还是会直接修改props的值,这是由于set不会监听对象里面属性的变化,需要对整个proxy重新赋值才会执行set方法,比如:proxy.value = {name: '哈哈哈'}

四、多种解决方式

方式1——computed拦截单个属性

const name = computed({
  get: () => props.modelValue.name,
  set: (value) => {
    emit('update:modelValue', {
      ...props,
      name: value
    })
  }
})

缺点: 要求知道表单的每个属性,一个个手动做computed拦截。

当然,如果非要做成动态为每个属性定义computed拦截,也是可以实现,只需创建一个ref对象,key为表单属性,value为每个属性的computed拦截对象。

const proxy = ref<{[key: keyof TProps]: any}>({})
for (let key in props.modelValue) {
  proxy.value[key] = computed({
    get: () => props.modelValue[key],
    set: (value) => {
      emit("update:modelValue", {
        ...props.modelValue,
        [key]: value,
      });
    },
  });
}

 <!-- 模板中使用proxy -->
<template>
  <el-form>
    <el-form-item label="名称">
      <el-input placeholder="名称" v-model="proxy.name"></el-input>
    </el-form-item>
    <el-form-item label="地址">
      <el-input placeholder="地址" v-model="proxy.address"></el-input>
    </el-form-item>
  </el-form>
</template>

以上主要通过遍历表单对象中的每个属性,动态为每个属性设置compoted拦截,保存到一个新的对象当中供模板中使用。

note: 为了方便观看,当前例子和之后例子,模板中绑定的表单对象将统一以proxy进行命名。

方式2——watch监听

const clone = (val: TProps) => JSON.parse(JSON.stringify(val))
const proxy = ref(clone(props.modelValue))

watch(proxy, (newValue) => {
  emit('update:modelValue', newValue)
}, {
  deep: true
})

表单组件单独维护一份深度克隆的表单数据,当监听到表单变化时,再更新父组件的状态。

方式3——Proxy代理对象

const proxy = computed(() => {
  return new Proxy(props.modelValue, {
    get(target, key) {
      return Reflect.get(target, key);
    },
    set(target, key, value) {
      emit('update:modelValue', {
        ...target,
        [key]: value,
      });
      return true;
    },
  })
})

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

传送门: Proxy文档

参考文章:妙用computed拦截v-model,面试管都夸我细

方式4——vueuse的useVModel钩子函数

import { computed, ref, watch } from "vue";
import { useVModel } from '@vueuse/core'

type TProps = {[key: string]: any}
const props = defineProps<{
  modelValue: TProps;
}>();
const emit = defineEmits<{
  (e: "update:modelValue", value: TProps): void;
}>();

const proxy = useVModel(props, 'modelValue', emit, {
  // 开启watch监听
  passive: true,
  // watch深度监听
  deep: true,
  deep: true,
  /**
   * 克隆表单对象
   *  When setting to `true`, it will use `JSON.parse(JSON.stringify(value))` to clone.
   */
  clone: true
})

useVModel源码当中,如果传递第4个配置对象参数,则passive默认为false,使用的是computed拦截的方式更新父组件状态;passivetrue则使用watch监听的方式进行更新父组件状态。

传送口: useVModel官方文档

vueuse的useVModel源码

code-snapshot.png

结语

以上是我目前想到的几种方式,在使用时最好封装成一个hook函数,可参考vueuse的封装方式。大家如果有什么问题,或者觉得有更好的方式以及哪种方式更好,欢迎评论区进行留言交流,感谢大家!

往期文章回顾

table表格自适应浏览器窗口变化解决方案

一文学会vue3如何自定义hook钩子函数和封装组件

一文学会请求中断、请求重发、请求排队、请求并发