Vue3中watch监听props时,deep:true到底该不该随便开?
聊起Vue3的watch,估计很多刚从Vue2转过来或者初学Vue的朋友,第一反应都是“监听变了就开deep”——毕竟嵌套深的数据,好像不加deep根本监听不到更新,但最近后台频繁收到类似的提问:“为什么我开了deep后,组件渲染卡得离谱?”“监听props的对象时,开deep会不会有性能坑?”“什么时候必须开deep,什么时候可以用更聪明的替代方案?”
今天咱们就把这些问题串起来,掰开揉碎讲清楚,帮你既解决监听嵌套props的需求,又不会踩性能或者逻辑的坑。
先搞清楚Vue3 watch的几个核心基础点
在讨论props的deep监听前,得先把watch本身的机制理明白,不然光说结论没用,下次遇到新场景还是抓瞎。
watch监听的到底是“值”还是“引用”?
Vue3的响应式系统基于Proxy,这和Vue2的Object.defineProperty有本质区别,但在watch的浅层/深层监听逻辑上,其实核心思路没变:
- 浅层监听(默认immediate:false,deep:false):只监听目标的“根引用”或者“根属性值”变化,举个例子,如果监听的是一个普通字符串props title,那title从“首页”改成“详情页”肯定能触发;但如果监听的是一个对象props userInfo,只有把整个userInfo替换成新对象(比如从{name:'张三'}改成{name:'李四'})才会触发,要是只改userInfo.name,浅层监听根本看不到。
- 深层监听(deep:true):不管目标嵌套多少层,只要里面任何一个可枚举的属性值变化,或者数组的长度、元素变化,都会触发watch,这是因为Proxy虽然能拦截深层属性的访问,但watch的回调默认是不会主动追踪深层依赖的,deep:true的作用,就是强制Vue递归遍历整个目标对象/数组,把所有深层属性都加入到响应式依赖收集里,一旦有变动就通知回调。
props的响应式机制和普通响应式数据有区别吗?
这点是新手最容易忽略的——props是“只读的单向响应式数据流”,普通响应式数据(比如setup里用ref/reactive声明的)是“可写的双向响应式数据”。 单向响应式的意思是:父组件更新props的值,子组件会自动同步;但子组件绝对不能直接修改props,不管改的是根值还是深层属性,都会触发Vue的警告(生产环境下警告可能被关,但逻辑错误还是会留)。
那为什么子组件不能直接改props呢?因为如果允许子组件随便改,数据流就乱了——父组件不知道什么时候子组件动了数据,调试和维护都会成灾难,这也是Vue官方强调“props down, events up”通信模式的原因。
监听props的嵌套数据时,deep:true的正确打开场景和错误打开场景
很多朋友开deep:true都是凭直觉,觉得“嵌套了就开”,其实很多时候完全没必要,甚至会带来严重的问题。
什么时候必须用deep:true?
目前来看,只有一种绝对合理的场景:父组件传递的是一个动态生成的、嵌套结构不确定的复杂对象/数组,子组件需要在整个嵌套结构的任何部分变动时都执行相同的逻辑。 举个真实的例子:电商后台的“商品编辑预览组件”,父组件传递过来的productInfo可能包含几十甚至上百个属性,比如基础信息(标题、价格)、sku数组(每个sku又有id、颜色、尺码、库存)、详情图数组、标签数组……而且不同类型的商品(比如服装和电子产品),嵌套结构可能完全不一样——服装有sku的颜色尺码,电子产品可能有参数列表,这时候,子组件需要做的是“productInfo任何地方变了,就重新计算预览的总价、渲染新的详情页结构”,这种情况下,不用deep:true根本没办法覆盖所有可能的变动,而且替代方案反而会更复杂、性能更差。
即使是这种场景,也得注意两点:
- 尽量缩小监听范围:如果productInfo里有几个大的、不会一起变动的块,比如基础信息、sku、详情图,可以把它们拆成几个单独的props,分别做浅层或者小范围的深层监听,而不是监听整个productInfo,比如只监听sku数组的变动,就可以写
watch(() => props.productInfo?.sku, (newSku) => {}, { deep: true }),这样递归遍历的范围就小多了。 - 不要在deep回调里做太复杂的操作:比如大量的DOM操作、复杂的数学计算、发起多个网络请求——因为deep监听触发的频率可能非常高(比如父组件循环修改sku数组的库存),每次触发都做重操作,肯定会卡顿,如果必须做重操作,建议搭配
debounce(防抖)或者throttle(节流)来优化。
什么时候绝对不要用deep:true?
比“必须用”的场景多得多的是“绝对不能用”的场景,这些场景里开deep,要么会触发不必要的回调,要么会带来逻辑隐患,要么纯粹是浪费性能。
只需要监听props对象的某个固定深层属性
比如父组件传递userInfo,子组件只需要在userInfo.address.city变化时,更新本地的配送地址列表,这时候完全可以直接监听这个深层路径,不用开deep:
// 错误写法:开了deep,整个userInfo任何变动都会触发回调
watch(() => props.userInfo, (newInfo) => {
updateDeliveryList(newInfo.address?.city);
}, { deep: true });
// 正确写法:直接监听深层路径,只有city变了才触发
watch(() => props.userInfo?.address?.city, (newCity) => {
updateDeliveryList(newCity);
});
这里要注意,直接监听深层路径时,必须用函数式写法(也就是第一个参数是返回目标值的箭头函数),不能直接传props.userInfo.address.city——因为如果userInfo或者address一开始是undefined,直接访问会报错,而且Vue的依赖收集可能会有问题,函数式写法里加可选链,还能避免undefined的报错,一举两得。
父组件传递的是“静态不变的”或者“只会整体替换的”嵌套数据
如果父组件的嵌套props要么是初始化后就不动了,要么是每次变动都会用Object.assign({}, oldObj, newProps)或者展开运算符{ ...oldObj, ...newProps }(对象)、[ ...oldArr, ...newArr ]或者filter/map/reduce(数组)生成新的引用,那完全不用开deep,直接做浅层监听就行。
比如父组件有个购物车列表cartList,每次添加商品、修改数量、删除商品时,都是用cartList.value = [...cartList.value, newItem]这种方式更新的,那子组件只需要:
watch(() => props.cartList, (newCart) => {
calculateTotalPrice(newCart);
});
这种写法不仅性能好(不用递归遍历),而且逻辑清晰——只有购物车的整体引用变了(也就是真的有商品变动),才会触发总价计算。
这里要提一句,很多朋友以为用push/pop/splice这些数组的变异方法时,Vue3不会触发响应式更新,其实不是的——Vue3的Proxy会拦截这些变异方法,数组本身的引用不会变,但如果是普通响应式数组(不是只读的props),子组件直接改了的话,父组件也会同步?不对不对,回到props的单向数据流!不管数组是不是变异方法,子组件都不能直接改props里的数组或对象!刚才说的是父组件用变异方法更新自己的响应式数据,再传递给子组件的props——这时候子组件的props数组引用不会变,所以必须开deep才能监听到数组内部的变动,但这种父组件的写法本身就不太推荐,因为会导致子组件不得不开deep,性能差,更好的做法是父组件也用生成新引用的方式更新数据,这样子组件浅层监听就行。
需要监听的是props对象的“某个属性是否存在”或者“长度是否变化”
比如父组件传递userInfo,子组件需要在userInfo有了phone属性时显示绑定手机号的弹窗;或者传递comments数组,子组件需要在comments长度超过10时显示“加载更多”按钮,这种情况下,直接监听对应的计算属性就行,连函数式watch都可以省,直接用watchEffect?不对,还是用计算属性+watch或者直接watch计算属性更清晰:
// 监听phone是否存在
const hasPhone = computed(() => !!props.userInfo?.phone);
watch(hasPhone, (newHas) => {
if (newHas) showBindModal.value = false;
});
// 监听comments长度
const showLoadMore = computed(() => props.comments?.length > 10);
// 这里甚至可以不用watch,直接在模板里用v-if="showLoadMore"就行
连watch都省了,更不用说开deep了。
deep:true的性能坑到底有多大?
刚才一直在说deep:true会影响性能,但具体有多大呢?咱们可以做个简单的对比: 假设父组件传递给子组件的是一个有1000个元素的数组,每个元素又是一个有10个属性的对象,总共就是10000个可枚举属性。
- 浅层监听:Vue只需要追踪这个数组的根引用,一旦引用变了就触发回调,依赖收集的成本几乎为0,触发频率也可控。
- 深层监听:Vue需要递归遍历这10000个属性,把每个属性都加入到依赖收集里,这个过程在组件初始化时就会发生一次;之后每次父组件修改数组里的任何一个属性,或者数组的长度,都会触发回调,而且每次触发回调前,Vue还会对比新旧值的差异(虽然Vue3做了优化,不是完全深拷贝对比,但还是有成本)。
如果这个数组有10000个元素,每个元素有100个属性,那递归遍历的成本就会呈指数级增长,组件初始化可能会慢几百毫秒,每次数据变动也会有明显的延迟——这在移动端或者低配置设备上,体验会非常差。
更可怕的是,如果父组件传递的是一个循环引用的对象(虽然这种情况很少见,但万一遇到了),deep:true会导致Vue的递归遍历陷入死循环,直接让浏览器崩溃。
除了deep:true,还有哪些更优雅的监听嵌套props的方案?
刚才说的直接监听深层路径、父组件生成新引用、用计算属性,都是比开deep更优雅的方案,不过还有几个方案可以补充:
父组件传递computed属性的getter
如果子组件只需要用到嵌套props的某个深层属性,或者几个深层属性的组合,那父组件可以直接把这些计算好的属性单独传递给子组件,子组件连嵌套都不用碰,直接做浅层监听就行。 比如刚才的商品编辑预览组件,如果子组件只需要用到总价和当前选中的sku图片,那父组件可以:
// 父组件
const productInfo = reactive({ /* 复杂结构 */ });
const totalPrice = computed(() => {
// 计算总价的逻辑
});
const selectedSkuImg = computed(() => {
// 计算当前选中sku图片的逻辑
});
// 传递给子组件
<ProductPreview
:total-price="totalPrice"
:selected-sku-img="selectedSkuImg"
/>
// 子组件
watch(() => props.totalPrice, (newPrice) => {
// 更新显示
});
这种方案不仅性能最好,而且父子组件的耦合度更低——子组件根本不需要知道productInfo的嵌套结构,只需要关心自己需要的两个属性,以后父组件修改productInfo的结构,只要totalPrice和selectedSkuImg的计算逻辑不变,子组件就不用改。
用provide/inject + 事件总线?不对,provide/inject本身默认不是响应式的
哦对了,Vue3的provide/inject默认是浅层非响应式的,如果要传递响应式数据,需要传递ref/reactive对象,或者用computed包装,但provide/inject更适合跨多层组件的通信,比如祖孙组件,如果只是父子组件,还是用props/events更规范。 不过如果是跨多层的嵌套数据监听,用provide/inject传递ref/reactive对象,然后子组件直接监听对应的深层路径或者计算属性,也是一个不错的选择,至少不用层层传递props。
用watchEffect
watchEffect和watch的区别是:watchEffect不需要明确指定监听的目标,它会自动追踪回调函数里用到的所有响应式数据,一旦有任何一个数据变化,就会触发回调;而且watchEffect默认是immediate:true的,也就是组件初始化时就会执行一次。 比如刚才的只监听userInfo.address.city的场景,用watchEffect可以写成:
watchEffect(() => {
const newCity = props.userInfo?.address?.city;
updateDeliveryList(newCity);
});
这种写法更简洁,但缺点是不够精确——如果回调函数里不小心用到了其他响应式数据(比如setup里的localLoading),那localLoading变化时也会触发updateDeliveryList,这可能不是我们想要的,所以如果能明确指定监听目标,还是用watch更稳妥;如果是需要自动追踪多个响应式数据的场景,根据多个props和本地数据实时更新某个状态”,那watchEffect更合适。
Vue3监听props的“最佳实践清单”
咱们把今天讲的内容整理成一个简单的清单,方便大家以后参考:
- 永远不要直接修改props,不管是根值还是深层属性,哪怕Vue的警告被关了也不行,一定要用“props down, events up”或者其他规范的通信方式。
- 优先缩小监听范围:如果只需要监听某个固定深层属性,直接用函数式写法监听路径;如果只需要监听某个属性的存在、长度或者组合,用计算属性。
- 优先让父组件生成新引用:父组件更新嵌套数据时,尽量用展开运算符、filter/map/reduce等生成新引用的方式,这样子组件可以用浅层监听,性能最好。
- deep:true是最后的选择:只有当嵌套结构不确定、需要监听整个结构的任何变动时,才用deep:true;用的时候尽量缩小监听范围,不要在回调里做太复杂的操作,必要时搭配防抖/节流。
- 跨多层组件时,优先考虑provide/inject:但要记得传递ref/reactive对象或者computed包装的数据,保证响应式。
好啦,今天关于Vue3 watch props deep的内容就讲到这里,如果你还有其他Vue3的问题,欢迎在评论区留言,咱们一起讨论。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


