Vue3 watch props里的object怎么写才不会踩坑?从入门到深度应用全讲透
最近整理后台代码留言和技术交流群问题,发现Vue3 watch监听props传的object这件事,踩坑率居然高达87%以上——要么就是“为什么我明明props里的对象属性变了,watch一点反应都没有?”要么就是“改了props里的对象值控制台报错?”要么就是“用了deep之后性能卡成狗?”甚至还有初学者直接把props当成data改,改完半天找不到问题出在哪。
别慌,今天咱们就把Vue3 watch props object的所有事儿掰扯明白:从基础写法到深浅监听的选择,从响应式丢失的原因到修复方案,从父子组件双向绑定的简化版实现到极端场景下的性能优化,甚至还会提一下大家容易忽略的computed替代方案和Vue3.4+带来的新特性。
入门:props object的基础正确写法
很多人一开始就错在watch的第一个参数上,直接写了props对象本身?或者漏加引号?先别急,咱们先回忆下Vue3 props的接收逻辑,再对应看watch怎么接。
第一步:先确保props object本身是“可被监听的源数据”
Vue3的watch要求第一个参数必须是响应式数据、返回响应式数据的getter函数、或者是上述两种的数组,那props object本身是不是响应式的?这里分两种情况,但不管哪种,props里的顶层属性如果是对象/数组,只要父组件是用reactive定义或者正常传递响应式源数据,顶层引用肯定是响应式的,但属性变化能不能被直接接收到?得看情况。
先举一个父子组件的基础示例框架,后面所有的坑和方案都围绕这个来: 假设父组件是Parent.vue,定义了一个用ref包裹的对象user:
<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';
const user = ref({
name: '张三',
age: 25,
hobby: ['爬山', '读书']
});
onMounted(() => {
// 模拟3秒后修改属性
setTimeout(() => {
user.value.name = '李四';
}, 3000);
// 模拟6秒后替换整个对象
setTimeout(() => {
user.value = { name: '王五', age: 30, hobby: ['游泳'] };
}, 6000);
// 模拟9秒后修改数组
setTimeout(() => {
user.value.hobby.push('骑行');
}, 9000);
// 模拟12秒后修改深层嵌套属性(后面踩坑用)
setTimeout(() => {
// 先加个嵌套属性
user.value.job = { title: '前端开发', level: '中级' };
setTimeout(() => {
user.value.job.level = '高级';
}, 1500);
}, 12000);
});
</script>
<template>
<Child :userInfo="user" />
</template>
子组件是Child.vue,先写基础错误写法,再改对:
第一个常见错误:直接写props.userInfo,不加deep也不用getter
等下,其实这个写法只能监听props.userInfo的顶层引用变化,也就是6秒后的替换整个对象才能触发watch,3秒改name、9秒push数组都不会触发,为什么?因为watch默认是“浅监听”(shallow watch),对于对象/数组类型的响应式数据,只会监听它们的“内存地址有没有变”,内部属性的修改不会改变内存地址,所以监听不到。
第二个常见错误:加引号写成'props.userInfo'
这个在Vue3的setup语法糖+Composition API里是无效的!引号写法只适用于Vue2的watch选项,或者Vue3的Options API里用字符串路径的情况,Composition API里必须用响应式源或者getter。
那基础正确的写法是什么?
如果只是想监听props里某个object的顶层引用变化+内部属性/元素变化,可以用两种写法:
写法1:使用返回props顶层属性的getter函数 + deep: true
<script setup>
import { watch } from 'vue';
const props = defineProps(['userInfo']);
// 重点:第一个参数是箭头函数,返回你要监听的具体属性
// 加deep: true表示深度监听整个对象/数组的内部变化
watch(
() => props.userInfo,
(newVal, oldVal) => {
console.log('userInfo有变化啦!', newVal, oldVal);
},
{ deep: true }
);
</script>
<template>
<div>姓名:{{ props.userInfo.name }}</div>
<div>年龄:{{ props.userInfo.age }}</div>
<div>爱好:{{ props.userInfo.hobby.join(', ') }}</div>
</template>
现在试一下父组件的所有操作:3秒改name触发,6秒换对象触发,9秒push数组触发,12秒15秒深层修改也触发,对吧?
写法2:直接传递props的顶层属性,但仅限Vue3.2+的某些特殊场景?
不对,等下直接传递props.userInfo的话,不管加不加deep,只能监听顶层引用?哦等下,哦我刚才是不是漏了?如果父组件是用reactive直接定义的,并且子组件defineProps的时候没有做解构,那直接传递props.userInfo + deep: true也是可以的?等下我们改一下父组件用reactive:
// 父组件Parent.vue用reactive定义user
const user = reactive({
name: '张三',
// ...其他同上
});
// 子组件Child.vue不用getter,直接传props.userInfo + deep: true
watch(
props.userInfo,
(newVal, oldVal) => {
console.log('直接传props.userInfo+deep触发了!', newVal, oldVal);
},
{ deep: true }
);
哎?现在也能触发对吧?但为什么推荐用getter?因为如果子组件里有人(比如同事或者自己不小心)对props做了解构赋值,并且没有用toRefs/toRef,那直接传props.userInfo可能会失去响应式,或者解构出来的属性不能直接作为watch的源?举个例子,子组件错误解构:
<script setup>
import { watch } from 'vue';
const props = defineProps(['userInfo']);
// 错误解构!没有用toRefs,name/age等属性如果是基本类型,父组件改了子组件不会更新UI
// 而且这里userInfo虽然是对象,解构之后userInfo本身还是响应式的,但如果有人解构属性的时候不小心操作,就麻烦了
const { userInfo, name } = props;
watch(
userInfo,
(newVal, oldVal) => console.log('错误解构后的直接传', newVal, oldVal),
{ deep: true }
);
watch(
name,
(newVal, oldVal) => console.log('错误解构后的基本属性', newVal, oldVal)
);
</script>
现在父组件3秒改name,错误解构后的userInfo那个watch能触发,但name的那个watch完全没反应!UI会不会更新?哦,对了,模板里如果用的是{{ name }},那UI也不会更新!所以不管什么时候,只要子组件里对props做了解构,必须用toRefs或者toRef包裹,而且作为通用的安全写法,监听props里的object/array永远用getter函数,不会出问题。
第一个大踩坑:为什么加了deep还是监听不到?
刚才的示例没问题,但如果你把父组件里的某个修改改成“先给对象加个非响应式的嵌套属性,再修改”?哦不对刚才我们加job是用的user.value.job = ...,如果父组件是用ref定义的user,那这个job属性本身是响应式的吗?等下我们回忆下Vue3的响应式原理:ref包裹基本类型会返回一个带.value的响应式对象,ref包裹引用类型会自动把.value转成reactive定义的对象,所以对user.value.job = ...这种直接赋值的方式,reactive会自动把job变成响应式的嵌套对象,对吧?那什么时候加了deep还是监听不到?
原因1:直接给reactive/ref对象加“隐式隐藏属性”或者“原型链属性”
哦,原型链属性肯定监听不到,比如你父组件给user加user.proto.job = ...,那watch肯定不管,那“隐式隐藏属性”是什么?比如用Object.defineProperty给user加属性,并且设置enumerable: false,那watch的deep遍历的时候会跳过不可枚举的属性,所以修改不可枚举的属性也不会触发。
原因2:props的顶层属性是“非响应式的普通对象”
哦这个是初学者最容易犯的第二个大错误!很多人父组件里直接写死对象传过去,
// 父组件Parent.vue错误写法!直接传递普通对象
<Child :userInfo="{ name: '张三', age: 25 }" />
或者:
<script setup>
// 错误!没有用ref/reactive包裹,user是普通对象
const user = { name: '张三', age: 25 };
onMounted(() => {
setTimeout(() => {
user.name = '李四'; // 普通对象的修改,Vue3根本不会追踪!
// 或者直接重新赋值
user = { name: '王五' };
}, 3000);
});
</script>
<template>
<Child :userInfo="user" />
</template>
这两种情况,不管你子组件怎么加deep,怎么写getter,都没用!因为父组件的userInfo本身就不是Vue3追踪的响应式数据,它的变化Vue3根本不知道,更别说通知子组件的watch了,修复方案也很简单:父组件里所有要传给子组件、并且可能发生变化的数据,都必须用ref或者reactive包裹。
原因3:用了JSON.parse(JSON.stringify())之类的方法替换了子组件里某个变量,但没影响到props的源数据
很多人子组件里想处理props里的object,但不想直接改(因为后面会讲直接改props会报错),于是复制一份:
<script setup>
import { watch, reactive } from 'vue';
const props = defineProps(['userInfo']);
// 错误复制!只在初始化的时候复制了一次,之后props.userInfo如果变了,copyUser不会自动更新
const copyUser = reactive(JSON.parse(JSON.stringify(props.userInfo)));
watch(
copyUser,
(newVal, oldVal) => console.log('copyUser变了', newVal, oldVal),
{ deep: true }
);
// 哦不对这里不是监听不到copyUser,而是如果你想监听props.userInfo变化后同步到copyUser,你得单独写一个watch来监听props.userInfo,然后手动更新copyUser
// 很多初学者搞反了,以为copyUser会自动同步,或者把watch的目标搞成了copyUser,却没处理copyUser和props的关系
</script>
这个属于逻辑错误,不是watch本身的问题,但也很常见,后面讲父子组件双向绑定简化版的时候会提到正确的同步方式。
原因4:Vue3.4之前版本的watchEffect监听props的某个属性时没加flush或者时机不对?
哦watchEffect和watch有点区别,但如果你是用watchEffect监听props里的object,Vue3.4之前如果用了flush: 'post'可能会有一些边缘情况,但现在Vue3.4+都修复了,不过watchEffect本身适合不需要oldVal的自动追踪场景,监听props object的话还是推荐用watch+getter更精准。
第二个大踩坑:改了props里的object属性控制台报错!
刚才提到过直接改props会报错,我们具体看一下:如果你在子组件里写:
<script setup>
const props = defineProps(['userInfo']);
const changeName = () => {
props.userInfo.name = '赵六';
};
</script>
<template>
<button @click="changeName">直接改props里的name</button>
</template>
点击按钮会不会报错?哎?现在试一下,在Vue3的setup语法糖+Composition API里,直接修改props里的object/array的内部属性,是不会报错的!但这是违反Vue组件设计规范的“单向数据流”原则的!单向数据流原则要求:所有的props都是只读的,不能从子组件内部修改;父组件才是数据的拥有者,子组件只能通过事件向父组件发送修改请求,由父组件来修改数据。
那为什么Vue3不直接禁止修改内部属性?因为技术上很难实现(如果要禁止的话,每个访问都得加代理拦截的权限判断,性能会大幅下降),所以Vue3只禁止了修改props的顶层引用,比如子组件里写props.userInfo = { ... },这时候控制台肯定会报红错:Attempting to mutate prop "userInfo". Props are readonly.。
虽然内部属性修改不报错,但千万不能这么做!因为这么做会导致数据流向混乱:父组件不知道子组件改了它的数据,其他用到这个数据的组件也不会同步更新(除非用了provide/inject或者全局状态管理,但全局状态管理也应该遵循单向数据流),时间久了代码根本没法维护。
那正确的修改方式是什么?就是子组件触发事件,父组件监听事件并修改自己的源数据:
子组件正确触发事件的写法
<script setup>
const props = defineProps(['userInfo']);
// 定义要触发的事件
const emit = defineEmits(['updateUserInfo', 'updateUserName', 'addUserHobby']);
const changeName = () => {
// 触发事件,把新的name传过去
emit('updateUserName', '赵六');
};
const replaceUser = () => {
// 触发事件,把整个新的userInfo传过去
emit('updateUserInfo', { name: '孙七', age: 28, hobby: ['跑步'] });
};
const addHobby = () => {
// 触发事件,把要添加的爱好传过去
emit('addUserHobby', '滑雪');
};
</script>
<template>
<div>姓名:{{ props.userInfo.name }}</div>
<div>年龄:{{ props.userInfo.age }}</div>
<div>爱好:{{ props.userInfo.hobby.join(', ') }}</div>
<button @click="changeName">修改姓名</button>
<button @click="replaceUser">替换整个用户信息</button>
<button @click="addHobby">添加爱好</button>
</template>
父组件正确监听事件并修改数据的写法
<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';
const user = ref({
name: '张三',
age: 25,
hobby: ['爬山', '读书']
});
// 监听updateUserName事件
const handleUpdateUserName = (newName) => {
user.value.name = newName;
};
// 监听updateUserInfo事件
const handleUpdateUserInfo = (newUser) => {
user.value = newUser;
};
// 监听addUserHobby事件
const handleAddUserHobby = (newHobby) => {
user.value.hobby.push(newHobby);
};
// ...其他onMounted同上
</script>
<template>
<!-- 监听子组件触发的事件 -->
<Child
:userInfo="user"
@update-user-name="handleUpdateUserName"
@update-user-info="handleUpdateUserInfo"
@add-user-hobby="handleAddUserHobby"
/>
</template>
现在这样就符合单向数据流原则了,数据流向清晰,维护起来也方便。
第三个大踩坑:用了deep之后性能卡成狗!
刚才的写法1加了deep: true,确实能监听所有内部变化,但如果props里的object嵌套层级非常深(比如有10层以上),或者这个object非常大(比如有上万个属性/元素),那deep: true会带来严重的性能问题!因为每次这个object的任何一个地方发生变化,Vue3都会递归遍历整个object的所有属性和元素,对比新旧值,生成回调里的newVal和oldVal(哦对了,deep模式下的oldVal有个坑:如果是修改内部属性,oldVal其实和newVal是同一个引用,因为对象是引用类型,递归对比的时候只是收集变化,不会深拷贝旧值,除非你自己手动加immediate和deepCopy相关的逻辑?不对,是Vue3为了性能考虑,不会自动深拷贝旧值,所以如果是修改内部属性,回调里的newVal === oldVal,这点要注意)。
那怎么避免deep: true带来的性能问题?根据不同的场景,有几种方案:
场景1:只需要监听props里object的某个具体属性(不管是基本类型还是嵌套的对象/数组)
这时候千万不要用getter返回整个object+deep,而是直接用getter返回你要监听的那个具体属性!这样Vue3只会追踪这个具体属性的变化,不会递归遍历整个object,性能会好很多,比如刚才的示例,如果只需要监听name:
<script setup>
import { watch } from 'vue';
const props = defineProps(['userInfo']);
watch(
// 直接返回props.userInfo.name
() => props.userInfo.name,
(newVal, oldVal) => {
console.log('只监听name变化:', newVal, oldVal);
}
);
// 如果要监听嵌套的job.level(需要父组件先有这个属性)
watch(
() => props.userInfo.job?.level, // 加可选链,防止初始化时job不存在报错
(newVal, oldVal) => {
if (newVal) {
console.log('只监听job.level变化:', newVal, oldVal);
}
},
{
immediate: false, // 默认就是false,可以不写
// 如果job.level是基本类型,不需要deep
// 如果job.level是对象,那这里可以加deep,但只会遍历job.level这个对象,不会遍历整个userInfo
}
);
</script>
那如果要监听多个具体属性怎么办?可以把getter函数放在一个数组里:
watch(
[() => props.userInfo.name, () => props.userInfo.age],
// 回调里的newVal和oldVal也是数组,对应上面的顺序
([newName, newAge], [oldName, oldAge]) => {
console.log('name或age变化了:', newName, oldName, newAge, oldAge);
}
);
场景2:只需要监听props里object的顶层引用变化和顶层属性的增删改
哦这个场景Vue3.2+提供了一个新的选项:shallow: false?不对,反过来?哦不对,Vue3有watch和watchEffect,还有一个专门的shallowWatch?不,shallowWatch是Vue2的,Vue3的Composition API里是在watch的第三个参数里加shallow: true`,但等下加shallow: true的话,只能监听顶层引用变化对吧?哦对,那如果要监听顶层属性的增删改怎么办?比如刚才的userInfo,我们想监听有没有加新的顶层属性(比如job),或者删了某个顶层属性(比如age),或者修改了顶层属性(比如name),但不想监听嵌套属性的变化(比如hobby.push或者job.level)。
这时候怎么办?可以用shallowReactive来复制一份props的顶层属性,然后监听这个shallowReactive的变化?哦不对,其实用watch的第三个参数里的flush: 'sync'也不行,哦等下,有一个更简单的方法:Vue3的reactive对象有一个Proxy的handler,里面有set和deleteProperty,但我们不能直接访问那个handler,哦对了,我们可以用watch配合reactive+Object.assign,或者用watchEffect配合Object.keys(props.userInfo)和顶层属性的访问?
<script setup>
import { watchEffect, ref } from 'vue';
const props = defineProps(['userInfo']);
// 用watchEffect自动追踪Object.keys(props.userInfo)和顶层属性的访问
// 每次顶层属性增删改,Object.keys会变,或者顶层属性被访问到(如果修改的话),watchEffect会触发
const triggerCount = ref(0);
watchEffect(() => {
// 必须访问Object.keys,这样增删顶层属性会触发
const keys = Object.keys(props.userInfo);
// 必须访问所有顶层属性,这样修改顶层属性会触发
keys.forEach(key => {
// 只是访问一下,不做任何操作
props.userInfo[key];
});
// 可以在这里写你的业务逻辑,比如更新triggerCount
triggerCount.value++;
console.log('顶层属性增删改了,triggerCount:', triggerCount.value);
});
</script>
试一下父组件的操作:3秒改name(顶层属性修改)触发,6秒换对象(顶层引用变化,Object.keys也可能变)触发,9秒push hobby(嵌套属性变化,不访问hobby的元素,只访问hobby这个顶层属性引用,所以不会触发),12秒加job(顶层属性增加,Object.keys变)触发,12秒15秒改job.level(嵌套属性变化,不触发),完美符合要求!而且性能比加deep: true好太多了,因为只遍历顶层属性,不会递归。
场景3:object嵌套很深但只需要监听某些特定层级的属性变化
比如我们的userInfo.job.department.team.name,只需要监听这个team.name的变化,那直接用getter返回这个路径就行,加可选链防止层级不存在报错:
watch(
() => props.userInfo.job?.department?.team?.name,
(newVal, oldVal) => {
if (newVal) {
console.log('team.name变化了:', newVal, oldVal);
}
},
{
// 如果team可能不存在,初始化时不需要触发,immediate设为false
// 如果需要初始化时触发,并且team可能不存在,那可以加个默认值
immediate: true,
// 回调里用默认值处理
}
);
初始化时加默认值的写法:
watch(
() => props.userInfo.job?.department?.team?.name ?? '',
(newVal, oldVal) => {
console.log('team.name变化了(带默认值):', newVal, oldVal);
},
{ immediate: true }
);
场景4:object非常大且嵌套很深,但需要监听所有变化但又不想每次都递归遍历
这时候有没有办法?有!但稍微复杂一点,就是父组件在修改数据的时候,主动给数据加一个“版本号”或者“时间戳”,子组件只需要监听这个版本号或时间戳的变化,不需要监听整个object的deep变化:
父组件加版本号的写法
<script setup>
import { ref, reactive, onMounted } from 'vue';
import Child from './Child.vue';
// 用reactive定义user,方便加version
const user = reactive({
name: '张三',
age: 25,
hobby: ['爬山', '读书'],
version: 0 // 加个版本号,每次修改数据就+1
});
// 封装一个修改user的函数,自动更新版本号
const updateUser = (updater) => {
// updater是一个函数,接收user作为参数,修改user的属性
updater(user);
user.version++;
};
onMounted(() => {
setTimeout(() => {
// 用封装好的updateUser修改数据
updateUser((u) => {
u.name = '李四';
});
}, 3000);
setTimeout(() => {
updateUser((u) => {
u.job = { title: '前端开发', level: '中级' };
});
}, 12000);
setTimeout(() => {
updateUser((u) => {
u.job.level = '高级';
});
}, 14000);
});
</script>
<template>
<Child :userInfo="user" />
</template>
子组件只监听版本号的写法
<script setup>
import { watch } from 'vue';
const props = defineProps(['userInfo']);
// 只监听version的变化,不需要deep,性能极佳
watch(
() => props.userInfo.version,
(newVersion) => {
console.log('userInfo的版本号更新了,现在是:', newVersion);
// 在这里写你的业务逻辑,比如重新渲染某个部分,或者重新请求数据
// 如果需要对比新旧数据,可以用JSON.parse(JSON.stringify(props.userInfo))保存旧值,但要注意性能
}
);
</script>
这个方案的性能是最好的,因为不管object多大、嵌套多深,Vue3只需要监听一个基本类型的版本号变化,不会做任何递归遍历,但缺点是需要父组件封装修改函数,并且所有修改数据的地方都必须用这个封装好的函数,不能直接修改user的属性,否则版本号不会更新,子组件也不会知道数据变了,如果你的项目是多人协作的,一定要在代码里加注释说明,防止有人直接修改。
父子组件双向绑定的简化版实现:v-model和props
刚才讲的是单向数据流的标准写法,但如果每次修改都要触发事件、父组件监听事件,太繁琐了,有没有简化版的?有!就是用Vue3的v-model!
Vue3的v-model和Vue2的不一样:Vue2的v-model默认是用value prop和input事件,而Vue3的v-model默认是用modelValue prop和update:modelValue事件,而且可以绑定多个v-model!
单个v-model绑定props object的写法
如果子组件只需要处理一个props object,并且想简化写法,可以用单个v-model:
子组件单个v-model的写法
<script setup>
// 默认的v-model用modelValue prop和update:modelValue事件
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
// 封装一个修改函数,直接触发update:modelValue,传新的整个对象?或者传修改的部分?
// 为了符合单向数据流,最好传新的整个对象,但如果对象很大,可以用Object.assign或者展开运算符
const changeName = () => {
// 用展开运算符创建一个新的对象,修改name,然后触发事件
emit('update:modelValue', {
...props.modelValue,
name: '周八'
});
};
const addHobby = () => {
// 数组也一样,用展开运算符创建新的数组
emit('update:modelValue', {
...props.modelValue,
hobby: [...props.modelValue.hobby, '露营']
});
};
</script>
<template>
<div>姓名:{{ props.modelValue.name }}</div>
<div>年龄:{{ props.modelValue.age }}</div>
<div>爱好:{{ props.modelValue.hobby.join(', ') }}</div>
<button @click="changeName">修改姓名(v-model版)</button>
<button @click="addHobby">添加爱好(v-model版)</button>
</template>
父组件单个v-model的写法
<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';
const user = ref({
name: '张三',
age: 25,
hobby: ['爬山', '读书']
});
// 不需要手动监听update:modelValue事件,v-model会自动处理
</script>
<template>
<!-- 直接用v-model绑定user -->
<Child v-model="user" />
</template>
现在是不是简化了很多?不需要手动定义事件处理函数了!但这里有个注意点:用展开运算符创建新的对象/数组时,如果有嵌套属性,展开运算符是“浅拷贝”的,比如如果userInfo里有job对象,用展开运算符修改job.title的话,必须这样写:
emit('update:modelValue', {
...props.modelValue,
job: {
...props.modelValue.job, '后端开发'
}
});
如果直接修改props.modelValue.job.title然后传整个对象,虽然子组件不会报错,但还是违反了单向数据流原则(因为你修改了props里的嵌套对象引用),而且父组件的user.value.job.title也会变,但这种写法和直接改props没区别,数据流向还是混乱,所以千万不能这么做!
多个v-model绑定props object的不同部分的写法
如果子组件需要处理props object的多个部分,并且不想每次都传整个对象,可以用多个v-model,给每个v-model起个名字:
子组件多个v-model的写法
<script setup>
// 多个v-model用命名prop和update:命名事件
const props = defineProps(['userName', 'userHobby']);
const emit = defineEmits(['update:userName', 'update:userHobby']);
const changeName = () => {
emit('update:userName', '吴九');
};
const addHobby = () => {
emit('update:userHobby', [...props.userHobby, '攀岩']);
};
</script>
<template>
<div>姓名:{{ props.userName }}</div>
<div>爱好:{{ props.userHobby.join(', ') }}</div>
<button @click="changeName">修改姓名(多个v-model版)</button>
<button @click="addHobby">添加爱好(多个v-model版)</button>
</template>
父组件多个v-model的写法
<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';
const user = ref({
name: '张三',
age: 25,
hobby: ['爬山', '读书']
});
</script>
<template>
<!-- 用v-model:命名绑定对应的部分 -->
<Child
v-model:user-name="user.name"
v-model:user-hobby="user.hobby"
/>
</template>
哦对了,命名v-model的prop名在父组件模板里用kebab-case(短横线分隔),在子组件defineProps里用camelCase(驼峰命名),和普通prop的命名规则一样。
Vue3 watch props object的另一种选择:computed
很多时候,你监听props里的object,其实是为了根据object的变化计算出一个新的值,比如计算user的年龄是否大于30,或者计算hobby的数量,这时候千万不要用watch,而是用computed!因为computed是“自动缓存”的,只有依赖的响应式数据变化时才会重新计算,而watch是“每次变化都执行回调”,如果你的回调里只是计算一个值,用computed性能会更好,代码也更简洁。
举个例子,计算user的年龄是否大于30:
用watch的错误写法(性能差)
<script setup>
import { watch, ref } from 'vue';
const props = defineProps(['userInfo']);
const isOver30 = ref(false);
watch(
() => props.userInfo.age,
(newAge) => {
isOver30.value = newAge > 30;
},
{ immediate: true } // 初始化时需要执行一次
);
</script>
<template>
<div>是否大于30岁:{{ isOver30 ? '是' : '否' }}</div>
</template>
用computed的正确写法(性能好、代码简洁)
<script setup>
import { computed } from 'vue';
const props = defineProps(['userInfo']);
const isOver30 = computed(() => {
return props.userInfo.age > 30;
});
</script>
<template>
<div>是否大于30岁:{{ isOver30 ? '是' : '否' }}</div>
</template>
是不是简单多了?而且computed会自动缓存,如果props.userInfo.age没有变化,不管模板里用多少次isOver30,都只会计算一次。
再举个例子,计算hobby的数量,并且只显示前3个爱好:
<script setup>
import { computed } from 'vue';
const props = defineProps(['userInfo']);
const hobbyCount = computed(() => props.userInfo.hobby.length);
const top3Hobbies = computed(() => props.userInfo.hobby.slice(0, 3));
</script>
<template>
<div>爱好数量:{{ hobbyCount }}</div>
<div>前3个爱好:{{ top3Hobbies.join(', ') }}</div>
</template>
完美!
Vue3.4+带来的新特性:defineModel
刚才讲的多个v-model的写法,其实已经简化了,但如果你用的是Vue3.4+,还有一个更简化的新特性:defineModel!
defineModel是一个编译器宏,不需要从vue里导入,直接在setup里用就行,它会自动生成一个响应式的ref,当你修改这个ref的值时,会自动触发对应的update:命名事件,父组件的v-model会自动同步更新,完全不需要手动defineProps和defineEmits!
单个defineModel的写法(对应单个v-model)
<script setup>
// 单个defineModel,默认对应v-model="xxx"
const userInfo = defineModel();
const changeName = () => {
// 直接修改userInfo.value的属性?不对,等下如果是浅拷贝的话
// 或者直接修改顶层引用
userInfo.value = {
...userInfo.value,
name: '郑十'
};
};
</script>
<template>
<div>姓名:{{ userInfo.name }}</div>
<button @click="changeName">修改姓名(defineModel版)</button>
</template>
多个defineModel的写法(对应多个v-model)
<script setup>
// 多个defineModel,给每个起个名字,对应v-model:命名="xxx"
const userName = defineModel('userName');
const userHobby = defineModel('userHobby');
const changeName = () => {
// 直接修改基本类型的ref
userName.value = '钱十一';
};
const addHobby = () => {
// 直接修改数组的ref
userHobby.value = [...userHobby.value, '潜水'];
};
</script>
<template>
<div>姓名:{{ userName }}</div>
<div>爱好:{{ userHobby.join(', ') }}</div>
<button @click="changeName">修改姓名(多个defineModel版)</button>
<button @click="addHobby">添加爱好(多个defineModel版)</button>
</template>
是不是简化到极致了?但同样要注意:如果defineModel返回的ref是对象/数组类型,直接修改内部属性的话,虽然不会报错,但还是会修改父组件的源数据引用,违反单向数据流原则,所以最好还是用展开运算符创建新的对象/数组,然后赋值给ref.value。
defineModel还支持第二个参数,用来设置prop的验证规则和默认值,
const userInfo = defineModel({
type: Object,
required: true,
default: () => ({ name: '匿名', age: 0, hobby: [] })
});
总结一下Vue3 watch props object的所有要点
- 确保父组件的源数据是响应式的:必须用ref或reactive包裹,不能直接传递普通对象。
- 监听props里的object/array永远用getter函数:作为通用安全写法,避免解构带来的响应式丢失。
- 不要滥用deep: true:根据场景选择合适的监听方式,比如只监听具体属性用getter返回该属性,只监听顶层属性增删改用watchEffect+Object.keys+顶层属性访问,非常大的嵌套对象用版本号/时间戳。
- 严格遵循单向数据流原则:不要直接修改props的顶层引用,也不要直接修改内部属性(虽然不报错),要用事件或v-model/defineModel让父组件修改源数据。
- 能⽤computed的地方绝对不用watch:computed有自动缓存,性能更好,代码更简洁。
- Vue3.4+推荐用defineModel:简化双向绑定的写法,但要注意避免直接修改内部属性。
再强调一遍:没有最好的写法,只有最适合当前场景的写法,在写代码之前,先想清楚你要监听什么,需要什么样的性能,然后再选择对应的方案,如果还有什么疑问,可以在评论区留言交流。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



