Vue3 watch监听props数组时为什么有时没反应?怎么解决深层变化监听问题?
很多刚从Vue2转过来Vue3的朋友,或者刚上手Vue3的新手,都会遇到一个棘手的问题:明明给子组件传了props数组,也写了watch监听,但就是有时候页面没更新,或者控制台打印的watch回调里数据没变化——特别是当数组只改了某个对象的属性、或者只push/pop了元素但没完全替换整个数组的时候,这到底是Vue3的bug吗?肯定不是,而是我们对Vue3的响应式机制和watch的用法细节没摸透,今天就把这个问题拆碎了讲,从原理到解决方案,再到实际项目中的避坑技巧,全说清楚。
为什么Vue3 watch监听props数组会没反应?
要解决问题,得先搞懂背后的原因,不然换个写法可能还是踩坑,这里涉及到Vue3两个核心知识点:props的响应式特性和watch的默认监听机制。
props本身不是完全“可写可监听深层结构”的?不对,先澄清个误区
很多人说“Vue3 props不能深层监听”,这其实是错的一半——props如果是父组件传下来的响应式对象/数组(不管是ref/reactive转的,还是setup里直接return的响应式数据),它本身在子组件里是浅响应式转深吗?不,子组件直接能拿到父组件的响应式引用结构,举个例子,父组件setup里写const list = reactive([{id:1,name:'苹果'},{id:2,name:'香蕉'}]),然后传给子组件<Child :my-list="list" />,子组件props里声明myList:Array,这时候子组件里直接用myList[0].name是能触发页面更新的——因为父组件的reactive对象属性变化是透传的。
那为什么有时候watch没反应呢?
根本原因是watch的默认监听是“浅层值引用变化”
Vue3的watch默认有个重要的机制:监听ref的value引用变化,监听reactive对象/数组的属性/元素的浅层引用变化(如果是直接传reactive的话),或者更准确说——如果直接传普通的props引用给watch的第一个参数(除了reactive本身),默认是只监听这个引用地址有没有变,或者引用指向的第一层值有没有变(像基本类型数组的第一层变会触发,但引用类型数组的第一层元素本身引用不变的话,即使内部属性变了也不会)。
举两个最常见的没反应的场景:
- 修改引用类型数组的内部属性
父组件:
list.value.push({id:3,name:'橙子'})?不,这个其实如果watch写对了会触发?等下别急,先看父组件传的是ref还是reactive,哦对,还有个容易混淆的点:如果父组件用ref包裹数组(const list = ref([{id:1},{id:2}])),传给子组件时,子组件通过props拿到的是解包后的普通引用数组,但这个数组本身是父组件ref.value的响应式代理延伸吗?不,解包后是直接的响应式Proxy吗? 等下查下权威资料的准确说法——Vue3官方文档明确写了:当父组件给子组件传递一个ref时,子组件的props会自动解包为ref的value;当传递一个reactive对象/数组时,子组件直接拿到该对象/数组的响应式Proxy;但不管是哪种,如果直接用watch监听props.arr这种写法(除了直接监听整个reactive对象的情况),默认都是只比较浅层的变化。 那具体场景一的细节:父组件const list = ref([{id:1,name:'苹果'},{id:2,name:'香蕉'}]),子组件const props = defineProps(['list']),然后watch(props.list, (newVal, oldVal) => {console.log('变了', newVal, oldVal)}),这时候父组件做list.value[0].name = '红富士'——子组件页面会更新,但watch回调不会打印!为什么?因为props.list解包后是父组件ref.value的响应式Proxy数组,数组的第一个元素(引用类型对象)的地址没变,只是对象内部的属性变了,watch默认只看第一层引用有没有变,所以不会触发。 - 父组件替换了ref数组的整个引用,但没注意
哦这个场景有时候会反过来——有人写了watch监听,但觉得奇怪怎么每次初始化都触发?其实这个是另一个小坑,但也和引用有关,比如父组件里写
let list = [{id:1}]; list = [{id:1,id:2}]然后赋给ref?不,应该是const list = ref([{id:1}]); list.value = [...list.value, {id:2}]——哦这时候数组的引用变了,不管watch有没有开deep都会触发,对吧?但如果是场景一那种引用不变的内部修改,就需要加deep了?
补充一个容易忽略的细节:父组件传的是“静态转换的非响应式数组”?
对,还有一种极端情况,比如父组件直接在模板里写<Child :my-list="[{id:1},{id:2}]" />——这个数组每次父组件重新渲染都会生成一个新的引用!那这时候子组件的watch(props.myList)会每次都触发,但如果是父组件自己没有重新渲染,只是想在子组件里监听这个静态数组的内部变化,那是不可能的——因为它根本不是响应式的,子组件拿到的只是一个普通的JS数组,Vue3不会追踪它的变化。
Vue3 watch监听props数组的正确打开方式有哪些?
好了,原理讲清楚了,现在给大家列几种经过验证的、不同场景下的解决方案,总有一种适合你。
开启watch的deep选项——最通用但要注意性能
不管你父组件传的是ref还是reactive包裹的数组,不管你要监听的是元素的增删改还是内部属性的变化,开启deep: true都能解决——但这是有代价的,deep监听会递归遍历整个数组的所有属性和嵌套对象,数组越大、嵌套越深,性能损耗就越大,所以如果数组很小(比如只有几个简单对象),可以放心用,否则要考虑其他方案。
怎么写? 如果父组件传的是ref数组,子组件的写法:
<script setup>
import { watch, defineProps } from 'vue'
const props = defineProps(['userList'])
// 开启deep监听
watch(
() => props.userList, // 注意这里要写成函数返回props.userList!如果直接传props.userList,当父组件传的是reactive数组的话没问题,但如果是ref数组?哦等下再查权威资料——Vue3的watch如果第一个参数是ref的话,可以直接传ref变量,但如果是props里的解包后的ref.value?哦不对,defineProps返回的props对象里,如果父组件传的是ref,子组件的props.xxx会是一个响应式的ref吗?哦之前的澄清可能有点模糊,再明确一遍:Vue3 3.2+ 版本里,defineProps返回的props对象本身是一个响应式对象,但props里的属性如果是父组件传的ref,会自动解包为非ref的响应式值(如果是基本类型,就是响应式的getter/setter;如果是引用类型,就是响应式的Proxy),所以不管是哪种情况,监听props数组的时候,**最好都写成函数返回式的写法**——即`() => props.userList`,这样不管后续父组件传的是什么形式,都能正常工作。
(newVal, oldVal) => {
console.log('userList变了', newVal, oldVal)
// 这里可以写你的业务逻辑,比如过滤数组、请求接口等
},
{
deep: true, // 开启深层监听
immediate: true // 可选,组件初始化时立即执行一次回调
}
)
</script>
如果父组件传的是reactive数组,直接传props.userList作为第一个参数也能工作,但函数返回式的写法更通用,建议统一用。
监听数组的某个具体索引或属性——精准高效不浪费性能
如果你的业务场景不需要监听整个数组的所有变化,只需要监听某个具体索引的元素、或者某个元素的具体属性、或者数组的长度变化,那用精准监听的方式性能会好很多。
比如只监听数组的长度:
<script setup>
import { watch, defineProps } from 'vue'
const props = defineProps(['todoList'])
// 只监听长度
watch(
() => props.todoList.length,
(newLen, oldLen) => {
console.log('todoList长度变了', newLen, oldLen)
if (newLen > oldLen) {
// 新增了待办事项,比如滚动到底部
}
}
)
</script>
再比如只监听第一个待办事项的完成状态:
<script setup>
import { watch, defineProps } from 'vue'
const props = defineProps(['todoList'])
// 确保数组有第一个元素再监听?或者加个immediate但判断一下?
watch(
() => props.todoList[0]?.done, // 可选链,防止数组为空时报错
(newDone, oldDone) => {
if (newDone !== undefined) {
console.log('第一个待办完成状态变了', newDone)
}
}
)
</script>
父组件直接传递ref的原始引用——绕过自动解包?3.2+版本可以用defineExpose或者toRefs/toRef?
哦对,Vue3 3.2+ 版本里,defineProps返回的props对象是响应式的,但解包后的属性不是ref(除了用defineProps<{xxx: Ref<xxx>}>()这种TS泛型写法的情况?或者用toRefs把props转成ref集合),比如用toRefs:
<script setup>
import { watch, defineProps, toRefs } from 'vue'
const props = defineProps(['userList'])
const { userList } = toRefs(props) // 这里的userList会变成一个Ref<Array>,不管父组件传的是ref还是reactive
// 直接监听这个ref,和普通的ref监听一样,开启deep就行
watch(
userList,
(newVal, oldVal) => {
console.log('变了', newVal, oldVal)
// 注意这里的oldVal是ref.value的旧值,但如果是deep监听引用类型的变化,oldVal和newVal可能指向同一个对象!
},
{ deep: true }
)
</script>
那这个方案和方案一有什么区别?其实没太大本质区别,只是写法不同,toRefs的好处是如果你需要同时监听props里的多个属性,可以一次性转成ref集合,然后分别监听,或者用watchEffect直接用这些ref。
watchEffect替代watch——自动追踪依赖但不够灵活
watchEffect是Vue3里另一个监听数据变化的API,它不需要指定具体的监听源,而是自动收集回调函数里用到的所有响应式数据的依赖,只要依赖变化就会执行,那用watchEffect怎么监听props数组呢?
<script setup>
import { watchEffect, defineProps } from 'vue'
const props = defineProps(['userList'])
watchEffect(() => {
// 这里要主动遍历props.userList的某个属性或者整个数组?
// 比如只用到长度
console.log('userList长度', props.userList.length)
// 或者用到第一个元素的name
console.log('第一个用户', props.userList[0]?.name)
// 或者遍历整个数组?但这样数组的任何变化都会触发
props.userList.forEach(user => {
console.log('用户', user.id, user.name)
})
})
</script>
watchEffect的好处是代码更简洁,不用写deep也不用写具体的监听源,自动追踪;但坏处是不够灵活——比如你只想在数组长度变化时执行,不想在某个元素的name变化时执行,那watchEffect做不到,因为它只要用到了name就会追踪;watchEffect默认immediate为true,组件初始化时一定会执行一次,不能像watch那样设置immediate: false。
用computed衍生数据替代直接监听props数组——更符合Vue的响应式理念
如果你的业务逻辑不是“数据变化后执行某个副作用(比如请求接口、修改本地存储、滚动页面)”,而是“根据props数组的变化生成一个新的衍生数据(比如过滤后的数组、排序后的数组、计算总数)”,那最好的方式不是用watch,而是用computed——因为computed是“纯函数”,没有副作用,性能更好,而且会自动缓存结果,只有依赖变化时才会重新计算。
<script setup>
import { computed, defineProps } from 'vue'
const props = defineProps(['productList'])
// 过滤出价格大于100的商品
const expensiveProducts = computed(() => {
return props.productList.filter(product => product.price > 100)
})
// 计算商品的总价
const totalPrice = computed(() => {
return props.productList.reduce((sum, product) => sum + product.price * product.count, 0)
})
</script>
<template>
<div>
<h3>高价商品(>100元)</h3>
<ul>
<li v-for="product in expensiveProducts" :key="product.id">
{{ product.name }} - {{ product.price }}元
</li>
</ul>
<p>总价:{{ totalPrice }}元</p>
</div>
</template>
这个方案完全不需要用到watch,也不用担心深层监听的问题,因为computed会自动追踪回调函数里用到的所有响应式数据的依赖,包括数组的增删改和内部属性的变化——只要回调里用到了price或者count,不管是哪个商品的,只要变化了,expensiveProducts或者totalPrice就会重新计算,页面也会自动更新,这才是Vue官方推荐的处理衍生数据的方式!
Vue3 watch监听props数组的避坑技巧
讲完了原理和解决方案,再给大家提几个实际项目中容易踩的坑,提前避开能省很多时间。
避坑技巧一:注意watch回调里的oldVal和newVal的指向问题
当你开启deep监听引用类型数组(不管是对象数组还是嵌套数组)时,watch回调里的oldVal和newVal会指向同一个数组对象/嵌套对象!因为Vue3的响应式机制是修改原对象的属性,而不是创建一个新的对象,所以oldVal其实是“旧的状态下的同一个对象的引用”,但对象的属性已经被修改了,所以你在回调里打印oldVal和newVal会发现它们完全一样。
那怎么拿到真正的旧值呢? 如果数组不大,可以在watch回调里用深拷贝把newVal存起来,下次再用这个存起来的值作为oldVal的参考;或者如果父组件用的是ref数组,每次修改时都用展开运算符或者slice、concat等方法生成一个新的数组引用,这样即使不开deep,watch也能触发,而且oldVal和newVal会指向不同的数组对象。
比如父组件这样写:
<script setup>
import { ref } from 'vue'
const list = ref([{id:1,name:'苹果'},{id:2,name:'香蕉'}])
// 修改第一个元素的name时,生成一个新的数组
const updateFirstFruit = () => {
list.value = list.value.map((item, index) => {
if (index === 0) {
return {...item, name: '红富士'} // 展开运算符生成新的对象
}
return item
})
}
// 新增元素时也可以用展开运算符
const addFruit = () => {
list.value = [...list.value, {id:3,name:'橙子'}]
}
</script>
这样写的话,子组件的watch即使不开deep,只要监听() => props.list,也能触发回调,而且oldVal和newVal是不同的数组对象,oldVal里的第一个元素也是旧的name值。
避坑技巧二:不要在子组件里直接修改props数组的元素或属性?哦这个是Vue2的“单向数据流”规则,Vue3里有没有变?
Vue3的官方文档还是明确强调了单向数据流的重要性:所有的props都遵循“父组件更新,子组件自动更新”的单向绑定,子组件不应该直接修改props里的数据——不管是基本类型还是引用类型,不管是修改整个引用还是修改内部属性。
那如果子组件需要修改props里的数据怎么办? Vue官方推荐的方式有两种:
-
子组件触发事件,父组件监听事件并修改自己的数据——这是最标准的做法,完全符合单向数据流。 比如子组件:
<script setup> import { defineProps, defineEmits } from 'vue' const props = defineProps(['todo']) const emits = defineEmits(['toggle-done']) const toggleTodo = () => { emits('toggle-done', props.todo.id) // 触发事件,把todo的id传过去 } </script> <template> <div :class="{ done: todo.done }" @click="toggleTodo"> {{ todo.name }} </div> </template>父组件:
<script setup> import { ref } from 'vue' import TodoItem from './TodoItem.vue' const todoList = ref([{id:1,name:'写代码',done:false},{id:2,name:'吃饭',done:false}]) const toggleTodoDone = (id) => { const todo = todoList.value.find(item => item.id === id) if (todo) { todo.done = !todo.done // 父组件自己修改数据 } } </script> <template> <div> <TodoItem v-for="todo in todoList" :key="todo.id" :todo="todo" @toggle-done="toggleTodoDone" /> </div> </template> -
子组件用props初始化一个本地的ref/reactive数据,然后监听props的变化同步更新本地数据——这种方式适合子组件需要对props数据进行临时修改,或者不需要立即同步给父组件的情况。 比如子组件:
<script setup> import { ref, watch, defineProps } from 'vue' const props = defineProps(['userInfo']) // 用props初始化本地数据 const localUserInfo = ref({...props.userInfo}) // 注意要用展开运算符,不然本地数据和props.userInfo指向同一个对象,子组件修改本地数据会影响父组件的数据! // 监听props.userInfo的变化,同步更新本地数据 watch( () => props.userInfo, (newVal) => { localUserInfo.value = {...newVal} }, { deep: true } ) // 子组件修改本地数据 const updateLocalName = () => { localUserInfo.value.name = '新名字' } </script>这种方式要注意:如果props.userInfo是一个嵌套很深的对象,要用深拷贝来初始化和同步本地数据,不然还是会有引用共享的问题。
避坑技巧三:父组件传的是静态数组时,不要期望watch能监听内部变化
之前提到过,如果父组件直接在模板里写<Child :my-list="[{id:1},{id:2}]" />,这个数组每次父组件重新渲染都会生成一个新的引用,所以子组件的watch会每次都触发,但如果父组件自己没有重新渲染,只是想在子组件里监听这个静态数组的内部变化,那是不可能的——因为它根本不是响应式的。
那如果父组件需要传一个静态的初始数组,然后子组件可以修改它(或者父组件可以修改它),那父组件应该用ref或者reactive把这个数组包裹起来,变成响应式的。
避坑技巧四:用watch监听数组时,不要用箭头函数作为回调函数里的this
虽然Vue3的setup里没有this,但如果你不小心在普通的watch回调里用了箭头函数(或者setup语法糖之外的选项式API里用了箭头函数),那this会指向undefined,而不是组件实例——不过setup语法糖里本来就不用this,所以这个坑在setup语法糖里不太常见,但选项式API里要注意。
Vue3 watch监听props数组没反应的根本原因是watch默认只监听浅层的引用变化,而我们通常修改的是数组的内部属性或者只增删元素不替换整个引用。
解决这个问题的方案有很多种:
- 如果需要监听整个数组的所有变化,且数组不大,可以用开启deep选项的watch;
- 如果只需要监听某个具体的变化,可以用精准监听;
- 如果需要生成衍生数据,用computed替代watch是最好的选择;
- 如果喜欢简洁的代码,可以用watchEffect,但要注意它的自动追踪依赖和immediate特性;
- 要注意单向数据流的规则,不要在子组件里直接修改props数据;要注意watch回调里的oldVal和newVal的指向问题;要注意父组件传的是不是响应式数组。
只要摸透了Vue3的响应式机制和watch的用法细节,监听props数组就不是什么难事了。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


