Vue3里怎么精准监听对象变化?避坑指南+全场景用法看这篇够不够?
写Vue3项目时,大家最常碰到的数据监听需求,大概率不是简单的字符串、数字或布尔值,而是数组、对象这种引用类型——特别是嵌套深、层级多的业务对象,比如用户信息、购物车列表项的商品规格、表单里的多层地址选择,很多人上手Vue3 watch时,要么直接复制Vue2的写法发现没反应,要么加上deep后页面疯狂重渲染性能崩了,要么只想监听对象里的某几个属性却不小心写了一大堆重复代码,今天咱们就把Vue3监听对象变化的所有坑、所有能用的场景、甚至连性能优化的小技巧都掰扯清楚,看完这篇,不管你是刚学Vue3的新手,还是想补全细节的老开发者,应该都能解决手头的问题。
先搞懂引用类型的“坑”,为什么直接watch对象没用?
很多新手第一次用Vue3 watch监听对象,可能会这么写:
import { reactive, watch } from 'vue'
const userInfo = reactive({
name: '张三',
age: 25,
address: {
province: '广东省',
city: '深圳市'
}
})
// 直接传userInfo对象进去
watch(userInfo, (newVal, oldVal) => {
console.log('用户信息变了!', newVal, oldVal)
})
// 试试改一个属性
userInfo.name = '李四'
结果打开控制台一看——啥也没有?这时候别慌,不是watch坏了,是你没搞懂引用类型在JavaScript和Vue3里的区别。
咱们得先回忆下JavaScript的基础:引用类型(对象、数组)的变量名存的不是值本身,而是一个指向堆内存里真实数据的地址,Vue3的响应式系统(不管是reactive还是ref包裹对象的情况),本质上也是通过Proxy劫持这个地址指向的对象的属性访问和修改,默认情况下,watch监听的是你传进去的第一个参数的“返回值的引用地址”或者“返回值本身的变化”。
那刚才的写法为什么没输出?因为直接传reactive返回的Proxy对象userInfo,它的引用地址从来没变过——你改name、age这些属性,堆内存里的地址没变,只是地址里存的某个属性值变了,所以watch的默认浅监听机制根本捕捉不到,这也是第一个大避坑点:别直接把整个reactive对象作为watch的第一个“原始参数”(除非你用的是下面要讲的特殊场景,但那个场景更少见)。
Vue3监听对象变化的四种主流方式,按需选别瞎用
既然直接传整个对象不行,那咱们就得换个思路,给watch喂能触发浅监听变化,或者能强制监听深变化的内容,目前Vue3官方给的、靠谱的监听对象方式有四种,每种都有自己的适用场景,咱们一个一个说。
用getter函数返回单个/多个对象属性,最常用的浅监听场景
如果只想监听对象里的某几个非嵌套属性(或者虽然嵌套但知道确切路径的属性),getter函数是最佳选择——性能好、写法灵活、还能避免没必要的deep开销。
写法1:监听单个对象属性
比如刚才的userInfo,只想监听name的变化,就可以这么写:
// 用箭头函数返回想要监听的属性
watch(
() => userInfo.name,
(newName, oldName) => {
console.log(`用户名从${oldName}改成了${newName}`)
}
)
// 测试下,现在改name就有输出了
userInfo.name = '王五'
这个getter函数的作用是什么?它相当于告诉Vue3的响应式系统:“你帮我盯着这个函数每次执行的返回值,如果返回值不一样了,就触发后面的回调。”因为userInfo.name是个字符串(原始类型),它的值变了就会触发返回值变化,所以浅监听就够了,性能完全没问题。
写法2:监听多个独立属性(不管是不是同一个对象的)
如果想同时监听userInfo的name和age,或者甚至是userInfo的name和另一个ref包裹的count变量,就可以给watch的第一个参数传一个数组,数组里的每个元素要么是getter函数,要么是ref本身:
import { ref, reactive, watch } from 'vue'
const count = ref(0)
const userInfo = reactive({ name: '张三', age: 25 })
// 数组形式,同时监听多个
watch(
[() => userInfo.name, () => userInfo.age, count],
([newName, newAge, newCount], [oldName, oldAge, oldCount]) => {
console.log('有数据变了!', { newName, newAge, newCount }, { oldName, oldAge, oldCount })
}
)
// 测试下,改任意一个都会触发
count.value++
userInfo.age = 26
这里有个小细节:数组形式的回调函数,newVal和oldVal也会变成对应的数组,顺序和你第一个参数数组里的监听源一致,如果某个监听源的初始值是空或者没变,对应的oldVal会保留上一次的状态,或者初始时是undefined(如果没开immediate的话)。
写法3:监听嵌套对象的单个确切属性
刚才的userInfo里有个address对象,现在只想监听address的city属性,怎么办?同样用getter函数,直接返回确切的路径就行:
watch(
() => userInfo.address.city,
(newCity, oldCity) => {
console.log(`城市从${oldCity}搬到了${newCity}`)
}
)
// 测试
userInfo.address.city = '广州市'
这里要注意:如果你是用ref包裹的对象,比如const userInfo = ref({ name: '张三', address: { city: '深圳' } }),那getter函数里要记得加.value哦,也就是() => userInfo.value.address.city。
加deep:true强制监听整个对象的所有属性变化,适合深层嵌套但必须全量监听的场景
刚才的getter函数虽然好用,但如果你的对象嵌套了三四层,而且你不知道用户会改哪一层的哪个属性(比如一个动态生成的表单配置对象,字段可能随后端返回变化),这时候就只能用deep:true了。
基本写法
不管是直接传reactive对象,还是传getter函数返回的整个reactive/ref对象,加上deep:true之后,Vue3就会递归遍历这个对象的所有属性,不管嵌套多深,只要有任何一个属性(包括数组的元素、对象的键值对)变化,都会触发回调:
// reactive的情况,直接传对象+deep:true
watch(
userInfo,
(newVal, oldVal) => {
console.log('整个userInfo有变化', newVal, oldVal)
},
{ deep: true }
)
// ref包裹对象的情况,推荐传getter函数返回.value(或者直接传ref对象,但传getter更规范,避免后续类型问题)
const userInfoRef = ref({
name: '张三',
address: { city: '深圳' }
})
watch(
() => userInfoRef.value,
(newVal, oldVal) => {
console.log('ref包裹的userInfo有变化', newVal, oldVal)
},
{ deep: true }
)
// 测试任意属性
userInfoRef.value.address.province = '湖南省'
不过这里有个超级大的坑,也是很多新手和老开发者都踩过的:加了deep:true之后,回调里的newVal和oldVal是一样的! 为什么?因为还是引用类型的问题——reactive和ref.value(如果是包裹引用类型的话)始终指向同一个堆内存地址,Vue3在递归监听属性变化时,不会保存旧对象的深拷贝,所以每次触发回调时,newVal和oldVal其实是同一个对象,你打印出来看的话,内容完全一致,根本没法对比哪里变了。
如果必须要对比变化前后的对象怎么办?有两个办法:一个是用第三方库(比如lodash的cloneDeep或者immer.js),在每次回调里保存旧值的深拷贝;另一个是用watchEffect的进阶用法,但那个比较麻烦,后面会提一点,不过还是建议大家:除非万不得已,否则尽量别用deep:true,因为递归遍历深层对象的开销真的很大——如果你的对象有几百个属性,甚至嵌套了图片、文件这种大引用,页面的响应速度会明显变慢,甚至出现卡顿。
用watchEffect/watchPostEffect/watchSyncEffect,自动追踪依赖的对象属性
watchEffect和watch不一样,它不需要你手动指定监听源,而是会自动追踪回调函数里用到的所有响应式数据(包括reactive对象的属性、ref、computed的返回值等等),只要这些数据里有任何一个变化,就会立即触发回调。
watchEffect的基本写法
比如刚才的userInfo,如果只想在name或address.city变化时,更新页面的某个提示,或者调用某个接口,就可以这么写:
import { reactive, watchEffect } from 'vue'
const userInfo = reactive({
name: '张三',
age: 25,
address: { city: '深圳' }
})
// 自动追踪userInfo.name和userInfo.address.city
watchEffect(() => {
console.log(`当前用户:${userInfo.name},所在城市:${userInfo.address.city}`)
// 这里可以放你想执行的逻辑,比如调用接口更新地址选择的物流信息
})
// 测试,改name或city会触发,改age不会
userInfo.age = 26
userInfo.address.city = '东莞市'
这个写法比watch的getter数组更简洁,对吧?因为不用你手动列监听源,Vue3会帮你自动找,但它也有缺点:它没有像watch那样的oldVal,没法直接对比变化前后的值;它是立即执行一次的(相当于watch里加了immediate:true),如果你不想在组件刚挂载的时候就执行,得手动处理一下;它的自动追踪有时候会“多管闲事”——比如你不小心在回调里写了一个和业务逻辑无关的响应式数据,它也会监听那个数据,导致不必要的触发。
watchEffect的三个兄弟:watchPostEffect、watchSyncEffect
刚才说的watchEffect是默认在DOM更新之前执行的,属于“pre”阶段的副作用;如果你想在DOM更新之后执行(比如需要拿到更新后的DOM节点的高度、宽度),就可以用watchPostEffect;如果你想在响应式数据变化立即同步执行(不管Vue的更新队列),就可以用watchSyncEffect,不过这两个用的场景比watchEffect少很多,大家知道有这么回事就行,碰到需要操作更新后DOM的场景再用。
用reactive的toRaw?No!推荐用computed+watch组合实现“部分对象变化监听”
很多人可能会想到:“如果我只想监听对象里的某几个嵌套属性,但又不想写长长的getter数组,能不能把这几个属性拿出来组成一个新对象,然后监听这个新对象?”答案是可以的,但不能直接用toRaw拿到原始对象再组合,因为toRaw返回的是普通对象,不是响应式的;正确的做法是用computed返回一个新的响应式对象(或者对象字面量),然后监听这个computed的返回值。
具体写法
比如还是userInfo,只想监听name、age和address.city这三个属性的变化,不管其他属性(比如以后可能加的phone、email),就可以这么写:
import { reactive, computed, watch } from 'vue'
const userInfo = reactive({
name: '张三',
age: 25,
address: { province: '广东省', city: '深圳' },
phone: '13800138000'
})
// 用computed返回一个包含目标属性的新对象
const targetUserInfo = computed(() => ({
name: userInfo.name,
age: userInfo.age,
city: userInfo.address.city
}))
// 监听这个computed对象,这里不需要加deep:true,因为computed返回的是一个新的对象字面量,每次目标属性变化时,整个对象的引用都会变
watch(
targetUserInfo,
(newVal, oldVal) => {
console.log('目标属性变了!', newVal, oldVal)
}
)
// 测试,改name、age、city会触发,改phone不会
userInfo.phone = '13900139000'
userInfo.age = 27
这里有个关键点:为什么不需要加deep:true?因为computed的返回值是每次目标属性变化时重新生成的新的对象字面量——新对象的堆内存地址和旧对象不一样,所以watch的默认浅监听就能捕捉到,这种写法既避免了deep:true的性能开销,又比长长的getter数组更清晰,特别是当目标属性有五六个甚至更多的时候,优势更明显。
进阶技巧:解决deep:true时newVal和oldVal相同的问题,以及处理ref包裹对象的特殊情况
刚才咱们提到了deep:true的坑——newVal和oldVal一样,现在咱们就来解决这个问题;ref包裹对象和reactive对象在监听时还有一些小的特殊情况,也得一起说。
用lodash.cloneDeep保存旧值,实现变化前后的对比
如果你的项目里已经装了lodash(或者愿意装一个轻量版的lodash.cloneDeep),这个方法是最简单的,具体思路是:在watch的回调外面定义一个变量,用来保存旧值的深拷贝;第一次执行watch的时候(或者组件刚挂载的时候),把初始对象的深拷贝赋值给这个变量;以后每次触发回调时,先对比新值(也就是当前的响应式对象)和保存的旧深拷贝,然后再把当前对象的深拷贝赋值给这个变量,更新旧值。
完整代码示例
import { reactive, watch, onMounted } from 'vue'
import cloneDeep from 'lodash.cloneDeep'
const userInfo = reactive({
name: '张三',
age: 25,
address: { province: '广东省', city: '深圳' }
})
// 定义变量保存旧深拷贝
let oldUserInfoCopy = null
// 组件刚挂载时,先保存初始值的深拷贝
onMounted(() => {
oldUserInfoCopy = cloneDeep(userInfo)
})
// 加deep:true监听整个对象
watch(
userInfo,
(newVal) => {
// 这里要加个判断,避免第一次执行(如果加了immediate:true的话)时oldUserInfoCopy是null
if (oldUserInfoCopy) {
console.log('变化前的用户信息:', oldUserInfoCopy)
console.log('变化后的用户信息:', newVal)
// 这里可以加对比逻辑,比如找出哪个属性变了
// 比如对比name
if (newVal.name !== oldUserInfoCopy.name) {
console.log(`只有name变了,从${oldUserInfoCopy.name}到${newVal.name}`)
}
// 对比嵌套的city
if (newVal.address.city !== oldUserInfoCopy.address.city) {
console.log(`只有city变了,从${oldUserInfoCopy.address.city}到${newVal.address.city}`)
}
}
// 每次回调结束前,更新旧深拷贝
oldUserInfoCopy = cloneDeep(newVal)
},
{ deep: true, immediate: false }
)
// 测试
userInfo.address.city = '佛山市'
这里要注意两个点:第一,onMounted的时机——如果你的watch加了immediate:true,那onMounted会在watch的第一次immediate执行之后才触发,所以这时候你得把初始化oldUserInfoCopy的逻辑放在watch外面,或者在watch的第一次回调里单独处理;第二,lodash.cloneDeep的性能——如果你的对象非常大,每次深拷贝都会有开销,所以这个方法只适合对象不是特别大、但必须对比变化前后的场景。
如果不想装lodash,也可以自己写一个简单的深拷贝函数,但要注意处理循环引用、Date、RegExp这些特殊类型,不然很容易出bug。
处理ref包裹对象的特殊情况——要不要加.value?
刚才咱们在讲getter函数的时候提到过,如果是ref包裹的对象,getter函数里要加.value,比如() => userInfoRef.value.name;但如果是直接传整个ref对象给watch,然后加deep:true呢?
两种写法的区别
import { ref, watch } from 'vue'
const userInfoRef = ref({ name: '张三', age: 25 })
// 写法1:直接传ref对象,不加.value
watch(
userInfoRef,
(newVal, oldVal) => {
console.log('写法1触发', newVal, oldVal)
},
{ deep: true }
)
// 写法2:传getter函数返回.value
watch(
() => userInfoRef.value,
(newVal, oldVal) => {
console.log('写法2触发', newVal, oldVal)
},
{ deep: true }
)
// 测试1:改对象的属性
userInfoRef.value.name = '李四'
// 测试2:替换整个ref.value的对象
userInfoRef.value = { name: '王五', age: 30 }
大家可以把这段代码复制到Vue3的项目里试一下,会发现两种写法在测试1和测试2的时候都会触发,但有一个小的区别(不过这个区别在加了deep:true之后就不太明显了):如果不加deep:true,写法1只有在替换整个ref.value的对象时才会触发(因为这时候ref的.value的引用地址变了),而写法2不管是改属性还是替换整个对象都不会触发(除非加deep:true);但如果加了deep:true,两种写法在改属性和替换整个对象时都会触发。
不过推荐大家不管加不加deep:true,只要是ref包裹的引用类型,都尽量用getter函数返回.value——因为这样写法更规范,类型提示也更好(在TypeScript项目里特别明显),而且不会和其他情况混淆。
避坑指南总结:90%的人都会踩的三个坑,别再犯了
讲完了四种主流方式和两个进阶技巧,咱们再来总结一下Vue3监听对象变化时最容易踩的三个坑,帮大家避避坑:
坑1:直接传整个reactive对象,不加deep:true,结果没反应
这个刚才讲过了,原因是reactive对象的引用地址没变,浅监听捕捉不到,解决办法是要么用getter函数返回单个/多个属性,要么加deep:true,要么用watchEffect。
坑2:加了deep:true,却想对比newVal和oldVal,结果发现两者一样
这个也讲过了,原因是Vue3不会保存旧对象的深拷贝,两者指向同一个堆内存地址,解决办法是用lodash.cloneDeep或者自己写的深拷贝函数保存旧值。
坑3:用watchEffect时,不小心在回调里写了无关的响应式数据,导致频繁触发
比如刚才的userInfo例子,你只想监听name和city,结果不小心在回调里写了console.log(userInfo.age),那改age的时候也会触发watchEffect,解决办法是:要么把无关的响应式数据从watchEffect的回调里移出去,要么换成watch的getter数组或computed+watch组合,手动指定监听源。
全场景实战:用购物车为例,把所有用法串起来
光说不练假把式,咱们现在用一个大家都熟悉的购物车场景,把刚才讲的所有用法串起来,加深一下印象。
业务场景描述
假设我们有一个购物车,购物车的每一项是一个商品对象,包含id、name、price、count、selected(是否选中)这些属性;购物车本身是一个数组,用reactive包裹;页面上有以下需求:
- 当任意商品的selected状态变化时,重新计算“全选”按钮的状态(用computed实现,但需要监听selected的变化吗?其实computed本身就会自动追踪依赖,不过咱们可以用watchEffect打印一下全选状态的变化);
- 当任意商品的count或price变化时,重新计算购物车的“总价”(同样computed自动追踪,用watch打印总价变化的前后对比);
- 当购物车数组的长度变化时(比如添加商品、删除商品),弹出一个提示框;
- 当某个特定商品(比如id为1的商品)的count变化时,调用接口更新后端的购物车数据;
- 当购物车的任意内容变化时(不管是添加商品、删除商品、改count、改price、改selected),自动保存到localStorage里(这个时候可以用deep:true,但要注意性能)。
完整代码示例(简化版,不包含接口调用和UI部分)
import { reactive, computed, watch, watchEffect, onMounted } from 'vue'
import cloneDeep from 'lodash.cloneDeep'
// 1. 定义购物车的响应式数据
const cart = reactive([
{ id: 1, name: 'iPhone 15', price: 5999, count: 1, selected: true },
{ id: 2, name: 'AirPods Pro 2', price: 1899, count: 2, selected: false }
])
// 2. 计算全选状态(computed自动追踪所有商品的selected)
const isAllSelected = computed(() => {
return cart.every(item => item.selected)
})
// 3. 计算总价(computed自动追踪所有商品的count和price)
const totalPrice = computed(() => {
return cart
.filter(item => item.selected)
.reduce((sum, item) => sum + item.price * item.count, 0)
})
// 4. 保存旧总价的深拷贝,用于对比(因为totalPrice是原始类型,其实不用深拷贝,但如果是对象的话就要用)
let oldTotalPrice = null
onMounted(() => {
oldTotalPrice = totalPrice.value
})
// 场景1:监听全选状态的变化,用watchEffect(自动追踪isAllSelected)
watchEffect(() => {
console.log('全选状态变了!', isAllSelected.value ? '已全选' : '未全选')
})
// 场景2:监听总价的变化,用watch,对比前后值
watch(
totalPrice,
(newVal, oldVal) => {
console.log(`总价从${oldVal}元变成了${newVal}元`)
oldTotalPrice = newVal
}
)
// 场景3:监听购物车数组的长度变化,用getter函数返回cart.length
watch(
() => cart.length,
(newLen, oldLen) => {
if (newLen > oldLen) {
alert('商品已添加到购物车!')
} else if (newLen < oldLen) {
alert('商品已从购物车删除!')
}
}
)
// 场景4:监听id为1的商品的count变化,用getter函数返回确切路径
watch(
() => cart.find(item => item.id === 1)?.count,
(newCount, oldCount) => {
// 这里要加个判断,避免商品被删除时find返回undefined
if (newCount !== undefined && oldCount !== undefined) {
console.log(`id为1的商品数量从${oldCount}改成了${newCount},准备调用接口更新后端数据...`)
// 模拟接口调用
// fetch('/api/update-cart', { method: 'POST', body: JSON.stringify({ id: 1, count: newCount }) })
}
}
)
// 场景5:监听购物车的任意内容变化,自动保存到localStorage,用deep:true,同时保存旧深拷贝(虽然这里不需要对比,但演示一下)
let oldCartCopy = null
onMounted(() => {
oldCartCopy = cloneDeep(cart)
// 组件刚挂载时,从localStorage读取数据(如果有的话)
const savedCart = localStorage.getItem('vue3-cart')
if (savedCart) {
// 用Object.assign或者splice替换cart的内容,保持reactive的响应式
cart.splice(0, cart.length, ...JSON.parse(savedCart))
}
})
watch(
cart,
(newVal) => {
console.log('购物车有变化,正在保存到localStorage...')
localStorage.setItem('vue3-cart', JSON.stringify(newVal))
oldCartCopy = cloneDeep(newVal)
},
{ deep: true }
)
// 模拟一些操作,测试上面的场景
setTimeout(() => {
// 测试场景2:改id为2的商品的count
cart[1].count = 3
}, 1000)
setTimeout(() => {
// 测试场景1:改id为2的商品的selected
cart[1].selected = true
}, 2000)
setTimeout(() => {
// 测试场景3:添加商品
cart.push({ id: 3, name: 'MacBook Air M2', price: 8999, count: 1, selected: true })
}, 3000)
setTimeout(() => {
// 测试场景4:改id为1的商品的count
cart[0].count = 2
}, 4000)
大家可以把这段代码复制到Vue3的项目里(记得装lodash.cloneDeep),打开控制台看一下输出,应该能看到所有场景的触发情况,非常直观。
最后总结:怎么选择合适的监听方式?
现在咱们讲了这么多,可能有些新手还是会晕:“到底什么时候用getter函数?什么时候用deep:true?什么时候用watchEffect?”别慌,咱们最后给大家一个清晰的选择指南,按照这个来选,大概率不会错:
- 只想监听单个/多个确切的属性(不管是不是嵌套的):首选getter函数(数组形式监听多个),性能最好,写法也灵活;如果属性比较多,可以用computed+watch组合;
- 需要自动追踪依赖,不想手动列监听源,且不需要对比oldVal,且能接受立即执行一次:选watchEffect;
- 需要操作更新后的DOM节点:选watchPostEffect;
- 嵌套深、不知道会改哪个属性,必须全量监听:选deep:true,但要注意性能,尽量避免;如果需要对比oldVal,记得用深拷贝;
- 只需要监听引用类型的引用地址变化(比如替换整个数组、整个对象):直接传ref对象(不加.value,不加deep:true),或者用getter函数返回整个reactive/ref对象(不加deep:true)。
好啦,今天关于Vue3监听对象变化的内容就讲到这里啦,希望能帮到大家,如果还有什么不清楚的地方,或者有其他Vue3的问题,欢迎在评论区留言哦!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


