Code前端首页关于Code前端联系我们

Vue3 watch里的oldValue和newValue为啥不对?从原理到避坑全讲透

terry 3小时前 阅读数 48 #Vue
文章标签 Vue3 watch新旧值

用Vue3做项目的同学,肯定有过半截卡壳的时刻:明明给某个数据加了watch监听,控制台打出来的oldValue和newValue要么一模一样,要么oldValue直接是undefined,连官方文档都翻烂了也找不到直接的“一键修复”按钮,别急,今天就从最底层的响应式原理说起,把这个高频问题讲得明明白白,连容易踩的小陷阱都给你标出来,以后写watch再也不用挠头。

Vue3 watch的底层逻辑:响应式追踪的“观察窗口”

要搞懂oldValue和newValue的问题,得先明白Vue3的watch到底是怎么工作的——它不像你家里装的监控摄像头,24小时无死角全录画面变化,而是一个带“条件触发”“快照对比”功能的观察器。

先拆解watch的核心组成

你平时写的watch(source, callback, options)这行代码,本质上是三个模块在配合:

  • source(数据源):告诉Vue要盯紧谁——可以是单个ref、reactive属性,也可以是返回值的函数,或者是数组里放多个这些东西。
  • callback(回调函数):数据变化后Vue要做的事,第一个参数是旧值,第二个是新值,第三个还能拿到一个onInvalidate清理函数(后面会提,但今天重点讲前俩)。
  • options(配置项):调整观察规则的开关——比如immediate要不要立刻跑一次回调、deep要不要深入追踪嵌套对象/数组、flush调整回调触发的时机(pre/post/sync)。

快照对比的关键:浅追踪vs深追踪下的“值保存方式”

为什么会出现oldValue和newValue不对的情况?核心就在Vue3对不同类型数据源的快照保存方式不一样。 我们知道,JS里的数据分两类:原始值(字符串、数字、布尔、null、undefined、Symbol、BigInt)和引用值(对象、数组、函数等),原始值是按值存储的,引用值是按地址存储的——这个是前端基本功,但放在Vue3 watch里会放大差别。

原始值/浅层引用值追踪:快照对比很“清爽”

比如你监听的是const count = ref(0)这种原始值ref,或者const user = reactive({ name: '张三' })里的user.name(单个原始值属性),Vue3会怎么做? 每次触发source的依赖更新前,Vue会先把source的当前值/引用地址的“浅拷贝快照”存下来,等下一次数据变化时,对比新的当前值和上次存的快照:如果不一样,就触发回调,把上次的快照当oldValue,新的当前值当newValue。

举个小例子试试:

import { ref, watch } from 'vue'
const count = ref(0)
// 点击按钮让count+1
const addCount = () => count.value++
watch(count, (oldVal, newVal) => {
  console.log('oldVal:', oldVal) // 第一次点:0,第二次点:1,对吗?
  console.log('newVal:', newVal) // 第一次点:1,第二次点:2,完全没问题!
})

这种情况下的oldValue和newValue从来不会出问题,因为原始值的快照存的就是真实内容,对比也是按真实内容比的。

引用值(整体/深层)追踪:这里藏着80%的坑

好,现在重点来了——当你监听的是整个reactive对象/数组,或者给ref引用值加了deep: true,Vue3的快照逻辑就变了。 举个最常见的坑例子:

import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 25 })
// 点击按钮修改user.age
const growUp = () => user.age++
// 直接监听整个user对象
watch(user, (oldVal, newVal) => {
  console.log('oldVal:', oldVal) // 你猜会打印啥?居然和newVal一模一样!都是{ name: '张三', age: 26 }
  console.log('newVal:', newVal)
})

为什么会这样?刚才说了,引用值是按地址存储的,直接监听整个reactive对象时,Vue存的快照是这个对象的地址,而不是里面的每一个属性,等你修改user.age时,对象的地址根本没变——那存的旧快照地址和新地址一样,指向的都是内存里同一个修改后的对象!所以回调里拿到的oldValue和newValue,本质上是同一个引用,控制台打印出来当然一模一样。

那如果是ref包装的引用值呢?比如const user = ref({ name: '张三' }),不加deep的话,你直接修改user.value.name会不会触发watch?不会——因为ref的浅追踪只看user.value这个地址有没有变,你只是改了地址指向的内容,没换地址,所以依赖不会更新,连回调都不会跑,如果加了deep: true,依赖会更新,但oldValue和newValue依然是同一个引用——因为深层追踪时,Vue存的快照逻辑和直接监听整个reactive一样,是存最外层的地址。

Vue3 watch里的oldValue为啥会是undefined?

刚才讲了old和new一样的情况,还有一种情况更常见:第一次触发回调(不管是immediate还是第一次数据变化),oldValue直接是undefined,这个问题其实很简单,答案就在immediate配置项和Vue的快照生成时机里。

