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

Vue3 watch新值旧值一样?怎么正确获取区分新旧值?

terry 57分钟前 阅读数 18 #Vue

你有没有碰到过用Vue3 watch监听响应式数据时,打印出来的newVal和oldVal完全是一模一样的对象或数组?或者用了深度监听之后,oldVal干脆变成了新值的副本?刚从Vue2转过来的开发者可能尤其容易踩这个坑——毕竟Vue2里深度监听对象时,oldVal至少是监听前那次更新前的旧引用,今天咱们就把Vue3 watch的newVal、oldVal这事儿彻底理清楚,从原理到解决方案,全说透。

为什么有时候watch拿到的newVal和oldVal一模一样?

这个问题得先从Vue3的响应式原理和watch的触发逻辑说起。

Vue3用的是Proxy拦截器实现响应式,而Vue2是Object.defineProperty,Proxy可以直接拦截整个对象的修改,包括新增、删除属性,修改数组下标这些,这是它比defineProperty强的地方,但在watch新旧值这件事上,也带来了一些变化。

watch的触发条件是响应式数据的“依赖变化”被追踪到——但这里的“依赖”要看你监听的是啥:

监听的是基本类型(字符串、数字、布尔、null、undefined、Symbol)

这种情况最省心,基本类型是“值传递”的,每次修改都是替换掉响应式对象里的整个值,Proxy会明确感知到“旧值被替换成新值了”,所以watch回调里的newVal和oldVal肯定不一样,而且都是准确的。

import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newVal, oldVal) => {
  console.log('new:', newVal, 'old:', oldVal) // 第一次点击count.value++后是new:1 old:0,很准
})

监听的是引用类型(对象、数组、Map、Set等)但没开deep

这时候Vue3默认只监听引用类型的地址变化——也就是你得把整个对象/数组重新赋值(比如obj.value = { ...obj.value, name: '新名字' }或者arr.value = [...arr.value, '新元素']),Proxy才会认为依赖变了,触发watch,这时候newVal是新地址的对象,oldVal是旧地址的对象,所以不一样,但如果你只是修改对象的某个属性、数组的某个元素,引用地址没动,Vue3的默认watch是不会触发的,更别说拿到新旧值了。

监听的是引用类型并且开了deep: true

这就是踩坑最多的场景!为什么?因为Proxy虽然能深度拦截属性修改,但它不会自动保存引用类型修改前的所有旧值副本——保存副本要消耗内存,Vue3不可能为每一个引用类型的每一次小修改都存一份,那这时候watch拿到的oldVal是什么?其实和newVal是同一个内存地址的对象,所以不管你打印的时候看到的是啥(有时候浏览器控制台会“偷懒”用缓存,有时候展开才发现全变了),本质上newVal === oldVal。

不过这里有个小例外:如果监听的是用shallowRef包裹的浅层响应式引用类型,并且修改的是深层属性,那deep: true也没用,和没开一样;但如果修改的是shallowRef包裹的整个引用地址,newVal和oldVal还是会不一样。

监听引用类型怎么才能拿到准确的newVal和oldVal?

知道了原理,解决方案就好找了,主要分三种场景:

只需要监听引用类型的某个具体属性/元素/计算结果

这种情况最简单,直接监听那个具体的东西就行,别监听整个引用类型,比如你想监听对象里的user.name,就别watch(user),而是watch(() => user.name);想监听数组里的第三个元素,就watch(() => arr.value[2]);想监听数组的长度,就watch(() => arr.value.length)。

因为这些具体的东西如果是基本类型,那watch默认就能拿到准确的新旧值;如果是引用类型的嵌套引用,你再针对它开deep或者用下面的方法。

import { reactive, watch } from 'vue'
const user = reactive({
  name: '张三',
  age: 18,
  address: {
    city: '北京'
  }
})
// 监听具体的基本类型属性
watch(() => user.name, (newVal, oldVal) => {
  console.log('name变了:', newVal, oldVal) // 正确
})
// 监听具体的嵌套引用类型属性,但这时候如果修改address.city,还是会触发newVal===oldVal
// 这时候要么继续嵌套监听具体的city,要么用后面的方法
watch(() => user.address, (newVal, oldVal) => {
  console.log('address被替换/开deep变了:', newVal, oldVal) // 如果只替换整个address,没问题;开deep变city,地址一样
}, { deep: true })

需要监听引用类型的所有变化,并且必须拿到修改前的完整旧值

这时候就得自己手动保存旧值副本了,常见的有两种方法:

方法1:用computed + watch的immediate选项

思路是:先写一个computed属性,把需要监听的引用类型深拷贝一份;然后watch这个computed属性,同时开immediate: true,这样组件初始化的时候就会先执行一次watch回调,把初始的深拷贝旧值存下来;之后每次computed因为原响应式数据变化而重新计算时,watch回调里的newVal就是新的深拷贝,oldVal就是之前存的旧深拷贝。

深拷贝要注意用可靠的方法,别用JSON.parse(JSON.stringify())——这个方法会丢失函数、正则、Symbol、循环引用这些东西,推荐用lodash的cloneDeep,或者自己写一个简单的深拷贝(如果你的数据结构很简单的话)。

