【Vue3】- 关于自定义组件使用对象的一些思考
前言
在前面我们学习了了关于怎么定义v-model(Vue3学习踩坑 - 自定义v-model),在这个过程中,我们使用的都是一些简单的类型,比如string或number类型的,那对于自定义组件,使用对象类型的这种,如果使用了v-model,或者直接定义对象传入子组件一般要怎么处理呢,针对这个问题我使用了一般v-model相同的处理方式,做了些尝试,但是却产生了一些问题,查阅了很多的资料后得到了我认为目前比较正确的结论,特此这里做下记录。
正文
首先我们来看错误的使用方式,同样的我们直接先上demo
vmode1.vue(写法1:通过computed计算属性)
<template>
<div class="vmodel1">
<div>
<button @click="add">add</button>
<div>{{ user.username }}</div>
<div>{{ user.age }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { iUser } from "@/components/vmodel/user";
interface iProps {
user: iUser;
}
const props = defineProps<iProps>();
interface iEmits {
(e: "update:user", user: iUser): void;
}
const emits = defineEmits<iEmits>();
let user = computed({
get: () => props.user,
set: (val) => {
console.log("val", val);
emits("update:user", val);
},
});
const add = () => {
user.value.age++;
};
</script>
vmode1.vue(写法2:通过toRefs将props变成响应式)
<template>
<div class="vmodel1">
<div>
<button @click="add">add</button>
<div>{{ user.username }}</div>
<div>{{ user.age }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { toRefs } from "vue";
import type { iUser } from "@/components/vmodel/user";
interface iProps {
user: iUser;
}
const props = defineProps<iProps>();
let { user } = toRefs(props);
const add = () => {
user.value.age++;
};
</script>
App.vue
<template>
<div>
<vmodel1 v-model:user="user" />
<div>user:{{ user }}</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import Vmodel1 from "@/components/vmodel/vmodel1.vue";
let user = ref({
username: "legion",
age: 20,
});
</script>
在图中我们可以看到,通过这种方式,我们在点击后更新App.vue组件中的user对象的值,从而实现数据的改变。
**但是!**这里如果仔细的看下就不难发现,在vmodel1.vue的第28行的console根本就没有打印!(甚至写法2中,直接就没有定义emit了,但是也实现了改变父组件的数据!),有兴趣的同学可以自己尝试下,在App.vue中,不使用<vmodel1 v-model:user="user" />,而是直接:<vmodel1 :user="user" />,同样可以修改父组件的值,也就是说完全被v-model给迷惑了,其实根本就没有触发事件。
也就是说,我们虽然实现了子组件修改值后父组件的值触发修改,但是实际上根本不是通过emit来实现的,也就是说通过这种方式,只是实现了值的改变,但是却完全违反了单向数据流原则,即原则上,我们不能在子组件中改变父组件的值,否则将会导致数据流将很容易变得混乱而难以理解,因此这里一定要注意这种是错误的示范!!。
那么针对这种对象类型的v-model,我们要怎么处理呢?正确的方式应该如下:
这里同样存在两种方式:
方式一:
针对对象的属性进行“拆分”,将需要用到的变量拆分到各个新的计算属性中,比如:
vmodel1.vue
<template>
<div class="vmodel1">
<div>
<button @click="add">add</button>
<div>{{ user.username }}</div>
<div>{{ user.age }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { iUser } from "@/components/vmodel/user";
interface iProps {
user: iUser;
}
const props = defineProps<iProps>();
interface iEmits {
(e: "update:user", user: iUser): void;
}
const emits = defineEmits<iEmits>();
let age = computed({
get: () => props.user.age,
set: (age) => emits("update:user", { ...props.user, age }),
});
const add = () => {
age.value++;
};
</script>
这里我们将age 字段单拆出来,通过计算属性的getter/setter方法来做取、设置值的动作,当设置计算属性的值的时候,会触发set方法,我们再将新的值emit出去,通知父组件进行处理,这里需要注意的是,我们emit的是要一个完整的对象,包含父组件属性的所有字段,否则父组件对象会被改变。
方式二:
不难发现,对于方式一的这种,比较适合从对象中取值时对象的属性不多的这种情况,但是如果需要用到的字段较多呢?比如说我们有100个字段,总不能把100个都定义计算属性吧?因此我们可以使用下面这种方式:
通过watch函数,来监听对象的值的变化:
<template>
<div class="vmodel1">
<div>
<button @click="add">add</button>
<div>{{ user.username }}</div>
<div>{{ user.age }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from "vue";
import type { iUser } from "@/components/vmodel/user";
interface iProps {
user: iUser;
}
const props = defineProps<iProps>();
interface iEmits {
(e: "update:user", user: iUser): void;
}
const emits = defineEmits<iEmits>();
const user = reactive({ ...props.user });
watch(user, (user) => {
console.log("user", user);
emits("update:user", { ...user });
});
const add = () => {
user.age++;
};
</script>
这里我们先试用reactive,将props的对象转换为一个响应式对象,然后再使用watch来对其进行监听,当值发生改变后,再通过emit将其通知给父组件,同样的这里要传递整个新的对象。
通过上面的两种方式,我们就可以很愉快的在自定义组件中使用对象或者v-model中使用对象了(可以尝试将App.vue中的v-mode:user,改成:user,可以发现是没法更新父组件的值的)。
总结
针对上面的正确传递对象的方式,我们做下总结
- 子组件使用计算属性,不要返回父组件的代理对象,否则很容易就会打破单向数据流向的原则
- 计算属性的setter方法,需要emit整个对象给父组件,且这个对象,不能是一个响应式类型
- 父组件传入的对象,不能使用reactive进行包裹,原因也很简单,因为我们emit的时候,是整个对象,如果使用reactive去包裹对象,会导致它失去响应式!