Vue props 传递对象与数组的引用注意事项
注意在 JavaScript 中对象和数组是通过引用传入的,所以对于一个数组或对象类型的 prop 来说,在子组件中改变变更这个对象或数组本身将会影响到父组件的状态。
具体是怎么「漏」出去的
Vue 的单向数据流约束只拦得住对 prop 变量本身的重新赋值,拦不住对 prop 所指向那个对象的内部改动。因为响应式代理是挂在对象上的,父子拿到的是同一个代理,谁改都会触发同一份依赖。
<!-- Child.vue -->
<script setup>
const props = defineProps({
user: Object,
list: Array,
});
function bad() {
// 会报警告:Set operation on key "user" failed: target is readonly
props.user = { name: 'x' };
// 不会报任何警告,但父组件的数据被静默改掉了
props.user.name = 'x';
props.list.push(1);
props.list.sort();
}
</script>
区别在于 Vue 只把 props 这个容器对象做了 readonly 处理(开发环境下是 shallowReadonly),没有递归深冻结。所以 props.user = ... 被拦下并警告,props.user.name = ... 则一路直达父组件的响应式对象。
顺带一个容易忽略的点:sort()、reverse()、splice() 这些数组方法是原地修改的,看起来像是「只读了一下顺序」,实际改了父组件的数组。我踩过一次表格组件内部 props.list.sort() 做本地排序,结果父组件那份原始数据顺序被永久打乱,后面依赖原始顺序的逻辑全错。
该怎么写
只读不改的场景:直接用,什么都不用做。
需要基于 prop 派生数据:用 computed,不要改 prop。
const sortedList = computed(() => {
// 先拷一份再排,不动原数组
return [...props.list].sort((a, b) => a.order - b.order);
});
需要本地可编辑的副本:显式拷贝,并且注意深浅。
import { ref, watch } from 'vue';
const form = ref(structuredClone(props.user));
// prop 变化时同步重建本地副本
watch(
() => props.user,
val => (form.value = structuredClone(val)),
{ deep: true },
);
structuredClone 是现代浏览器和 Node 17+ 的内置深拷贝,比 JSON.parse(JSON.stringify()) 靠谱——它能正确处理 Date、Map、Set、循环引用,而 JSON 那套会把 Date 变成字符串、把 undefined 和函数直接丢掉。缺点是它不能拷贝函数,遇到函数属性会抛 DataCloneError。数据里带函数就只能用 lodash 的 cloneDeep。
用扩展运算符 { ...props.user } 是浅拷贝,只有一层的简单对象够用,嵌套对象仍然共享引用。
需要修改父组件数据:走 emit,把决定权交回父组件。
<!-- Child.vue -->
<script setup>
const props = defineProps({ user: Object });
const emit = defineEmits(['update:user']);
function rename(name) {
// 发出新对象,不改原对象
emit('update:user', { ...props.user, name });
}
</script>
<!-- Parent.vue -->
<template>
<!-- v-model:user 是 update:user 的语法糖 -->
<Child v-model:user="user" />
</template>
defineModel 更省事
Vue 3.4 之后 defineModel 稳定了,双向绑定的样板代码可以省掉:
<script setup>
// 自动声明 modelValue prop 和 update:modelValue 事件
const user = defineModel('user', { type: Object, required: true });
function rename(name) {
// 直接赋值,内部会 emit
user.value = { ...user.value, name };
}
</script>
defineModel 返回的 ref 是可写的,写入时自动 emit 对应事件。但要注意它仍然是「替换整个值」的语义,user.value.name = 'x' 这种写法照样是在改父组件的对象。
对象类型 prop 的默认值必须用工厂函数
这是另一个和引用相关的坑。对象和数组的 default 必须写成函数返回,否则所有组件实例会共享同一个默认对象:
defineProps({
// 错:所有实例共用一个数组
list: { type: Array, default: [] },
// 对
list: { type: Array, default: () => [] },
// 工厂函数能拿到原始 props,做联动默认值
config: { type: Object, default: rawProps => ({ size: rawProps.size }) },
});
Vue 在开发环境下会对前一种写法给出警告,但生产构建里警告被移除,问题会变得很隐蔽。
想彻底防住的话
如果这个组件是给别人用的公共组件,靠约定不够,可以在开发环境显式冻结:
import { readonly } from 'vue';
const user = import.meta.env.DEV ? readonly(props.user) : props.user;
readonly 是深层的,任何层级的写操作都会被拦下并警告。只在开发环境包一层是因为它有代理开销,生产环境不值得。
更彻底的做法是干脆不传对象——把需要的字段拆成基本类型 prop 传进来。基本类型是值拷贝,天然不存在这个问题。字段不多的时候这是最省心的方案。

还没有留言,来发表第一条吧~