import { reactive, computed, watch } from 'vue'
import cloneDeep from 'lodash/cloneDeep'
const todos = reactive([
  { id: 1, text: '吃饭', done: false },
  { id: 2, text: '睡觉', done: true }
])
// 计算属性深拷贝todos
const todosCopy = computed(() => cloneDeep(todos))
// 存旧值的变量
let oldTodosCopy = null
// 监听计算属性,开immediate初始化旧值
watch(todosCopy, (newVal) => {
  console.log('todos的新深拷贝:', newVal)
  console.log('todos的旧深拷贝:', oldTodosCopy)
  // 更新旧值,为下一次做准备
  oldTodosCopy = newVal
}, { immediate: true })

这种方法的优点是逻辑清晰,不管原数据怎么变化都能拿到完整的新旧值;缺点是每次原数据变化都要深拷贝,数据量大的时候可能会有性能问题,要谨慎使用。

方法2:用watchEffect手动追踪+保存

思路是:用watchEffect代替watch,在watchEffect里手动访问需要监听的响应式数据,确保依赖被收集;同时在watchEffect第一次执行的时候,把初始数据深拷贝存下来;之后每次watchEffect因为数据变化重新执行时,先打印/处理旧值,再把新数据深拷贝存为下一次的旧值。

注意watchEffect是没有明确的newVal和oldVal参数的,全靠自己控制。

import { reactive, watchEffect } from 'vue'
import cloneDeep from 'lodash/cloneDeep'
const user = reactive({
  name: '李四',
  age: 20,
  tags: ['程序员', 'Vue爱好者']
})
let oldUserCopy = null
watchEffect((onCleanup) => {
  // 手动访问所有需要监听的属性,确保依赖被收集
  const currentName = user.name
  const currentAge = user.age
  const currentTags = [...user.tags] // 这里也可以浅拷贝,但为了保险还是深拷贝整个user
  const currentUser = cloneDeep(user)
  // 如果有旧值,先处理
  if (oldUserCopy) {
    console.log('user的旧深拷贝:', oldUserCopy)
    console.log('user的新深拷贝:', currentUser)
  }
  // 更新旧值
  oldUserCopy = currentUser
  // 这里可以加onCleanup清除副作用,但这个场景暂时不需要
})

这种方法的优点是更灵活,可以自由控制依赖的收集;缺点是需要自己手动访问所有依赖,容易漏,而且同样有深拷贝的性能问题。

只是想对比引用类型的某个部分有没有变化,不需要完整旧值

比如你只想知道用户的tags数组有没有新增或删除元素,或者address的city有没有变,那不需要深拷贝整个对象,只需要在watch里用computed或者手动提取的关键值做对比就行。

import { reactive, watch } from 'vue'
const user = reactive({
  name: '王五',
  tags: ['老师', 'React转Vue']
})
// 提取关键对比值:tags的长度和join后的字符串(这样即使元素顺序变了也能发现,但如果只需要长度就更简单)
const getTagsKey = () => [user.tags.length, user.tags.join(',')].join('|')
// 监听这个关键值
watch(getTagsKey, (newKey, oldKey) => {
  console.log('tags变化前的关键值:', oldKey)
  console.log('tags变化后的关键值:', newKey)
  if (newKey!== oldKey) {
    // 在这里处理tags变化的逻辑,不需要完整旧值也能知道变了
    console.log('tags真的变了!')
  }
})

这种方法的优点是性能最好,没有深拷贝;缺点是只能对比自己定义的关键部分,不能拿到完整的旧值。

还有几个Vue3 watch的小细节要注意

除了newVal和oldVal的问题,还有几个容易混淆的点,顺便提一下:

watch监听ref和reactive的区别

  • 监听ref:直接传ref变量就行,比如watch(count,...),这时候回调里的newVal和oldVal是ref.value的值;
  • 监听reactive:直接传reactive对象的话,默认相当于开了deep: true(这也是Vue3和Vue2的一个小区别),但newVal和oldVal还是同一个地址;所以监听reactive最好用getter函数,比如watch(() => user.name,...)或者watch(() => ({...user }),...)。

watch的flush选项

flush选项控制watch回调的执行时机:

  • flush: 'pre'(默认):在DOM更新之前执行;
  • flush: 'post':在DOM更新之后执行;
  • flush:'sync':同步执行,只要数据变化就立即触发,性能最差,尽量少用。

比如你需要在watch回调里访问更新后的DOM,就把flush设为'post'。

watch的onCleanup函数

watch回调里可以接收第三个参数onCleanup,用来清除上一次执行产生的副作用,比如定时器、事件监听器、网络请求等。

import { ref, watch } from 'vue'
const keyword = ref('')
watch(keyword, (newVal, oldVal, onCleanup) => {
  let timer = null
  if (newVal) {
    timer = setTimeout(() => {
      console.log('搜索关键词:', newVal)
    }, 500)
  }
  // 清除上一次的定时器,避免频繁输入时触发多次搜索
  onCleanup(() => {
    clearTimeout(timer)
  })
})

Vue3 watch的newVal和oldVal问题,核心就是基本类型值传递没问题,引用类型默认只监听地址,开deep后地址不变所以newVal===oldVal,解决方法根据场景不同有三种:监听具体属性/元素/计算结果、手动深拷贝保存旧值、对比关键值。

最后再提醒一句:深拷贝虽然能解决问题,但数据量大的时候一定要谨慎,能不用就不用,尽量用监听具体属性或对比关键值的方法,性能会好很多,如果确实需要深拷贝,也可以用更高效的库,比如fast-copy,比lodash的cloneDeep快一些。

现在你应该不会再被Vue3 watch的newVal和oldVal坑到了吧?如果还有其他Vue3的问题,欢迎留言讨论哦!

版权声明

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

热门