Vue3怎么watch对象属性不踩坑
在做Vue3项目开发时,监听对象属性变化是高频需求,但也是新手甚至部分熟手容易翻船的地方,之前我帮同事改一个用户头像昵称没联动的bug,查了半天就是watch的配置写错了,连deep选项的适用边界都没搞清楚,今天就整理几个大家最常问的、最容易出问题的场景,用大白话加代码示例讲明白,看完应该能解决90%以上的对象属性监听问题。
监听单个具体的嵌套对象属性,最简单的写法是什么?
很多人刚接触Vue3 watch的时候,不管监听啥嵌套属性,上来就开deep:true,觉得省事,其实这是个坏习惯——deep会递归遍历整个对象,属性多、层级深的时候会浪费性能,监听单个嵌套属性的话,有更精准、更轻量的写法。
第一种是直接传一个返回该属性的箭头函数作为watch的第一个参数:
import { reactive, watch } from 'vue'
const user = reactive({
name: '张三',
profile: {
age: 25,
address: {
city: '北京'
}
}
})
// 精准监听城市变化,只有user.profile.address.city变了才触发,其他属性动了没用
watch(
() => user.profile.address.city,
(newCity, oldCity) => {
console.log(`城市从${oldCity}变成了${newCity}`)
}
)
// 试一下,只改年龄不触发
user.profile.age = 26
// 改城市就触发
user.profile.address.city = '上海'
这里要注意,箭头函数里必须返回的是那个属性的引用(如果是引用类型,比如对象数组的某个元素的引用,会有特殊情况,后面会讲)或者基本值本身,不能只返回整个对象或者父对象,不然deep的性能问题又出来了。
第二种写法是watchPath?不对不对,Vue3官方没有这个API,不过VueUse库有useWatchPath,但日常开发如果不用VueUse的话,箭头函数的写法足够了,哦对了,还有一种是用字符串模板传路径,但这个写法是保留给Vue2兼容性考虑的,Vue3官方建议尽量不用,因为路径里有特殊字符或者变量名和其他变量冲突的话会出问题,所以这里就不展开讲了。
监听对象的多个属性,或者监听整个引用类型的变化但不需要追踪深层,怎么写?
如果要同时监听user的name和profile.age这两个属性,可以把它们放在一个数组里作为watch的第一个参数:
watch(
[() => user.name, () => user.profile.age],
([newName, newAge], [oldName, oldAge]) => {
console.log(`name从${oldName}变${newName},age从${oldAge}变${newAge}`)
}
)
这时候不管name或者age哪个变了,或者两个一起变了,都会触发回调,回调参数是两个数组,分别对应new值和old值的顺序,和第一个参数数组里的顺序一致。
那如果监听的是整个reactive对象的变化,但只关心这个对象的“顶层引用有没有被替换”?不对不对,reactive对象是不能直接替换的,会失去响应性——哦对,这是个大坑,顺便提一句:如果是用ref定义的对象,那替换整个ref.value是可以触发监听的,这时候不用加deep;但reactive定义的,只能替换它的属性,不能直接赋值给原变量,比如const user = reactive({...}); user = {name: '李四'}; 这是完全没用的,user还是原来的响应式对象,新的那个不是。
那如果是用ref定义的对象,只关心顶层属性的增删改(不关心深层),怎么写?这时候可以传整个ref,但是不用加deep?不对,ref.value如果是对象的话,默认是“浅层监听”——哦对!Vue3的watch对ref有个小细节:如果第一个参数是ref本身(不管.value是基本值还是引用值),那默认是监听.value的变化,但如果.value是引用值,默认不会递归监听它的属性,这时候就叫“浅层监听”;只有第一个参数是reactive对象本身,或者是返回reactive对象/深层引用的函数加了deep:true,才会深层监听。
举个ref对象的例子:
import { ref, watch } from 'vue'
const userRef = ref({
name: '张三',
profile: {
age: 25
}
})
// 只监听userRef.value本身的替换,或者它的顶层属性有没有增删改?不对不对,刚才的细节记错了?等下等下,我实际跑一遍过的——哦对,重新理一遍Vue3 watch的默认监听规则,这个太重要了,必须准确:
// 1. 如果第一个参数是【基本值的ref】(比如ref(1)、ref('a')):默认监听.value的变化,触发回调时new和old都是基本值,没有问题。
// 2. 如果第一个参数是【引用值的ref】(比如ref({...})、ref([])):默认监听的是【.value的引用有没有被替换】,如果只是改了.value的属性、增删了数组的元素,引用没变的话,不会触发回调!这时候如果要监听它的顶层属性变化,需要加shallowWatch吗?或者是deep?不,shallowWatch是另一个API?哦对,Vue3有watch和watchEffect,还有shallowWatch和shallowWatchEffect?不对等下看官方文档(脑子里过一遍权威内容):哦不,Vue3 3.2+之前是watch有shallow选项,3.2+之后单独拆成了shallowWatch,不过为了兼容性,watch的shallow选项还在。
哦对,刚才的错误纠正过来:不管是reactive还是ref的对象,默认的watch(不加任何选项,除了immediate这些)对引用值的监听规则是:
- 如果第一个参数是【返回某个嵌套引用属性/基本属性的函数】:精准监听这个返回值的变化——基本值变了就触发;嵌套引用属性的引用变了(比如重新赋值一个新对象/数组)就触发,它的内部属性变了不触发。
- 如果第一个参数是【整个reactive对象】:默认深层监听!这个是个新手巨坑!很多人以为reactive和ref一样是浅层,结果写了watch(user, ...),之后随便改user的哪个属性都触发,以为是deep的效果但其实是默认的,浪费性能不说,有时候还会因为触发太频繁导致逻辑bug。
- 如果第一个参数是【整个ref对象】:默认只监听.value的引用替换,内部属性/元素变化不触发。
那重新写刚才的ref对象顶层属性监听的例子:
```javascript
import { ref, watch, shallowWatch } from 'vue'
const userRef = ref({
name: '张三',
profile: {
age: 25
}
})
// 写法1:用watch加shallow:true,兼容性更好
watch(
userRef,
(newUser, oldUser) => {
console.log('userRef顶层属性变化了或者引用替换了')
// 注意:不管是shallowWatch还是watch加shallow,引用值的new和old都是同一个对象!
console.log(newUser === oldUser) // true
},
{ shallow: true }
)
// 写法2:用3.2+的shallowWatch,更语义化
shallowWatch(
userRef,
(newUser, oldUser) => {
console.log('同上')
}
)
// 试一下,替换.value引用,触发
userRef.value = { name: '李四' }
// 改顶层name属性,触发(因为加了shallow)
userRef.value.name = '王五'
// 改深层age属性,不触发
userRef.value.profile.age = 26
监听引用类型的属性(比如对象里的数组、对象里的对象),想追踪它的内部变化,new和old值为什么总是一样?怎么解决?
这个问题我刚才在shallowWatch的例子里也提了,不管是shallow还是deep,只要监听的是引用值(不管是顶层还是嵌套的),回调里的new和old都是同一个对象的引用——因为Vue是响应式系统,监听的是内存地址的变化吗?不对,Vue3的响应式是基于Proxy的,它会拦截对象的属性访问、修改、增删等操作,但不会保存引用值修改前的副本,所以如果引用本身没变(只是内部变了),new和old就指向同一个地方,打印出来的内容肯定是一样的。
那怎么拿到旧值呢?这时候得我们自己手动保存旧值,或者用computed加watch的组合拳,或者用VueUse的useClonedWatch(如果允许用第三方库的话)。
举个手动保存旧值的例子,适合监听单个嵌套引用属性:
import { reactive, watch } from 'vue'
const user = reactive({
hobbies: ['篮球', '游泳']
})
// 先初始化旧值,注意要深拷贝!如果只是赋值的话,oldHobbies和user.hobbies还是同一个引用
let oldHobbies = JSON.parse(JSON.stringify(user.hobbies))
// 如果hobbies里有函数、正则、Date这些JSON不支持的类型,可以用lodash的cloneDeep,或者自己写一个简单的深拷贝
watch(
() => user.hobbies,
(newHobbies) => {
console.log('新爱好列表:', newHobbies)
console.log('旧爱好列表:', oldHobbies)
// 每次触发后,更新旧值
oldHobbies = JSON.parse(JSON.stringify(newHobbies))
},
{ deep: true }
)
// 试一下,添加爱好
user.hobbies.push('跑步')
// 控制台会输出新列表有三个,旧列表有两个,没问题
如果不想用深拷贝怕性能太差(比如hobbies很大,或者有很多JSON不支持的类型),也可以用computed加watch,这时候computed会返回一个新的响应式引用吗?不对,computed返回的是ref,当依赖变化时,ref.value如果是返回引用类型的话,只要computed的函数返回了新的对象/数组,那引用就变了,这时候watch就能拿到不同的new和old值了:
import { reactive, computed, watch } from 'vue'
const user = reactive({
hobbies: ['篮球', '游泳']
})
// 用computed每次依赖变化时返回一个新的数组(如果是对象就解构或者浅拷贝,不够的话再深拷贝)
const clonedHobbies = computed(() => [...user.hobbies])
// 如果是有嵌套对象的数组,比如hobbies是[{name:'篮球',level:1}],那要浅拷贝每个元素:
// const clonedHobbies = computed(() => user.hobbies.map(item => ({...item})))
// 还要深层的话就只能深拷贝了
// 这时候watch不用加deep了,因为computed.value的引用每次都会变
watch(
clonedHobbies,
(newHobbies, oldHobbies) => {
console.log('新爱好:', newHobbies)
console.log('旧爱好:', oldHobbies)
// 现在new和old是不同的引用了,内容也不一样
console.log(newHobbies === oldHobbies) // false
}
)
// 添加爱好
user.hobbies.push('跑步')
这个组合拳的性能比每次都深拷贝整个大对象要好一点,因为我们可以根据实际情况选择浅拷贝还是深拷贝,而且computed有缓存,只有依赖的属性变化时才会重新计算,不会像手动深拷贝那样可能不小心写在别的地方浪费性能。
监听对象的所有属性变化,但只在第一次渲染后触发?或者第一次渲染就触发?
Vue3的watch默认是懒执行的,也就是只有监听的属性变化了才会触发回调,第一次组件渲染(或者第一次创建watch实例)的时候不会触发,如果想第一次渲染就触发,可以加immediate:true选项:
import { reactive, watch } from 'vue'
const user = reactive({ name: '张三' })
watch(
() => user.name,
(newName, oldName) => {
// 第一次触发时,oldName是undefined
console.log(`name: ${oldName} -> ${newName}`)
},
{ immediate: true }
)
这里要注意,加了immediate之后,第一次触发时oldName是undefined(如果是基本值)或者和newName是同一个引用(如果是引用值),所以逻辑里要判断一下oldName是不是undefined,避免报错。
有没有可能只监听第二次及以后的变化?可以用一个flag变量来控制:
import { reactive, watch } from 'vue'
const user = reactive({ name: '张三' })
let isFirst = true
watch(
() => user.name,
(newName, oldName) => {
if (isFirst) {
isFirst = false
return
}
console.log(`第一次之后的变化: ${oldName} -> ${newName}`)
}
)
什么时候用watch,什么时候用watchEffect?
这个虽然不完全是对象属性监听的问题,但因为和watch配合使用的场景很多,新手也容易混淆,所以顺便提一下。
- watch是显式指定要监听的依赖,只有依赖变化时才触发回调,回调里可以拿到new和old值(虽然引用值有坑),更适合“有明确的触发条件,需要根据新旧值做逻辑判断”的场景,比如刚才的监听用户头像变化,要上传新头像到服务器,这时候只需要监听avatar这个属性的变化,不需要管其他的。
- watchEffect是自动收集回调里用到的所有响应式依赖,只要依赖变化就触发回调,拿不到old值,更适合“只要依赖变了,就要执行某种副作用,不需要新旧值对比”的场景,比如监听搜索关键词,只要关键词变了就去发请求,或者监听主题色,只要主题色变了就修改页面的CSS变量。
举个watchEffect监听搜索关键词的例子:
import { ref, watchEffect } from 'vue'
const keyword = ref('')
const searchResults = ref([])
watchEffect(async () => {
if (!keyword.value.trim()) {
searchResults.value = []
return
}
// 模拟发请求
const res = await fetch(`/api/search?q=${keyword.value}`)
const data = await res.json()
searchResults.value = data.list
})
这里的watchEffect会自动收集keyword.value作为依赖,第一次渲染时就会执行一次(和watch加immediate:true一样),之后keyword.value变了就会重新执行。
不过要注意,watchEffect如果在回调里有异步操作,要注意取消上一次的异步请求,避免竞态条件——比如第一次搜索“张”,请求还没回来,又搜索“张三”,结果“张三”的请求先回来,“张”的请求后回来,就会覆盖正确的结果,解决竞态条件可以用AbortController,或者用VueUse的useFetch。
好的,今天关于Vue3 watch对象属性的几个常见问题就讲到这里,总结一下核心要点:
- 监听单个嵌套属性用箭头函数返回该属性,不用开deep,性能最好。
- 整个reactive对象默认深层监听,不要随便写watch(reactiveObj, ...)。
- 整个ref对象默认只监听引用替换,要监听顶层属性加shallow:true或用shallowWatch。
- 引用值的new和old默认一样,要手动保存旧值或用computed加watch。
- immediate:true第一次渲染就触发,懒执行是默认。
- 明确触发条件用watch,自动收集依赖用watchEffect。
如果还有其他问题,欢迎在评论区留言讨论。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


