一、前言
大家好,下文将首先介绍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
拦截的方式更新父组件状态;passive
为true
则使用watch
监听的方式进行更新父组件状态。
传送口: useVModel官方文档
vueuse的useVModel源码
结语
以上是我目前想到的几种方式,在使用时最好封装成一个hook
函数,可参考vueuse
的封装方式。大家如果有什么问题,或者觉得有更好的方式以及哪种方式更好,欢迎评论区进行留言交流,感谢大家!