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

watch的基础oldValue获取,什么时候有?什么时候是undefined?

terry 2小时前 阅读数 36 #Vue
文章标签 Vue watcholdValue

最近在社区刷帖答疑,发现10个关于Vue3 watch的问题里,有7个都是围着oldValue打转的——要么刚用setup()发现它和watchEffect不一样没有直接暴露旧值接口?要么明明开启了deep监听,oldValue和newValue打印出来一模一样?要么修改数组push pop这类方法,连基本的触发条件都卡壳更别说旧值?今天刚好整理了踩坑笔记和对实现逻辑的梳理,一次性讲透这几个高频痛点,保证看完不会再在数据监听上踩雷。 很多刚从Vue2转过来的开发者,对setup()里的watch接口陌生,其实它和Vue2有80%的逻辑是一致的,但20%的差异刚好是踩坑的重点。

首先说最基础的场景:监听响应式基础类型数据,不管是用ref定义的数字、字符串、布尔值,还是用reactive的toRefs单独提取的基础属性,只要监听的是“单个可追踪的引用值”,watch回调函数的第二个参数就是妥妥的oldValue,第一个是newValue,比如你有个count的ref,每次++,回调里打印两个值,都是准确的数值变化。

但如果监听的是整个reactive对象或者数组,那第一次触发回调的时候,oldValue一定是undefined吗?不对——准确说是“非immediate模式下第一次不触发,immediate模式下第一次触发但oldValue是undefined”,Vue2其实也是这个逻辑,但很多人可能忘了,为什么会这样?因为watch需要先记录一次初始值作为后续对比的基准,非immediate的时候,第一次记录基准但不执行回调,等数据真变了,拿第一次的基准当oldValue;immediate模式强制立即执行,这时候基准还没“缓存完成”的流程闭环,自然就只能返回undefined。

deep监听时oldValue和newValue相同?核心原因是引用类型的“浅比较存储”

这绝对是排名第一的踩坑点:明明加了deep:true,改了对象的属性或者数组的元素,打印出来的两个值完全一样,甚至展开浏览器控制台也是同步的,连浏览器都帮倒忙?

其实锅不在浏览器,也不在Vue写错了,核心是JavaScript引用类型的存储机制+Vue watch的优化策略,先回忆一下JS基础:对象、数组这类引用类型,变量存的是堆内存里的地址,不是实际数据,比如你有个person = reactive({ name: '张三' }),修改person.name = '李四',person这个变量的地址根本没变,变的只是地址指向的堆里的内容。

那Vue的deep监听是怎么存oldValue的呢?它只会存储“被监听源当前的引用”对应的快照内容的副本吗?不,只有监听源是基础类型的集合元素或者用toValue单独转换的属性才会存副本,直接监听整个reactive对象/数组时,deep模式下为了性能考虑,只存“当时的引用指向的堆内存的临时读取结果”——但这个结果本质上还是通过引用拿的,当你修改了堆里的内容,打印的时候再去读引用,自然两个值都是最新的。

浏览器控制台其实也有个小细节:如果打印的是对象/数组,你点展开之前,它显示的是“当时调用console.log的引用状态预览”,点展开之后才会重新读取堆内存的最新内容,所以有时候你会发现预览里两个值不一样,但展开全变成新的了,这个时候可以用JSON.parse(JSON.stringify())先把oldValue转成字符串再转回来,存一个独立的副本,展开就不会变了。

那有没有办法直接拿到deep模式下的“真实oldValue快照”?当然有,官方虽然没直接在deep回调里加,但给了两个替代方案,而且性能可控。

第一个方案:监听对象/数组的toJSON方法结果,或者手动用computed返回JSON副本,比如你可以写个const personSnapshot = computed(() => JSON.parse(JSON.stringify(person))),然后watch(personSnapshot, (newV, oldV) => { ... }),这种方法最简单,但缺点也明显:每次person里的任何小属性变化,都会全量序列化和反序列化,如果对象特别大(比如有几千个元素的数组,或者嵌套十几层的表单),会有不小的性能损耗,只适合中小型数据结构。

