◆

Vue props 传递对象与数组的引用注意事项

2023-02-02 01:370

注意在 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 传进来。基本类型是值拷贝,天然不存在这个问题。字段不多的时候这是最省心的方案。

💬留言板0

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

PAGE LOAD / LIVEVue props 传递对象与数组的引用注意事项加载中