Vue3 watch为什么有时候拿不到旧值?oldValue和newValue一样怎么办?
你是不是刚上手Vue3的watch监听,兴冲冲写了个例子,结果一运行发现打印出来的oldValue和newValue一模一样?或者明明换了个写法,直接找不到oldValue在哪?别慌,这俩问题我刚摸Vue3那会踩得坑比调试的代码行数还多,今天慢慢唠清楚。
先搞懂Vue3 watch的基础逻辑:什么时候能正常拿到旧值?
要解决问题,得先知道“正常情况”是什么样的,不管是Vue2还是Vue3,watch的核心都是监听响应式数据的变化触发时机和变化前后的快照存储——但Vue3的响应式从Object.defineProperty改成了Proxy,这俩环节有点小变动,是很多新手误解的开始。
先举个完全正常的例子看看:
<template>
<div>
<p>当前数字:{{ count }}</p>
<button @click="count++">加1</button>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
// 直接监听ref变量(其实是监听内部的value)
watch(count, (newVal, oldVal) => {
console.log('新值:', newVal)
console.log('旧值:', oldVal)
})
</script>
点几下按钮,控制台肯定会依次输出0→1,1→2这种清晰的新旧值,这种情况对应什么条件呢?
- 监听的是“值类型”响应式数据:比如ref(0)、ref('hello'),或者ref一个布尔值,值类型在内存里存的是具体内容,每次改都是直接替换整个内存地址,Proxy能轻松捕捉到变化,Vue3也会在变化前把旧的内存地址里的值存下来作为oldValue。
- 没有开启
flush: 'post'之外的特殊配置(默认post没问题):默认情况下,watch是在DOM更新后才触发的,不过值类型的旧值快照早就存好了,和DOM更新顺序没关系,但如果是后面要讲的数组/对象,flush就会有一点点间接影响。 - 监听的不是“没有触发依赖收集的对象属性路径”:这个后面会单独展开说,新手经常踩数组索引直接赋值、对象新增属性的坑,但那是监听不到变化,不是新旧值一样的问题。
最常见的坑:监听“引用类型”响应式数据,oldValue和newValue完全相同
刚才的例子顺风顺水,改个复杂点的引用类型试试?比如监听整个对象或者整个数组:
<template>
<div>
<p>用户姓名:{{ user.name }}</p>
<button @click="user.name = '李四'">改名字</button>
</div>
</template>
<script setup>
import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 20 })
// 直接监听整个reactive对象
watch(user, (newVal, oldVal) => {
console.log('新的整个user:', newVal)
console.log('旧的整个user:', oldVal)
console.log('两个user是不是同一个引用:', newVal === oldVal)
})
</script>
点“改名字”,你会发现三个输出都是张三变李四后的同一个对象,最后一行的===还会返回true——这就是大家吐槽最多的“旧值拿不到,俩值一模一样”的场景。
为啥会这样?根本原因是引用类型存的是内存地址,修改内部属性/元素不会换地址,Vue3的Proxy只会监听“修改”这个动作,但存快照的时候,它不会像Vue2那样(Vue2其实监听整个对象也会有类似问题,但监听属性路径会深拷贝浅属性)直接给整个引用类型做深拷贝存下来——为啥不做?因为深拷贝太耗性能了!如果你的user是个有几百条嵌套数据的状态,每次改个名字都深拷贝一次,浏览器卡成PPT谁负责?
那有没有办法拿到引用类型的旧值?当然有,但得分情况选方案,不能随便加deep加immediate,不然性能更崩。
只监听具体的属性路径(推荐,最省性能)
如果只需要知道某个嵌套不深的属性变化前后的值,直接把这个属性路径改成函数返回值就好——注意,reactive的属性路径监听不能直接传user.name,必须传一个函数,因为user.name本身是个普通值,不是响应式的:
// 监听user.name,这时候返回的是值类型
watch(() => user.name, (newName, oldName) => {
console.log('新名字:', newName) // 李四
console.log('旧名字:', oldName) // 张三
})
这个方法的原理是:函数里的user.name会触发依赖收集,每次name变的时候,函数会重新执行返回新的普通值,Vue3会把这个普通值的前后快照存下来,当然就能拿到oldValue了。
监听整个引用类型的同时,手动加deep和clone?不,是用lodash.cloneDeep自己存快照
等等,Vue3的watch有没有内置的clone参数?翻遍官方文档也没找到——这个参数是Vue2一些第三方封装的watch插件里的,别搞错了,如果必须监听整个对象的所有变化(比如用户的name、age、地址随便改一个都要触发某个全局逻辑,还要知道整个对象之前的样子),那只能自己存快照:
import { reactive, watch } from 'vue'
import cloneDeep from 'lodash/cloneDeep'
const user = reactive({ name: '张三', age: 20 })
// 初始化的时候存一个深拷贝的旧快照
let oldUserSnapshot = cloneDeep(user)
watch(user, (newUser) => {
// 新的快照其实直接是newUser,但要注意不能直接用,因为newUser会跟着user变!
// 所以先打印自己存的旧快照
console.log('旧快照:', oldUserSnapshot)
// 再做逻辑处理
console.log('新的完整user:', newUser)
// 最后更新旧快照为新的深拷贝
oldUserSnapshot = cloneDeep(newUser)
}, { deep: true })
这里有两个关键点:
- 必须加
deep: true:因为直接监听整个reactive对象,默认只会监听对象本身的引用变化(比如user = reactive({...})重新赋值,但setup里的变量是const的,不能直接改,所以不加deep的话,直接监听整个reactive对象几乎不会触发)。 - 不能直接把newUser存下来当旧快照:因为newUser和user是同一个引用,后面user变了,newUser也会跟着变,存了等于白存——必须深拷贝一次。
这个方案的缺点就是太耗性能,lodash的cloneDeep虽然优化过,但遇到超大对象还是会有延迟,所以尽量用方案一,除非万不得已。
第二个常见问题:直接找不到oldValue参数?
这个问题比新旧值一样简单多了,主要出现在用watchEffect或者简写watch的回调函数的时候。
你用的是watchEffect,不是watch
很多新手分不清watch和watchEffect,以为watchEffect就是“自动监听所有用到的响应式数据的watch”——确实是自动,但watchEffect没有oldValue参数!因为watchEffect的核心是“副作用函数”,它只关心“用到了哪些响应式数据,数据变了就重新执行”,根本不会提前存快照,所以自然没有oldVal。
那如果watchEffect里的逻辑需要旧值怎么办?那还是别用watchEffect了,换回watch,把用到的所有响应式数据都放进监听数组里,或者用方案二自己存快照。
你简写了watch的回调函数,只写了一个参数
这个是低级错误,但新手也经常犯——比如刚才的例子,直接写成:
watch(count, (newVal) => {
console.log('新值:', newVal)
// 没写oldVal,自然找不到!
})
把第二个参数加上就好了,参数名随便你取,不一定非要叫newVal和oldVal,叫current和previous也行,只要顺序对就行——第一个是新值,第二个是旧值。
进阶小细节:开启flush: 'sync'会不会影响oldValue?
刚才提到默认的flush是'post',也就是DOM更新后触发,那如果改成'sync'(数据一变立刻触发,不等DOM更新)或者'pre'(DOM更新前触发),会不会影响oldValue的获取?
答案是值类型不会,引用类型(不管是监听路径还是整个对象加clone)也不会——因为oldValue的快照是在“触发响应式更新的那个瞬间”就存下来的,和后面什么时候执行回调函数没关系,举个flush: 'sync'的例子验证一下:
import { ref, watch, nextTick } from 'vue'
const count = ref(0)
watch(count, (newVal, oldVal) => {
console.log('sync触发的新值:', newVal)
console.log('sync触发的旧值:', oldVal)
console.log('sync时的DOM内容:', document.querySelector('p')?.textContent)
}, { flush: 'sync' })
// 点击按钮后手动触发count++,在同一个事件循环里
const handleClick = () => {
count.value++
console.log('点击事件后的count.value:', count.value)
console.log('点击事件后的DOM内容:', document.querySelector('p')?.textContent)
nextTick(() => {
console.log('nextTick后的DOM内容:', document.querySelector('p')?.textContent)
})
}
假设初始p标签是“当前数字:0”,点击按钮后控制台的输出顺序是:
- sync触发的新值:1,旧值:0
- sync时的DOM内容:当前数字:0
- 点击事件后的count.value:1
- 点击事件后的DOM内容:当前数字:0
- nextTick后的DOM内容:当前数字:1
可以看到,不管flush是啥,旧值都是对的,只是DOM更新的时间不一样。
还有一个容易被忽略的小坑:用toRefs解构后的属性,监听的时候要不要用函数?
刚才说reactive的属性监听必须用函数,那用toRefs解构出来的属性呢?
const user = reactive({ name: '张三', age: 20 })
const { name, age } = toRefs(user)
这时候的name和age都是ref对象!所以可以直接监听,不用写函数:
watch(name, (newName, oldName) => {
console.log('新名字:', newName)
console.log('旧名字:', oldName)
})
这个方法其实比写() => user.name更方便,尤其是需要监听多个属性的时候,直接放进数组就行:
watch([name, age], ([newName, newAge], [oldName, oldAge]) => {
console.log('新数据:', newName, newAge)
console.log('旧数据:', oldName, oldAge)
})
这个方法监听的也是属性的变化前后的值,性能也很好,推荐在需要解构reactive对象的时候用。
Vue3 watch拿不到旧值或者新旧值一样的解决方案
- 找不到oldValue参数:检查是不是用了watchEffect(换成watch),是不是简写回调函数只写了一个参数(加上第二个参数)。
- 新旧值一样(大概率是引用类型):
- 优先用监听具体属性路径(函数形式)或者toRefs解构后直接监听ref属性的方案,性能最好,能直接拿到旧值。
- 必须监听整个引用类型的所有变化时,手动用lodash.cloneDeep存旧快照,记得加
deep: true,而且每次回调后都要更新旧快照。
- 注意flush的配置:只影响回调触发的时间,不影响旧值的获取。
其实Vue3的watch设计成这样,是权衡了性能和易用性的结果——毕竟引用类型的深拷贝真的太耗资源了,Vue3不可能默认开启,只能让开发者自己根据场景选择合适的方案,只要搞懂了值类型和引用类型的区别,Proxy的监听原理,这些坑其实都很容易避免。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