第二个方案:使用第三方库或者手写一个深度克隆的watch封装函数,社区里比较常用的是vueuse里的watchDeepWithOld,它内部用了lodash-es的cloneDeep做了性能优化的深度克隆(只克隆变化的分支?其实是lodash的结构化克隆,但比JSON的快很多,而且支持Date、RegExp这类JSON不支持的类型),如果你不想引入第三方库,也可以自己写个简单的封装,比如监听源先存一份初始的深拷贝,每次watch触发后,再把新值深拷贝覆盖旧的缓存,不过自己手写要注意处理各种边界类型,别漏了循环引用、Symbol属性这些。

修改数组的push/pop/splice这类变异方法,watch能拿到oldValue吗?

刚才提到过直接监听整个reactive数组的情况,变异方法其实和修改对象属性是一个逻辑:数组的引用地址没变,堆里的元素变了,所以非immediate第一次触发有oldValue的“临时预览”但展开是新的,用刚才的快照方案就能解决。

但还有个特殊的场景:监听数组的某个具体索引的ref元素,比如arr = reactive([1,2,3]),然后你把arr[0] = 4,或者用arr.splice(0,1,4),这个时候watch(toRef(arr,0), (newV,oldV) => { ... })是能拿到准确的oldValue(1)和newValue(4)的,因为toRef(arr,0)相当于给索引0的元素单独建了一个可追踪的引用,不管你用赋值还是变异方法改这个索引的内容,这个引用的“依赖追踪目标”是索引0对应的堆内存位置,Vue会单独记录它的变化前后的基础值(或者如果是对象类型的元素,还是存引用,但比直接监听整个数组要准)。

watchEffect能不能拿到oldValue?可以,但需要自己手动维护缓存

很多人喜欢用watchEffect,因为它不用写监听源,自动追踪依赖,但官方确实没给watchEffect暴露像watch那样的oldValue回调参数,那如果真的需要在watchEffect里对比旧值怎么办?其实很简单,用ref或者reactive在setup()里维护一个缓存变量就行。

举个例子:你有个搜索关键词的ref keyword,还有个页码的ref page,只要其中一个变了,就发请求拿数据,但发请求前需要判断“是不是关键词变了导致页码要重置”,这个时候就需要oldKeyword和oldPage的缓存,你可以这样写:

import { ref, watchEffect } from 'vue'
const keyword = ref('')
const page = ref(1)
const oldKeyword = ref('')
const oldPage = ref(1)
watchEffect((onInvalidate) => {
  // 这里先执行业务逻辑前的准备,比如记录现在的keyword和page,等下一次触发时就是old值
  const currentKeyword = keyword.value
  const currentPage = page.value
  // 然后对比旧值
  if (currentKeyword !== oldKeyword.value) {
    page.value = 1
  }
  // 发请求的部分...
  // 最后更新缓存
  oldKeyword.value = currentKeyword
  oldPage.value = currentPage
})

这里要注意watchEffect的执行顺序:每次依赖变化时,会先执行上次注册的onInvalidate回调(如果有的话),然后再执行新的watchEffect回调,所以缓存的更新要放在业务逻辑之后,确保下次拿到的是这次的“旧值”。

如果你维护的缓存是引用类型,记得也要用深拷贝更新,不然还是会出现old和new一样的问题。

不同场景下oldValue的正确获取姿势

我们可以把所有场景归成三类,对应不同的解决方案:

  1. 监听基础类型/单个reactive属性的toRef:直接用watch的第二个参数oldValue,完全准确,性能最好。
  2. 监听中小型引用类型数据的整体变化:用computed返回JSON.parse(JSON.stringify())的快照,监听这个快照,注意immediate模式下第一次oldValue是undefined。
  3. 监听大型引用类型数据/需要保留Date/RegExp等特殊类型:用vueuse的watchDeepWithOld,或者自己手写带结构化克隆的watch封装。
  4. 用watchEffect但需要对比旧值:手动用ref/reactive维护缓存,引用类型记得深拷贝。

其实不管用哪种方案,核心都是记住JavaScript的引用类型存储机制——只要理解了“变量存的是地址,不是内容”,所有关于oldValue的问题都能迎刃而解,Vue3的watch设计其实是在“性能”和“易用性”之间做了平衡,直接监听整个引用类型虽然oldValue不够直观,但避免了默认全量深拷贝带来的性能浪费,给了开发者自己选择的空间,这也是Vue3越来越受欢迎的原因之一。

版权声明

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

热门