Vue3 watch的newValue和oldValue到底怎么用?还有哪些隐藏坑?
用过Vue2的老开发者都知道,watch的两个回调参数非常有用——判断变化是正方向还是反方向、只响应首次外的后续修改、对比特定字段有没有变动……但到了Vue3,组合式API的watch来了,新老参数好像没变,但坑变多了?用法也加了不少细节?别慌,今天就用大家平时写代码的场景,把这俩参数掰扯透。
基础回顾:watch的newValue、oldValue分别是什么?
不管你用的是组合式API的watch,还是选项式API里保留的watch,核心逻辑没差:这俩都是函数的形参名字,可以随便改,但位置固定——第一个是监听器触发时目标属性的最新值,第二个是触发前的旧值。
举个最直观的小例子:比如你有个计数器,点击按钮加1,你想看看加1前后的数,代码大概长这样:
// 组合式API写法
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newCount, oldCount) => {
console.log(`加完啦!之前是${oldCount},现在是${newCount}`)
})
// 模拟点击
count.value = 1 // 控制台输出:加完啦!之前是0,现在是1
count.value = 2 // 控制台输出:加完啦!之前是1,现在是2
选项式API的写法其实就是把变量放在data,watch放在watch配置项里,参数逻辑一模一样,这一步是入门级操作,但别嫌简单,后面所有坑都是从这个“基础对应位置”的认知延伸出来的。
进阶场景1:监听引用类型数据,为啥oldValue和newValue看起来一样?
这是Vue3 watch里最常见、最容易让人挠头的坑,没有之一,之前有个朋友做了个购物车,想在商品数量增加时只弹“新增商品成功”,减少时弹“减少商品成功”,结果不管加还是减,console.log出来的old和new都是修改后的数组,根本没法判断。
为什么会这样?得从JavaScript的数据类型说起:基本类型(string、number、boolean、null、undefined、Symbol、BigInt)是按值传递的,也就是变量存的是具体数据;引用类型(object、array、Map、Set这些)是按引用传递的,变量存的是内存地址。
Vue2里监听数组的非直接赋值(比如push、pop)时,oldValue和newValue也是一样的,但监听整个引用(比如直接arr = [])时不一样;到了Vue3组合式API,不管你怎么监听引用类型的响应式数据,只要修改的是引用内部的属性/元素,newValue和oldValue永远指向同一个内存地址,打印出来当然一模一样!
那怎么解决?别慌,有两种主流的方法,看你的需求选就行:
直接监听引用内部的“关键点”
如果只是要判断某个具体字段变没变,别监听整个对象/数组,直接监听那个字段,比如刚才的购物车场景,假设每个商品是{id: 1, name: '可乐', count: 1},你可以监听某个商品的count,或者监听购物车数组的length,甚至是某个商品ID对应的count(这个得用箭头函数返回):
const cart = ref([{id: 1, name: '可乐', count: 1}, {id: 2, name: '薯片', count: 2}])
// 1. 直接监听整个购物车数组的length(如果判断整体增减)
watch(() => cart.value.length, (newLen, oldLen) => {
if (newLen > oldLen) {
alert('新增商品成功!')
} else if (newLen < oldLen) {
alert('删除商品成功!')
}
})
// 2. 监听单个商品的count(比如可乐,这里用了箭头函数,因为cart是ref,得先.value)
watch(() => cart.value[0].count, (newCount, oldCount) => {
if (newCount > oldCount) {
alert('可乐加购成功!')
} else if (newCount < oldCount) {
alert('可乐减购成功!')
}
})
这种方法的优点是性能最好——只监听你关心的那个点,其他地方变了不触发;缺点是如果要监听多个关键点,得写好几个watch,代码会有点冗余。
开启deep + 手动深拷贝旧值
如果必须监听整个引用类型,比如要对比整个对象里所有字段的变动(比如表单提交前的脏值检测,看看用户改了啥),那只能开启deep选项,但这还不够,因为开启deep后,内部变动触发的回调里,new和old还是同一个内存地址的引用,这时候得手动保存旧值的深拷贝:
import { ref, watch, reactive, toRaw, cloneDeep } from 'lodash-es' // 这里用了lodash的cloneDeep,也可以自己写深拷贝,但推荐用成熟库
const form = reactive({
name: '张三',
age: 18,
phone: '13800138000'
})
// 先存一份初始的深拷贝
let oldForm = cloneDeep(toRaw(form)) // toRaw把reactive转成普通对象,避免深拷贝响应式代理
watch(form, (newFormRaw) => {
// 对比两个普通对象
const changedFields = []
Object.keys(oldForm).forEach(key => {
if (oldForm[key] !== newFormRaw[key]) {
changedFields.push(key)
}
})
console.log('用户修改了这些字段:', changedFields)
// 更新oldForm
oldForm = cloneDeep(newFormRaw)
}, { deep: true })
// 模拟修改
form.name = '李四' // 控制台输出:用户修改了这些字段: ['name']
form.age = 20 // 控制台输出:用户修改了这些字段: ['age']
这里有几个细节要注意:
- 最好用
toRaw把reactive转成普通对象再深拷贝,因为直接深拷贝响应式代理可能会带来一些不必要的性能开销,甚至是代理嵌套的问题; - lodash-es是专门给ES模块用的,别直接用lodash,不然Vue3打包的时候可能会有问题(比如Tree Shaking失效);
- 如果只是浅拷贝(比如
Object.assign、展开运算符),那对象里还有嵌套对象的话,内层的引用还是一样的,对比没用,所以必须深拷贝; - 手动深拷贝旧值的话,watch的第二个回调参数oldValue其实就没用了,直接忽略就行。
进阶场景2:监听ref的基本类型,开启immediate后,oldValue是undefined?
对,这个也是Vue3组合式API里的一个“特性”,不是bug,组合式API的watch默认是惰性监听——第一次初始化的时候不会触发,只有目标属性后续变化了才会触发;但如果你开启了immediate: true,第一次初始化的时候就会立即执行一次回调,这时候还没有“旧值”这个概念,所以第二个参数就是undefined。
选项式API里的watch开启immediate后,oldValue也是undefined,这个逻辑没变,只是很多组合式API的新手可能忘了这个点。
举个例子说明一下:
const count = ref(0)
// 开启immediate
watch(count, (newCount, oldCount) => {
console.log(`加完啦?之前是${oldCount},现在是${newCount}`)
}, { immediate: true })
// 不用手动点击,页面加载(或者说组件挂载后?不对,immediate是在watch创建的时候立即执行的)就会输出:加完啦?之前是undefined,现在是0
count.value = 1 // 输出:加完啦?之前是0,现在是1
那如果开启immediate后也需要旧值怎么办?比如第一次加载的时候,要判断初始值是不是符合要求,符合就显示默认提示,不符合就显示修改提示?这时候可以像刚才深拷贝引用类型那样,手动存一份初始值:
const count = ref(0)
const initCount = count.value // 初始值手动存下来
watch(count, (newCount, oldCount) => {
// 先判断是不是第一次immediate触发
if (oldCount === undefined) {
if (newCount >= 0) {
console.log('初始值符合要求!')
} else {
console.log('初始值不符合要求!')
}
// 或者不管是不是第一次,直接用initCount和newCount、oldCount组合判断
}
}, { immediate: true })
或者你也可以用watchEffect代替watch?不过watchEffect没有new和old参数,只能自己手动对比,而且watchEffect是自动收集依赖的,有时候可能会不小心触发多余的回调,具体用哪个得看场景。
进阶场景3:用watch同时监听多个值,new和old参数是数组吗?
对!这个是组合式API比选项式API更方便的地方——可以用一个watch同时监听多个响应式数据,不管是ref还是reactive的属性(用箭头函数返回),这时候回调的第一个参数是所有监听目标最新值组成的数组,第二个参数是所有监听目标旧值组成的数组,顺序和你传进去的监听源数组的顺序完全一致。
比如你有一个登录表单,同时监听用户名和密码,只要其中一个变了,就判断是不是可以提交(比如用户名长度大于3,密码长度大于6):
import { ref, watch } from 'vue'
const username = ref('')
const password = ref('')
const canSubmit = ref(false)
// 同时监听两个ref
watch([username, password], ([newUser, newPwd], [oldUser, oldPwd]) => {
console.log(`用户名从${oldUser}变成了${newUser},密码从${oldPwd}变成了${newPwd}`)
canSubmit.value = newUser.length > 3 && newPwd.length > 6
})
// 模拟输入
username.value = 'zhangs' // 输出:用户名从变成了zhangs,密码从变成了,canSubmit还是false
password.value = '1234567' // 输出:用户名从zhangs变成了zhangs,密码从变成了1234567,canSubmit变成true
这里还要注意,如果监听的多个值里有引用类型,那引用类型对应的new和old数组元素,还是会有刚才提到的“内存地址相同”的问题,解决方法还是一样的——要么监听内部关键点,要么开启deep + 手动深拷贝。
进阶场景4:组合式API有watch和watchEffect,什么时候用watch带new和old?什么时候用watchEffect?
虽然这篇文章主要讲new和old,但这个问题太常见了,很多人分不清,导致用错了watch反而浪费了new和old的优势,简单总结一下:
- 当你需要“明确知道目标属性什么时候变了”、“需要对比新旧值”、“需要惰性监听(第一次不触发)”的时候,用watch带new和old;
- 当你不需要对比新旧值、只需要在依赖变化时自动执行副作用(比如请求接口、修改DOM)、需要立即执行(不用写immediate)的时候,用watchEffect。
举个对比的例子:比如你有一个搜索框,输入关键词后延迟500ms请求接口(防抖),这时候两种写法都可以:
用watch的写法(适合需要对比关键词是否为空的情况)
import { ref, watch } from 'vue'
const keyword = ref('')
let timer = null
watch(keyword, (newKey, oldKey) => {
// 对比新旧值:如果新关键词是空,就清空搜索结果;如果和旧关键词一样,就不请求(虽然防抖已经处理了,但双重保险)
if (newKey === oldKey) return
clearTimeout(timer)
if (newKey.trim() === '') {
console.log('清空搜索结果')
return
}
timer = setTimeout(() => {
console.log('请求接口,关键词是:', newKey)
}, 500)
})
用watchEffect的写法(更简洁,但没法直接对比新旧值)
import { ref, watchEffect } from 'vue'
const keyword = ref('')
let timer = null
// 自动收集keyword的依赖,keyword变了就执行
watchEffect(() => {
clearTimeout(timer)
const currentKey = keyword.value.trim()
if (currentKey === '') {
console.log('清空搜索结果')
return
}
timer = setTimeout(() => {
console.log('请求接口,关键词是:', currentKey)
}, 500)
})
两种写法都能实现功能,但watch的写法多了一个“对比新旧值是否一样”的逻辑,虽然在这个场景下防抖已经能处理,但如果有其他逻辑(比如关键词从空变非空、非空变空要做不同的动画),watch带new和old就会方便很多。
最后再提几个容易被忽略的小细节
- 组合式API里的watch可以停止监听:调用watch会返回一个停止函数,你可以在组件卸载前调用它,或者在不需要监听的时候随时调用,比如刚才的搜索框,组件卸载的时候要记得清掉定时器和停止监听,避免内存泄漏:
const stopWatch = watch(keyword, (newKey, oldKey) => { /* ... */ })
// 组件卸载前调用 onUnmounted(() => { clearTimeout(timer) stopWatch() })
不过Vue3的组合式API里,如果你在setup或者<script setup>里调用watch,组件卸载的时候会自动停止监听,不需要手动调用stopWatch,这一点比选项式API方便;但如果你是在定时器、Promise回调、或者其他非setup的生命周期钩子之外调用的watch,就必须手动停止了。
2. **监听箭头函数返回的基本类型时,不需要开启deep**:只有监听引用类型的响应式数据,并且要监听内部属性/元素变动的时候,才需要开启deep;如果是箭头函数返回的基本类型(() => cart.value.length`),直接监听就行,deep选项是无效的。
3. **deep选项会带来性能开销**:开启deep后,Vue会递归遍历整个引用类型的响应式数据,监听每一个内部属性/元素的变动,所以如果你的引用类型数据很大(比如包含1000个商品的购物车,每个商品又有很多嵌套属性),尽量别开启deep,还是用“监听内部关键点”的方法。
4. **flush选项可以控制回调执行的时机**:默认是`flush: 'pre'`——在DOM更新前执行;如果改成`flush: 'post'`,会在DOM更新后执行;改成`flush: 'sync'`,会同步执行(每次变动都立即执行,不合并,性能很差,尽量别用),比如你需要在回调里获取修改后的DOM元素的高度,就得改成`flush: 'post'`。
以上就是关于Vue3 watch的newValue和oldValue的所有内容,从基础回顾到进阶场景,再到隐藏坑和小细节,应该能解决你90%以上的问题,下次写代码的时候碰到这俩参数,别再慌了,先想想是什么数据类型,再想想你的需求是什么,然后选择合适的方法就行。 版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


