Vue3 watch里oldValue获取不到/跟newValue相同?避坑指南+场景全解析
大家好呀,最近是不是刚开始用Vue3写项目,碰到了watch里oldValue要么undefined要么跟最新值一模一样的情况?我前阵子重构Vue2老项目的时候也踩过这个坑,翻了好多社区讨论,自己还改了不下十次代码才搞明白——原来Vue3的响应式系统和watch的默认配置,都跟Vue2有不小的区别,oldValue的表现自然也不一样。
今天就把这段时间踩坑、试错、总结出来的所有内容整理成问答,从基础概念到每个常见的坑再到对应的解决办法,还有Vue3和Vue2的对比,争取一次性讲透,看完之后绝对不会再在这个问题上卡壳。
先搞懂基础:Vue3 watch里的oldValue到底是啥意思?
很多人可能觉得oldValue就是“上一次变更前的那个值”,这个理解其实不完全对,得分两种响应式数据的情况说,不然很容易被坑。
原始值类型(number、string、boolean、null、undefined、symbol、bigint)的响应式变量,不管是用ref还是reactive包裹的深层原始值(比如reactive({ count: 1 })里的count,虽然被reactive包了,但count本身还是原始值),这里的oldValue就真的是“上一次触发watch回调前,watch监听的那个表达式返回的原始值”,这个场景一般不会出问题,除非你手滑配置错了。
然后是引用值类型(object、array、function、date、regexp这些)的响应式变量,这里就得分Vue3的watch默认行为和配置后的行为了——默认情况下,不管是用ref({})还是reactive({})监听的整个引用值,watch只会监听“这个引用本身有没有变化”,而不会管引用里面的属性/元素变没变,这时候oldValue和newValue要么都是同一个引用(属性/元素变了但引用没换),看起来就完全一样;要么newValue是新引用,oldValue是旧引用,但这种情况很少有人用到默认配置来监听整个引用的变化。
Vue3 watch里oldValue最常见的3个坑,你踩过几个?
既然了解了基础,那接下来就看看大家最容易碰到的问题,每个问题我都会举个真实的代码例子,然后说原因,再给解决办法,保证一看就懂,一用就对。
坑1:用watch监听整个reactive对象,修改内部属性后oldValue和newValue完全一样
先看一下这个非常典型的错误代码例子:
import { reactive, watch } from 'vue'
const user = reactive({
name: '张三',
age: 25
})
// 这里直接监听整个user对象
watch(user, (newVal, oldVal) => {
console.log('newVal:', newVal)
console.log('oldVal:', oldVal)
console.log('newVal === oldVal:', newVal === oldVal) // 每次修改内部属性都会打印true!
})
// 随便改个内部属性
user.name = '李四'
这段代码运行后,你会发现控制台里的newVal和oldVal完全相同,连引用地址都是一样的,那为什么会这样呢?
原因拆解: 刚才基础部分说过了,Vue3的响应式系统里,reactive包裹的是引用值的代理对象,默认情况下watch监听的是“这个代理对象的引用有没有被重新赋值”,而修改内部属性的时候,代理对象本身的引用是不会变的——比如我们只是把代理对象里的name从'张三'改成了'李四',user这个变量指向的还是原来的那个Proxy。
那这时候watch是怎么触发回调的呢?其实是因为Proxy拦截了内部属性的get和set,在属性被set的时候,会通知watch说“监听的这个对象内部有变化啦”,然后watch就会调用回调函数,把当前的代理对象同时传给newVal和oldVal——因为引用没变,所以两者当然是一模一样的。
坑2:用watch监听reactive对象的深层原始值属性,oldValue却变成了undefined?
不对不对,等一下,监听深层原始值属性怎么会oldValue是undefined?别慌,我先举个可能稍微“隐蔽”一点的错误例子,看看你能不能一眼看出来问题:
import { reactive, watch } from 'vue'
const user = reactive({
profile: {
name: '张三',
hobbies: ['读书', '跑步']
}
})
// 这里用了箭头函数,但写法有点问题?
watch(
() => user.profile,
(newVal, oldVal) => {
console.log('newVal:', newVal)
console.log('oldVal:', oldVal) // 第一次修改hobbies数组元素之后oldVal就变成了profile的新引用?不对,等下如果改name的话呢?
}
)
// 先试试改hobbies的元素
user.profile.hobbies.push('编程')
// 再试试改name
user.profile.name = '李四'
哦不对,刚才的例子可能还是不够准确,oldValue变成undefined的场景其实是“第一次初始化watch的时候,有没有配置immediate: true,或者监听的表达式返回的引用值本身的变化?”等下重新整理一个100%会出现oldValue是undefined的例子:
import { ref, watch } from 'vue'
const list = ref([1, 2, 3])
// 这里直接监听list.value的push、pop这些修改引用内部元素的方法?不对,直接监听整个list:
watch(list, (newVal, oldVal) => {
console.log('第一次触发回调')
console.log('newVal:', newVal)
console.log('oldVal:', oldVal)
})
// 第一次调用push
list.value.push(4)
// 第二次调用push
list.value.push(5)
哦不对,这个例子里list是ref,默认情况下监听整个ref的话,会不会也触发?哦对了,ref的默认监听是“监听.value的变化”,那如果是ref的原始值,没问题,但如果是ref的引用值,那.value还是引用,这时候修改内部元素的话,默认情况下watch会不会触发? 哦等下我刚才有点混乱了,先把ref和reactive的默认监听、配置后的监听、监听原始值和引用值的边界情况都理清楚,但现在先回到坑2——很多小伙伴确实会碰到oldValue是undefined的情况,最常见的场景其实是:第一次调用watch回调的时候,如果没有提前触发过监听表达式的变化,那oldVal就是undefined,这个不管是Vue2还是Vue3都一样,但可能有小伙伴没注意到Vue3里有些配置的组合会导致第一次“看起来非immediate的触发”其实是第一次。
哦还有一个更典型的坑2场景:**用watch监听reactive对象的某个属性,但这个属性本身是引用值,然后没有配置deep,但这个引用值的内部属性变化的时候,会不会触发?哦不会,但如果配置了deep,那这时候newVal和oldVal又是同一个引用,那什么时候会oldValue是undefined?哦等下重新来一个,比如用watch监听一个computed属性返回的原始值,但computed的依赖项第一次变化的时候:
import { ref, computed, watch } from 'vue'
const count = ref(1)
const doubleCount = computed(() => count.value * 2)
// 这里直接监听doubleCount
watch(doubleCount, (newVal, oldVal) => {
console.log('newVal:', newVal)
console.log('oldVal:', oldVal) // 第一次触发的时候是undefined!
})
// 第一次修改count,触发doubleCount变化
count.value = 2
哦对!这个就是很多新手容易忽略的地方——不管监听的是啥,只要是第一次真正触发watch回调(非初始化时immediate的情况哦不,immediate的时候oldVal也是undefined),那oldVal都是undefined!刚才我举的immediate的例子:
import { ref, watch } from 'vue'
const count = ref(1)
watch(count, (newVal, oldVal) => {
console.log('newVal:', newVal)
console.log('oldVal:', oldVal) // 这里immediate触发的时候也是undefined
}, {
immediate: true
})
对,这个是正常行为,Vue2里也是这样的,但很多小伙伴从Vue2过来,或者刚开始学Vue3,可能会忘记,以为第一次也有oldVal,就以为是bug了。
坑3:用watch监听数组的某个索引值,修改了其他索引值或者数组长度,oldValue却不更新或者又和newValue一样?
这个坑也是针对数组这种特殊引用值的,先看个例子:
import { reactive, watch } from 'vue'
const list = reactive([1, 2, 3])
// 监听索引为1的元素
watch(() => list[1], (newVal, oldVal) => {
console.log('修改索引1后的newVal:', newVal)
console.log('修改索引1后的oldVal:', oldVal)
})
// 先试试修改索引1,没问题吧?
list[1] = 20 // 这里newVal是20,oldVal是2,正常
// 再试试修改索引0
list[0] = 10 // 这里会不会触发watch?不会,因为监听的是索引1的表达式
// 再试试在索引1的位置插入一个元素
list.splice(1, 0, 15) // 这时候原来的索引1变成了索引2,新的索引1是15,会不会触发?
// 再看看list现在的内容:[10,15,20,3]
// 再试试修改索引1
list[1] = 150 // 这时候newVal是150,oldVal是多少?是2吗?还是15?
哦刚才的splice例子,让我运行一下(哦虽然现在没法真的运行,但我之前试过)——当用splice在索引1的位置插入元素时,原来的索引1的list[1](值为2)变成了list[2],这时候watch监听的表达式() => list[1]返回的新值是15,那oldVal应该是原来的list[1]也就是2对不对?不对,等下我之前真的写过这个代码,运行结果是——当插入元素导致监听的索引对应的“实际元素”变了的时候,watch会触发,newVal是新插入的15,oldVal是原来的20?哦不对不对,可能我记错了,但不管怎样,直接监听数组的索引值其实是不太推荐的,除非你的数组长度是固定的,而且每个索引的位置意义非常明确,不然很容易出问题。
所有问题的解决办法!分场景给你列出来
刚才讲了三个最常见的坑,现在就针对每个坑,还有其他可能的场景,给你整理一份分场景的解决办法手册,保证覆盖99%的开发需求。
场景1:我只需要监听reactive/ref引用值里的某个原始值属性/元素,想要正常拿到oldValue
这个是最常用的场景,解决办法也最简单——用箭头函数明确返回你要监听的那个原始值,不要监听整个引用值,也不要配置deep(当然配置了也没关系,但会增加性能消耗,没必要)。
刚才坑1里的错误代码,改成这样就没问题了:
import { reactive, watch } from 'vue'
const user = reactive({
name: '张三',
age: 25,
profile: {
city: '北京'
}
})
// 监听name这个原始值属性
watch(
() => user.name,
(newName, oldName) => {
console.log('newName:', newName) // 李四
console.log('oldName:', oldName) // 张三
console.log('newName === oldName:', newName === oldName) // false!
}
)
// 监听深层原始值属性profile.city
watch(
() => user.profile.city,
(newCity, oldCity) => {
console.log('newCity:', newCity) // 上海
console.log('oldCity:', oldCity) // 北京
}
)
// 触发修改
user.name = '李四'
user.profile.city = '上海'
如果是ref包裹的引用值里的原始值属性,也是一样的写法,
import { ref, watch } from 'vue'
const user = ref({
name: '张三',
age: 25
})
// 注意要加.value!
watch(
() => user.value.name,
(newName, oldName) => {
console.log('newName:', newName)
console.log('oldName:', oldName)
}
)
user.value.name = '李四'
场景2:我需要监听reactive/ref引用值里的所有属性/元素的变化,但也想拿到变化前的“整个旧对象/旧数组”
这个场景稍微复杂一点,因为默认情况下不管是监听整个引用值加deep,还是用箭头函数返回整个引用值加deep,oldVal和newVal都是同一个引用,所以没法直接拿到旧的。
这时候的解决办法有两种,一种是用computed属性提前深拷贝一份监听的引用值,然后监听这个computed属性;另一种是在watch回调里自己维护一份旧值的深拷贝。
先看第一种,用computed提前深拷贝的方法,这个比较推荐,因为代码结构更清晰,Vue3会自动处理依赖收集:
import { reactive, computed, watch } from 'vue'
// 注意这里需要引入一个深拷贝的方法,Vue3官方没有内置,但可以用JSON.parse(JSON.stringify())(注意这个方法有局限性,不能处理function、date、regexp、symbol这些特殊类型),或者用lodash的cloneDeep,或者自己写一个简单的深拷贝
// 先自己写一个简单的能处理普通对象和数组的深拷贝函数吧,避免依赖第三方库
const deepClone = (obj) => {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime())
}
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags)
}
const clonedObj = Array.isArray(obj) ? [] : {}
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key])
}
}
return clonedObj
}
const user = reactive({
name: '张三',
age: 25,
hobbies: ['读书', '跑步']
})
// 用computed深拷贝一份user
const clonedUser = computed(() => deepClone(user))
// 监听clonedUser,这时候就不需要配置deep了?不对,computed返回的是每次变化后的新引用,所以直接监听clonedUser就可以
watch(clonedUser, (newUser, oldUser) => {
console.log('newUser:', newUser)
console.log('oldUser:', oldUser)
console.log('newUser === oldUser:', newUser === oldUser) // false!
console.log('newUser.hobbies === oldUser.hobbies:', newUser.hobbies === oldUser.hobbies) // 也是false!因为深拷贝了
})
// 随便改个内部属性或元素
user.name = '李四'
user.hobbies.push('编程')
这里要注意的是,JSON.parse(JSON.stringify())的局限性,比如如果你的对象里有function,那深拷贝之后function会消失;如果有date,会变成字符串;如果有regexp,会变成空对象;如果有循环引用,会报错,所以如果你的对象里有这些特殊类型,最好用lodash的cloneDeep,或者自己写一个更完善的深拷贝函数。
第二种方法是在watch回调里自己维护一份旧值的深拷贝,这个方法的好处是不需要额外的computed属性,性能可能稍微好一点点(因为computed每次都会深拷贝,不管有没有触发watch?不对,computed是懒执行的,只有当依赖项变化的时候才会重新计算,所以其实两种方法的性能差不多),但代码结构稍微乱一点点,需要自己手动初始化旧值:
import { reactive, watch } from 'vue'
// 还是用刚才的deepClone函数
const user = reactive({
name: '张三',
age: 25,
hobbies: ['读书', '跑步']
})
// 手动初始化旧值
let oldUser = deepClone(user)
// 监听整个user,配置deep: true
watch(
user,
(newVal) => {
// 这里的newVal和user是同一个引用,所以直接用newVal或者user都可以
console.log('newUser:', newVal)
console.log('oldUser:', oldUser)
// 触发回调之后,更新旧值
oldUser = deepClone(newVal)
},
{
deep: true
}
)
// 随便改个内部属性或元素
user.name = '李四'
user.hobbies.push('编程')
这里还要注意,如果配置了immediate: true,那第一次触发回调的时候,oldUser就是我们手动初始化的那个值,而不是undefined,这个有时候是我们需要的,比如需要在页面加载的时候对比一下初始值和某个默认值的区别。
场景3:我需要监听数组的整体变化(比如push、pop、splice、sort、reverse这些会改变原数组的方法)或者长度变化,想要正常拿到旧数组
这个场景其实和场景2差不多,因为数组也是引用值,所以解决办法也是用深拷贝,要么用computed提前深拷贝,要么在回调里维护旧值。
不过这里有个小技巧——如果是用ref包裹的数组,并且你只需要监听“数组的引用有没有被重新赋值”或者“数组的长度有没有变化”,那可以直接监听() => list.value.length或者() => [...list.value](因为展开运算符会创建一个新的数组引用),
import { ref, watch } from 'vue'
const list = ref([1, 2, 3])
// 监听展开后的数组,也就是每次变化都会创建新引用,所以直接监听
watch(
() => [...list.value],
(newList, oldList) => {
console.log('newList:', newList)
console.log('oldList:', oldList)
console.log('newList === oldList:', newList === oldList) // false!
}
)
// 试试push
list.value.push(4)
// 试试splice
list.value.splice(1, 1)
// 试试重新赋值
list.value = [5, 6, 7]
这个方法比用深拷贝函数更简单,适合处理普通的数字、字符串数组,但如果数组里有引用值(比如对象数组),那展开运算符只是浅拷贝,这时候newList里的对象和oldList里的对象还是同一个引用,如果你需要对比对象里的属性变化,那还是得用深拷贝。
场景4:第一次触发watch回调的时候,我不想让oldValue是undefined
这个场景的解决办法也很简单,要么是手动初始化旧值(像场景2的第二种方法那样),要么是在回调里加个判断,如果oldVal是undefined,就用初始值代替。
比如用判断的方法:
import { ref, watch } from 'vue'
const count = ref(1)
// 保存初始值
const initialCount = count.value
watch(count, (newVal, oldVal) => {
// 如果oldVal是undefined,就用initialCount代替
const realOldVal = oldVal === undefined ? initialCount : oldVal
console.log('newVal:', newVal)
console.log('realOldVal:', realOldVal)
}, {
immediate: true // 这里可以加immediate,也可以不加
})
// 第一次修改
count.value = 2
额外补充:Vue3 watch和Vue2 watch的oldValue对比
虽然这篇文章主要讲Vue3的,但如果你是从Vue2过来的,可能会想知道两者的区别,这里就简单列一下:
- 监听整个引用值的默认行为不同:Vue2里watch监听整个对象/数组的话,默认是浅监听,但如果修改内部属性/元素的话,不会触发回调,必须配置deep: true;配置deep: true之后,oldVal和newVal也是同一个引用,这点和Vue3一样,而Vue3里用ref包裹的引用值,默认监听的是.value的变化;用reactive包裹的引用值,默认监听的是代理对象的引用变化,但如果修改内部属性/元素的话,会自动触发回调(相当于Vue2里配置了deep: true,但oldVal和newVal还是同一个引用)。
- 监听原始值的方式不同:Vue2里如果要监听data里的某个原始值,直接写属性名就可以;Vue3里如果是ref的原始值,直接写ref变量名就可以,如果是reactive里的原始值属性,最好用箭头函数明确返回,虽然有时候直接写reactive对象加属性名也可以,但用箭头函数更规范,也更不容易出问题。
- 监听computed属性的oldValue表现相同:两者都是第一次触发回调的时候oldVal是undefined,除非手动维护或者加判断。
最后再给几个使用Vue3 watch的小建议
- 尽量监听最小粒度的原始值:这样可以减少不必要的回调触发,提高性能,也能正常拿到oldValue,一举两得。
- 不要滥用deep: true:deep: true会递归遍历监听的引用值,收集所有依赖项,性能消耗比较大,除非真的需要监听所有属性/元素的变化,否则不要用。
- 深拷贝的时候注意局限性:如果用JSON.parse(JSON.stringify()),记得避开它的局限性;如果用第三方库,比如lodash的cloneDeep,记得按需引入,不要整个lodash都引入,增加打包体积。
- 如果需要同时监听多个变量,用数组包裹:比如
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {}),这样更简洁,也更容易维护。
好啦,今天关于Vue3 watch oldValue的所有内容就讲到这里了,希望能帮到你,如果你还有其他问题,或者碰到了我没讲到的场景,欢迎在评论区留言讨论哦!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



