Vue3 watch怎么监听嵌套对象?深层监听避坑指南来了
日常开发Vue3项目,嵌套对象和数组是绕不开的数据结构——比如用户表单里的地址模块,或者从后端接口拿到的多层级列表数据,这些时候用watch监听,经常会遇到“明明改了子属性,watch却没反应”的问题,或者开了deep:true但触发太频繁的性能问题,今天咱们就把这些坑点都捋顺,从基础用法到进阶优化,一个个讲明白。
什么是Vue3的watch监听机制?
先不说嵌套对象,先搞懂watch的底层逻辑,后面的问题才好理解。
Vue3是基于Proxy实现响应式的,这和Vue2的Object.defineProperty完全不一样——Vue2会递归遍历整个对象,给每个属性都加上getter/setter,但Vue3只在访问到某个属性的时候,才会把它变成响应式的(懒响应式),而且只代理第一层对象的属性,比如我们有个data里的user对象:
const user = reactive({
name: '张三',
address: {
city: '北京',
district: '朝阳区'
}
})
这里的user本身是一个Proxy代理,但address只是user里的一个普通对象属性,只有当我们访问user.address的时候,Vue3才会生成address的Proxy代理,而district、city这些属性,得访问到user.address.city才会被代理。
只修改嵌套对象的子属性,watch为什么不触发?
这是很多新手刚转Vue3会遇到的第一个坑,先看个错误例子:
// ❌ 错误写法
watch(user.address, (newVal, oldVal) => {
console.log('地址变了:', newVal, oldVal)
})
// 点击修改按钮,只改city
const changeCity = () => {
user.address.city = '上海'
}
这段代码运行后,点击按钮控制台根本不会打印东西,为什么?
刚才说了,Vue3的代理是懒加载+层叠代理,但更关键的是,watch默认只监听对象本身的引用变化,或者基础类型的值变化,上面的watch的第一个参数是user.address,这时候它只是在监听这个address对象的内存地址——如果我们把整个address对象替换掉(比如user.address = { city: '上海', district: '静安区' }),那引用变了,watch会触发,但如果只是修改address里的city,引用没变,当然就没反应。
同样的,如果直接用reactive的顶层对象当第一个参数,比如watch(user, ...),这时候默认监听的也是user本身的引用,只改子属性也不会触发。
怎样让watch正确监听嵌套对象的子属性?
解决这个问题有三种主流方法,咱们逐个看适用场景:
方法1:开启deep:true开启深层监听
这是最简单、最常用的方法,不管嵌套多少层,只要任意子属性的值变了,watch都会触发,修改刚才的错误例子:
// ✅ 写法1:直接监听顶层/子对象,加deep:true
watch(user.address, (newVal, oldVal) => {
console.log('地址变了:', newVal, oldVal)
}, {
deep: true
})
或者监听顶层user对象:
// ✅ 写法1扩展:监听顶层,加deep:true
watch(user, (newVal, oldVal) => {
console.log('用户信息变了:', newVal, oldVal)
}, {
deep: true
})
不过这里有个小细节,开启deep:true后,newVal和oldVal是同一个对象——因为Vue3没有对嵌套对象做快照,新旧值指向的是同一个内存地址,只是内部属性变了,如果需要对比旧值,只能自己在监听回调外存一份。
方法2:用getter函数指定要监听的具体子属性
如果我们只关心嵌套对象里的某一个或某几个子属性,没必要开deep:true,用getter函数直接返回那个子属性就行,这样性能会更好,而且不会出现新旧值相同的问题。
// ✅ 写法2:只监听city
watch(
() => user.address.city,
(newVal, oldVal) => {
console.log('城市变了:', newVal, oldVal) // 这里newVal和oldVal是不同的基础类型
}
)
// ✅ 写法2扩展:监听多个具体子属性,用数组包裹getter
watch(
[() => user.name, () => user.address.city],
([newName, newCity], [oldName, oldCity]) => {
console.log('用户名或城市变了:', newName, oldName, newCity, oldCity)
}
)
这个方法的优点是精准监听、性能好、新旧值可直接对比;缺点是如果要监听的子属性太多,代码会有点冗长。
方法3:用watchEffect自动追踪依赖
watchEffect和watch不一样,它不需要指定监听的源,只要在回调函数里用到的响应式数据变了,它就会自动触发,对于嵌套对象的多个子属性同时需要监听的情况,watchEffect有时候比watch+数组更简洁。
// ✅ 写法3:自动追踪所有用到的user.address里的属性
watchEffect(() => {
console.log('当前城市和区域:', user.address.city, user.address.district)
})
不过watchEffect有几个注意点:第一,它会立即执行一次(默认immediate是true);第二,没有新旧值的对比;第三,回调里要注意不要写死引用,不然追踪不到。
深层监听deep:true性能差怎么办?
虽然deep:true简单,但如果嵌套对象非常大(比如有几十上百个子属性,甚至包含数组对象嵌套数组),每次修改任何一个子属性,Vue3都要递归遍历整个嵌套结构检查变化,这会带来不小的性能开销,这时候就需要做优化了,常用的优化方法有三种:
优化1:尽量用方法2(getter函数精准监听)
这个刚才说过了,是最有效的优化——只监听你真正关心的子属性,不需要递归遍历整个对象。
优化2:拆分嵌套对象,用ref包裹独立部分
如果嵌套对象里的某个子模块是独立的、经常会被修改的,我们可以把它单独拿出来用ref包裹,这样监听这个ref的时候,不需要开deep:true,只要子属性变了(如果子模块也是响应式的),就可以通过访问子模块的方式触发,或者替换整个子模块触发,性能比deep:true好很多。
// ✅ 拆分独立模块
const baseInfo = reactive({ name: '张三', age: 25 })
const address = reactive({ city: '北京', district: '朝阳区' })
const user = reactive({ baseInfo, address })
// 只监听address的变化,不需要deep(因为address本身是reactive,修改内部属性如果触发watch?哦不对,这里如果直接watch address还是要deep,除非拆成ref+reactive的组合,或者用ref包裹reactive对象)
const addressRef = ref(reactive({ city: '北京', district: '朝阳区' }))
// 现在watch addressRef,默认监听的是ref的value引用,但如果想监听内部属性,还是要开deep?不对,等下,我们可以用watchEffect或者getter
// 或者更彻底的,把baseInfo和address都改成ref包裹的对象,然后用computed组合成user
const userComputed = computed(() => ({
baseInfo: baseInfo.value,
address: addressRef.value
}))
// 这时候单独监听addressRef.value.city,用getter就行
拆分对象的核心思路是减少单个响应式对象的嵌套层级和复杂度,让Vue3的Proxy代理更轻量。
优化3:用shallowReactive/shallowRef配合手动监听
如果嵌套对象只有第一层需要响应式,或者只有特定的深层子属性需要监听,我们可以用shallowReactive(浅响应式,只代理第一层属性)或者shallowRef(浅响应式,只监听value的引用变化),然后对需要监听的深层子属性手动用reactive/ref包裹,或者在修改的时候手动替换整个shallowRef的value,这样能完全避免递归遍历的性能问题。
// ✅ shallowReactive示例
const shallowUser = shallowReactive({
name: '张三',
address: { // 这个address是普通对象,只有修改shallowUser.name或者替换整个shallowUser.address才会触发UI更新和浅层监听
city: '北京',
district: '朝阳区'
}
})
// 只监听shallowUser.name,不需要deep
watch(() => shallowUser.name, (newVal) => console.log(newVal))
// 监听address的变化?要么替换整个address:
const changeAddressWhole = () => {
shallowUser.address = { city: '上海', district: '静安区' }
}
// 要么把address单独拿出来做成reactive,然后在shallowUser里存它的引用,同时监听这个reactive的address
const reactiveAddress = reactive({ city: '北京', district: '朝阳区' })
const shallowUser2 = shallowReactive({
name: '张三',
address: reactiveAddress
})
watch(() => reactiveAddress.city, (newVal) => console.log(newVal))
shallowReactive/shallowRef适合处理只读的深层数据或者只有第一层频繁修改的数据,比如从后端拿到的大列表,只有列表的排序、筛选状态(第一层)需要响应式,列表项里的具体内容如果不修改的话,用浅响应式完全没问题。
监听嵌套数组和嵌套对象有什么区别?
其实在Vue3里,嵌套数组和嵌套对象的监听逻辑几乎是一样的,因为Vue3用Proxy代理数组,不管是修改数组的索引、长度,还是修改数组里的对象的子属性,都是类似的处理方式,举个监听嵌套数组的例子:
const todoList = reactive([
{ id: 1, content: '写文章', done: false },
{ id: 2, content: '改代码', done: true }
])
// ❌ 只监听todoList[0],默认只监听引用
watch(todoList[0], (newVal) => console.log(newVal))
// 修改todoList[0].done,不会触发
const changeDone = () => {
todoList[0].done = true
}
// ✅ 加deep:true
watch(todoList[0], (newVal) => console.log(newVal), { deep: true })
// ✅ 用getter精准监听done
watch(() => todoList[0].done, (newVal) => console.log(newVal))
// ✅ 监听整个todoList的变化(包括添加/删除数组项、修改数组项的子属性)
watch(todoList, (newVal) => console.log(newVal), { deep: true })
不过有个小细节:如果用ref包裹数组,比如const todoListRef = ref([{...}, {...}]),修改数组的索引或者用push/splice等方法,会自动触发UI更新,但如果直接watch todoListRef,默认只监听value的引用变化,要是想监听数组内部的变化,还是要加deep:true,或者用getter函数返回整个数组的value(不过返回value的话默认还是监听引用,和直接watch todoListRef一样,所以必须加deep)。
Vue3 watch嵌套对象的最佳实践
讲了这么多方法和坑点,最后给大家整理一下日常开发的最佳实践:
- 优先用getter函数精准监听具体子属性:不管是性能还是新旧值对比,都是最好的选择,适合只关心少数子属性的场景。
- 需要监听嵌套对象的所有子属性时,再开deep:true:但要注意新旧值相同的问题,如果需要对比旧值,要自己在外面存一份快照。
- 嵌套对象复杂且有独立模块时,拆分响应式对象:减少单个对象的复杂度,提升性能。
- 处理大的只读深层数据时,用shallowReactive/shallowRef:完全避免递归遍历的开销。
- 需要自动追踪多个依赖时,用watchEffect:但要注意立即执行和无新旧值的问题。
最后再提醒大家一句:不管用哪种方法,都要根据具体的业务场景选择,不要为了“方便”就随便开deep:true,不然项目大了之后,性能问题会很头疼。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网

