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

Vue3 watch为什么有时候拿不到旧值?oldValue和newValue一样怎么办?

terry 15小时前 阅读数 201 #Vue

你是不是刚上手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这种清晰的新旧值,这种情况对应什么条件呢?

  1. 监听的是“值类型”响应式数据:比如ref(0)、ref('hello'),或者ref一个布尔值,值类型在内存里存的是具体内容,每次改都是直接替换整个内存地址,Proxy能轻松捕捉到变化,Vue3也会在变化前把旧的内存地址里的值存下来作为oldValue。
  2. 没有开启flush: 'post'之外的特殊配置(默认post没问题):默认情况下,watch是在DOM更新后才触发的,不过值类型的旧值快照早就存好了,和DOM更新顺序没关系,但如果是后面要讲的数组/对象,flush就会有一点点间接影响。
  3. 监听的不是“没有触发依赖收集的对象属性路径”:这个后面会单独展开说,新手经常踩数组索引直接赋值、对象新增属性的坑,但那是监听不到变化,不是新旧值一样的问题。

最常见的坑:监听“引用类型”响应式数据,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了。

监听整个引用类型的同时,手动加deepclone?不,是用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 })

这里有两个关键点:

  1. 必须加deep: true:因为直接监听整个reactive对象,默认只会监听对象本身的引用变化(比如user = reactive({...})重新赋值,但setup里的变量是const的,不能直接改,所以不加deep的话,直接监听整个reactive对象几乎不会触发)。
  2. 不能直接把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”,点击按钮后控制台的输出顺序是:

  1. sync触发的新值:1,旧值:0
  2. sync时的DOM内容:当前数字:0
  3. 点击事件后的count.value:1
  4. 点击事件后的DOM内容:当前数字:0
  5. 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拿不到旧值或者新旧值一样的解决方案

  1. 找不到oldValue参数:检查是不是用了watchEffect(换成watch),是不是简写回调函数只写了一个参数(加上第二个参数)。
  2. 新旧值一样(大概率是引用类型)
    • 优先用监听具体属性路径(函数形式)或者toRefs解构后直接监听ref属性的方案,性能最好,能直接拿到旧值。
    • 必须监听整个引用类型的所有变化时,手动用lodash.cloneDeep存旧快照,记得加deep: true,而且每次回调后都要更新旧快照。
  3. 注意flush的配置:只影响回调触发的时间,不影响旧值的获取。

其实Vue3的watch设计成这样,是权衡了性能和易用性的结果——毕竟引用类型的深拷贝真的太耗资源了,Vue3不可能默认开启,只能让开发者自己根据场景选择合适的方案,只要搞懂了值类型和引用类型的区别,Proxy的监听原理,这些坑其实都很容易避免。

版权声明

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

热门