开启了immediate但第一次“没存快照”

当你给watch加了immediate: true时,Vue会在组件挂载/监听生效的第一时间就执行一次callback——但这个时候,source还没有产生过“变化前”的状态,Vue根本没机会存第一次的旧快照,所以只能给oldValue传undefined。 比如这个例子:

import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (oldVal, newVal) => {
  console.log('oldVal:', oldVal) // 第一次立刻跑时:undefined
  console.log('newVal:', newVal) // 第一次立刻跑时:0
}, { immediate: true })

这种情况是官方设计的正常逻辑,不是bug,只要你记得在回调里加个判断,比如if (oldVal === undefined) return,就能避免第一次执行时的异常操作。

深层替换ref引用值但加了deep?别搞反了

还有一种比较隐蔽的undefined情况:比如你一开始写的是深层追踪ref引用值(deep: true),后来改需求改成直接替换整个user.value的地址——这时候,第一次替换后第二次修改内部属性会不会出问题?不会,但第一次替换的时候,如果刚好你没注意替换前后的逻辑,可能会误以为oldVal是undefined?不对,直接替换整个ref引用值(不加deep也会触发)时,第一次如果没有immediate,替换前的快照是初始的引用地址,所以oldVal是初始值,不是undefined,哦对了,这里可能容易搞反配置:直接替换整个ref引用值的内容(换地址)不需要加deep,加deep反而会让Vue多做很多无用的深层依赖追踪,影响性能。

怎么解决Vue3 watch oldValue和newValue不对的问题?

刚才讲了原理,现在给你几个100%能用的解决方案,按需取用就行。

监听“计算后的原始值/单个独立属性”(最推荐,性能最好)

既然直接监听整个引用值会因为地址不变导致快照相同,那我们换个思路:不监听整个对象,只监听我们真正关心变化的那个属性,或者把对象转换成一个“变化时能生成新原始值/新地址快照”的计算属性。

子方案1:直接监听reactive的单个属性,或者用函数返回单个属性

刚才那个user.age的例子,改一下监听源就行:

import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 25 })
const growUp = () => user.age++
// 改成直接监听user.age
watch(() => user.age, (oldVal, newVal) => {
  console.log('oldVal:', oldVal) // 25,对了!
  console.log('newVal:', newVal) // 26,完美!
})

如果是ref包装的对象,也可以这么写:watch(() => user.value.age, ...)

子方案2:监听计算属性返回的JSON字符串(适合需要对比多个嵌套属性但不需要深操作的情况)

比如你要监听user对象里的name和age有没有同时或单独变化,但不想写两个watch,也不想用deep,可以用computed返回一个JSON.stringify后的字符串——字符串是原始值,每次对象内容变了,字符串的内容肯定变,地址也会变(如果是每次重新生成的话,当然computed里每次都会返回新字符串),所以快照对比完全没问题。

import { reactive, computed, watch } from 'vue'
const user = reactive({ name: '张三', age: 25 })
const updateUser = () => { user.name = '李四'; user.age++ }
// 用computed生成JSON字符串作为监听源
const userSnapshot = computed(() => JSON.stringify(user))
watch(userSnapshot, (oldVal, newVal) => {
  // 记得要把字符串转回对象才能用哦!
  const oldUser = JSON.parse(oldVal)
  const newUser = JSON.parse(newVal)
  console.log('oldUser:', oldUser) // { name: '张三', age: 25 }
  console.log('newUser:', newUser) // { name: '李四', age: 26 }
})

这个方案的优点是简单、不需要额外工具,但缺点也很明显:JSON.stringify会忽略函数、Symbol、undefined、循环引用的属性,如果你的对象里有这些东西,就不能用这个方法。

用lodash.clonedeep/手写深拷贝保存监听前的旧快照(适合需要对比整个嵌套对象的情况)

如果你的对象里有JSON.stringify处理不了的内容,或者需要对比整个对象的所有变化,那可以自己在watch的回调触发前,保存一份旧的深拷贝快照——不过这里要注意保存的时机,不能直接在callback里存,因为callback是在数据变化后才跑的,那时候已经晚了。

那怎么在变化前存?还记得watch的callback里第三个参数onInvalidate吗?不对,onInvalidate是用来清理上一次回调的副作用的(比如取消上一次的请求),但我们可以用它配合一个外部变量来存旧快照?或者更简单的,用Vue3官方推荐的watchEffect的onInvalidate+闭包?不对,还是用watch的配置?哦对了,其实可以自己手写一个闭包,保存上一次的深拷贝值:

import { reactive, watch } from 'vue'
import { cloneDeep } from 'lodash-es' // 记得用es模块版,方便tree-shaking
const user = reactive({ name: '张三', age: 25, hobbies: ['打篮球'] })
const addHobby = () => user.hobbies.push('听音乐')
// 手写闭包保存旧深拷贝
let oldUserSnapshot = cloneDeep(user)
watch(
  () => user, // 这里用函数返回整个user,必须加deep才能追踪内部变化
  (newVal) => {
    // 直接用外部存的oldUserSnapshot当旧值
    console.log('oldUserSnapshot:', oldUserSnapshot) // { name: '张三', age: 25, hobbies: ['打篮球'] }
    console.log('newVal:', newVal) // { name: '张三', age: 25, hobbies: ['打篮球', '听音乐'] }
    // 回调执行完后,更新旧快照为新的深拷贝
    oldUserSnapshot = cloneDeep(newVal)
  },
  { deep: true }
)

这里要注意两点:

  1. 必须用深拷贝,不能用浅拷贝(比如Object.assign或者展开运算符),因为浅拷贝只能复制对象的第一层属性,第二层及以后的嵌套引用还是指向同一个地址,修改新对象的嵌套属性时,旧浅拷贝的嵌套属性也会变。
  2. 要引入轻量的深拷贝工具,比如lodash-es的cloneDeep,或者手写一个简单的深拷贝函数(但手写的要注意处理循环引用、函数、Symbol等情况,不然容易出bug)。

直接替换整个ref/reactive的地址(适合不需要保留旧引用的情况)

如果你的需求是“修改对象时,直接把整个对象换掉”,而不是只改内部的某个属性,那可以不用deep,直接替换地址——这时候Vue的浅追踪就能生效,oldValue和newValue也会是不同的引用,快照对比完全没问题。 比如把刚才的user改成ref包装的:

import { ref, watch } from 'vue'
const user = ref({ name: '张三', age: 25 })
const updateUser = () => {
  // 直接替换整个user.value的地址,用展开运算符创建一个新对象
  user.value = { ...user.value, name: '李四', age: user.value.age + 1 }
}
// 不用加deep,直接监听user就行
watch(user, (oldVal, newVal) => {
  console.log('oldVal:', oldVal) // { name: '张三', age: 25 }
  console.log('newVal:', newVal) // { name: '李四', age: 26 }
})

这个方案的优点是性能最好(不需要deep追踪),代码也简洁,但缺点是会丢失旧对象的引用(比如有些第三方库的UI组件是绑定旧引用的,换了地址可能会导致组件重新渲染,甚至出问题),所以要根据需求选择。

避坑小贴士:Vue3 watch的其他容易忽略的细节

除了oldValue和newValue的问题,还有几个和watch相关的细节,新手很容易踩坑,顺便提一下:

watch vs watchEffect:什么时候用哪个?

很多同学搞不清watch和watchEffect的区别,简单说:

  • watch:是“显式监听”,你要明确告诉Vue要盯紧谁,只有当你指定的source变化时才会触发回调,能拿到oldValue和newValue,适合需要对比前后值、或者只监听特定数据的情况。
  • watchEffect:是“隐式监听”,你不需要告诉Vue要盯紧谁,只要在回调里用到了响应式数据,Vue就会自动追踪这些数据的依赖,只要其中任何一个数据变化,就会触发回调,拿不到oldValue和newValue,适合不需要对比前后值、只需要在数据变化后执行某些操作的情况(比如自动保存表单、自动请求数据)。

flush配置项:pre/post/sync到底选哪个?

flush配置项是用来调整watch回调触发时机的,默认是pre

  • pre(默认):在组件DOM更新之前触发回调,这时候你拿到的DOM还是旧的,适合需要在DOM更新前做一些预处理的情况(比如修改其他数据,避免多次DOM更新)。
  • post:在组件DOM更新之后触发回调,这时候你拿到的DOM是新的,适合需要操作更新后的DOM的情况(比如获取某个元素的宽高)。
  • sync:同步触发回调,只要数据一变化,立刻就跑回调,性能最差,只有在极少数需要同步响应的情况下才用(比如处理高频更新的输入框,但其实用computed或者防抖节流更好)。

不要在watch回调里直接修改监听的source

这个是老生常谈的问题了,但还是有很多同学犯:比如你监听count,然后在回调里又修改count,这样会导致watch无限循环触发,浏览器直接卡死,如果一定要修改监听的source,记得加个判断条件,只有当newVal大于10的时候才修改回10”。

总结一下

今天我们从Vue3 watch的底层响应式原理说起,讲了oldValue和newValue不对的两个核心原因:引用值的快照存的是地址、开启immediate时没有第一次旧快照;然后给了三个100%能用的解决方案:监听单个属性/计算后的原始值、用深拷贝保存旧快照、直接替换整个引用的地址;最后还提了几个容易忽略的避坑小贴士。

以后写Vue3 watch的时候,只要记住这些原理和方法,就再也不会被oldValue和newValue的问题搞懵了,如果还有其他Vue3的问题,欢迎在评论区留言讨论哦!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门