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

Vue3 watch怎么拿到上一次的旧值?引用类型旧值为空怎么办?

terry 9小时前 阅读数 94 #Vue

用过Vue2的开发者都知道,watch函数里直接有第二个形参就是旧值,上手特别顺,但转到Vue3之后,有些场景下旧值突然“消失”或者和新值一样?这可不是框架抽风,而是Vue3对响应式做了升级优化,watch的用法也跟着变了——不同的写法、不同的监听对象,获取旧值的方式甚至结果都会不一样,接下来咱们就一步步拆解,彻底搞懂Vue3 watch旧值的所有门道。

普通值类型:直接用第二个形参就能拿到

先从最简单的说起,要是你监听的是单个ref定义的字符串、数字、布尔值、null、undefined这些原始值类型,或者是用reactive对象里点出来的单个原始值属性(注意这里是单个属性哦,后面会讲为什么不能直接监听整个reactive对象),那Vue2的习惯完全能用,直接在watch回调里取第二个形参就是旧值。

举个小例子,比如我们做一个输入框的输入长度统计,每次输入超过10个字就提示,同时把上一次的长度也展示出来:

<script setup>
import { ref, watch } from 'vue';
const inputText = ref('');
const lastLength = ref(0);
const currentLength = ref(0);
const showWarning = ref(false);
// 第一个参数是要监听的目标,第二个是回调函数(newVal, oldVal)
watch(inputText, (newVal, oldVal) => {
  currentLength.value = newVal.length;
  lastLength.value = oldVal.length; // 这里直接取第二个参数
  showWarning.value = newVal.length > 10;
});
</script>
<template>
  <div>
    <input v-model="inputText" placeholder="输入内容试试" />
    <p>当前长度:{{ currentLength }}</p>
    <p>上次输入长度:{{ lastLength }}</p>
    <p v-if="showWarning" style="color: red;">输入太长啦,控制在10字以内!</p>
  </div>
</template>

你可以把这段代码复制到Vue Playground里试试,每次打字、删字,lastLength都会更新成上一次的长度,完全没问题。

直接监听整个reactive对象:旧值和新值一模一样!

这是Vue3新手最容易踩的坑——很多人习惯直接监听用reactive定义的整个对象,比如下面这段代码:

<script setup>
import { reactive, watch } from 'vue';
const userInfo = reactive({
  name: '张三',
  age: 25
});
// 直接监听整个reactive对象
watch(userInfo, (newVal, oldVal) => {
  console.log('新值:', newVal);
  console.log('旧值:', oldVal);
  console.log('新旧值是否相等:', newVal === oldVal);
});
// 点击按钮修改name
const updateName = () => {
  userInfo.name = '李四';
};
</script>
<template>
  <div>
    <p>姓名:{{ userInfo.name }}</p>
    <p>年龄:{{ userInfo.age }}</p>
    <button @click="updateName">修改姓名</button>
  </div>
</template>

点击修改按钮之后,你会发现控制台里的newVal和oldVal打印出来的内容完全一样,而且newVal === oldVal居然返回true!这是为什么呢?

首先得回忆一下Vue3的响应式原理:reactive定义的对象是被Proxy代理过的,当我们修改对象内部的属性时,Proxy会拦截到这个操作,但整个代理对象本身的引用地址是没有改变的,而watch默认是浅监听(不过直接监听reactive对象时,Vue会强制开启深监听,但还是只对比引用),当回调函数触发时,它拿到的newVal和oldVal都是同一个Proxy代理对象的引用,打印出来的内容自然就是修改后的最新状态,自然不会有“旧值”的概念了。

那怎么解决这个问题呢?有三个常用的方法,咱们一个一个说。

用函数返回值的形式监听整个reactive对象

这是最简单的解决办法,只要把第一个参数从直接传userInfo改成传一个箭头函数() => userInfo,再加上第三个配置对象里的deep: true,就能正常拿到旧值了?不对不对,这里等一下——等我实际试一遍再说!

哦对哦,刚才说的引用问题还在,直接返回() => userInfo的话,不管加不加deep,新旧值还是同一个引用,打印出来的内容还是一样的,那要怎么才能拿到真正的旧值副本呢?对了,得结合JSON.parse(JSON.stringify())或者structuredClone()这类深拷贝方法,在函数返回值的时候先深拷贝一份当前的状态?不对不对,这样写的话,watch监听的目标就变成了每次深拷贝出来的新对象,每次属性变化都会触发两次?别慌别慌,正确的做法是把深拷贝放在回调函数外面?不,放在watch的回调里?也不对——放在watch的getter函数外面?哦,等我理清楚:

正确的逻辑是,watch的getter函数(也就是第一个箭头函数)每次被触发时,会返回当前的目标值,Vue会把这个返回值缓存下来作为下一次的“旧值”,但如果我们直接返回代理对象本身,缓存的就是引用,下次变化时还是同一个引用;如果我们在getter函数里深拷贝一份代理对象的当前状态返回,那缓存的就是一个独立的旧副本,下次触发回调时,getter函数返回新的深拷贝副本(也就是新值),第二个参数就是之前缓存的旧副本,这样就对了!

而且因为我们返回的是一个新对象,Vue默认的浅监听就能检测到变化(因为引用变了),所以连deep: true都不用加?那再加的话会不会有问题?其实不会,只是有点多余。

那再修改一下刚才的代码:

<script setup>
import { reactive, watch } from 'vue';
const userInfo = reactive({
  name: '张三',
  age: 25,
  address: { // 再加个嵌套对象测试
    city: '北京',
    district: '朝阳区'
  }
});
// 用深拷贝的getter函数监听整个reactive对象
watch(
  () => JSON.parse(JSON.stringify(userInfo)), // getter返回深拷贝的当前状态
  (newVal, oldVal) => {
    console.log('新值:', newVal);
    console.log('旧值:', oldVal);
    console.log('新旧值是否相等:', newVal === oldVal);
    // 这里可以放心地修改newVal或者oldVal,不会影响原对象
    newVal.name = '临时改的';
    console.log('原对象name:', userInfo.name); // 还是李四或者张三,不会变
  }
);
// 测试修改name
const updateName = () => {
  userInfo.name = '李四';
};
// 测试修改嵌套对象的city
const updateCity = () => {
  userInfo.address.city = '上海';
};
</script>
<template>
  <div>
    <p>姓名:{{ userInfo.name }}</p>
    <p>年龄:{{ userInfo.age }}</p>
    <p>城市:{{ userInfo.address.city }}</p>
    <p>区域:{{ userInfo.address.district }}</p>
    <button @click="updateName">修改姓名</button>
    <button @click="updateCity">修改城市</button>
  </div>
</template>

现在再点击修改姓名或者修改城市,控制台里的newVal和oldVal就完全不一样了,而且修改newVal的属性也不会影响原对象,完美!

不过这里要注意JSON.parse(JSON.stringify())的局限性:它不能处理函数、正则表达式、Symbol、BigInt、循环引用这些特殊类型,如果你的reactive对象里有这些东西,建议用更强大的深拷贝方法,比如lodash.cloneDeep()(不过要注意lodash的体积,如果只需要深拷贝,也可以自己手写一个简单的,或者用ES2022新增的structuredClone()——这个浏览器原生支持,能处理大部分特殊类型,除了函数和循环引用里的某些情况,不过一般开发场景足够用了)。

分别监听reactive对象里的每个属性

如果你的reactive对象里属性不多,而且不需要一次性监听所有属性的变化,那可以分别监听每个属性,这样也能直接用第二个形参拿到旧值,而且不需要深拷贝,性能会更好。

还是刚才的例子,分别监听name和address.city:

<script setup>
import { reactive, watch } from 'vue';
const userInfo = reactive({
  name: '张三',
  age: 25,
  address: {
    city: '北京',
    district: '朝阳区'
  }
});
// 监听单个原始值属性name
watch(() => userInfo.name, (newVal, oldVal) => {
  console.log('name新值:', newVal);
  console.log('name旧值:', oldVal);
});
// 监听嵌套对象的原始值属性city
watch(() => userInfo.address.city, (newVal, oldVal) => {
  console.log('city新值:', newVal);
  console.log('city旧值:', oldVal);
});
const updateName = () => {
  userInfo.name = '李四';
};
const updateCity = () => {
  userInfo.address.city = '上海';
};
</script>

这种方法的好处是精准,只监听你需要的属性,不会因为其他属性的变化触发不必要的回调,性能最优;坏处是如果属性很多,会写很多重复的watch代码,维护起来有点麻烦。

用watchEffect配合手动缓存旧值

要是你觉得watch的写法有点啰嗦,或者需要同时做一些副作用操作(比如修改DOM、发送请求),那可以用watchEffect,再手动维护一个变量来缓存旧值。

watchEffect的特点是:不需要指定监听目标,回调函数里用到了哪个响应式数据,就会自动监听哪个数据第一次组件挂载时就会执行一次(可以通过配置flush或者stop来控制);没有第二个旧值形参,所以必须手动缓存。

那手动缓存怎么实现呢?其实很简单,在组件初始化的时候(或者在watchEffect第一次执行的时候),先把响应式数据的当前状态深拷贝一份(如果是引用类型的话,原始值不用),赋值给一个普通变量(ref或者reactive都可以,普通变量也行,不过如果要在模板里展示的话,最好用ref),然后在watchEffect的回调函数里,先把旧值存起来,再更新缓存。

举个例子:

<script setup>
import { reactive, ref, watchEffect } from 'vue';
import { cloneDeep } from 'lodash-es'; // 记得用es版本,不然打包会有问题
const userInfo = reactive({
  name: '张三',
  age: 25,
  address: {
    city: '北京',
    district: '朝阳区'
  }
});
// 用ref缓存旧值
const oldUserInfo = ref(cloneDeep(userInfo));
watchEffect((onInvalidate) => {
  // 这里先读取旧值,然后做你需要的操作
  console.log('当前userInfo:', userInfo);
  console.log('上次userInfo:', oldUserInfo.value);
  // 重要!!!在副作用执行完之后,或者用onInvalidate,更新旧值缓存
  // 这里建议用onInvalidate,因为它会在下一次副作用执行之前调用,
  // 可以避免一些异步操作导致的旧值错乱问题
  onInvalidate(() => {
    oldUserInfo.value = cloneDeep(userInfo);
  });
});
const updateName = () => {
  userInfo.name = '李四';
};
const updateCity = () => {
  userInfo.address.city = '上海';
};
</script>

这里解释一下为什么要用onInvalidate:如果你的watchEffect回调里有异步操作,比如发送请求,那在异步操作完成之前,用户可能又修改了响应式数据,触发了下一次watchEffect,如果我们直接在回调函数的最后更新旧值,那第一次异步操作的回调还没拿到真正的旧值,旧值就已经被更新成第二次的新值了,而onInvalidate会在下一次副作用执行之前,或者在组件卸载的时候调用,这样就能保证每次副作用执行时,拿到的都是上一次的旧值,非常稳妥。

这种方法的好处是灵活,可以自动监听所有用到的响应式数据,不需要单独指定,而且可以结合onInvalidate处理异步操作的旧值;坏处是第一次会执行一次,可能会有不必要的副作用(可以用flush: 'post'或者用一个flag变量来控制第一次不执行),而且需要手动维护旧值缓存,容易出错。

监听ref定义的数组或对象:同样要注意引用问题!

刚才讲的都是直接监听整个reactive对象的情况,其实监听ref定义的数组或对象,也会遇到同样的问题——如果我们直接修改数组的元素或者对象的属性(比如用push、pop、shift、unshift、splice,或者直接给obj.key赋值),那ref.value的引用地址是没有改变的,这时候watch默认的浅监听是检测不到变化的,就算检测到了(比如加上deep: true),新旧值还是同一个引用,拿不到旧值。

那怎么解决呢?其实和直接监听整个reactive对象的方法一样,要么用函数返回值的形式加getter里的深拷贝,要么分别监听数组的长度或者对象的属性,要么用watchEffect加手动缓存。

不过这里有个特殊情况:如果我们直接给ref.value重新赋值一个新的数组或对象(比如userRef.value = { name: '李四' }),那引用地址就变了,这时候watch默认的浅监听就能检测到变化,而且第二个形参就是旧的ref.value引用,拿到的就是真正的旧值,不需要深拷贝。

举个例子对比一下:

<script setup>
import { ref, watch } from 'vue';
// 直接修改属性的情况
const userRef1 = ref({ name: '张三', age: 25 });
watch(userRef1, (newVal, oldVal) => {
  console.log('userRef1新值:', newVal);
  console.log('userRef1旧值:', oldVal);
  console.log('userRef1新旧值是否相等:', newVal === oldVal);
}, { deep: true }); // 必须加deep才能检测到属性变化
// 直接重新赋值的情况
const userRef2 = ref({ name: '张三', age: 25 });
watch(userRef2, (newVal, oldVal) => {
  console.log('userRef2新值:', newVal);
  console.log('userRef2旧值:', oldVal);
  console.log('userRef2新旧值是否相等:', newVal === oldVal);
}); // 不需要加deep
const updateUser1 = () => {
  userRef1.value.name = '李四'; // 直接修改属性
};
const updateUser2 = () => {
  userRef2.value = { name: '李四', age: 25 }; // 直接重新赋值
};
</script>
<template>
  <div>
    <p>userRef1姓名:{{ userRef1.name }}</p>
    <button @click="updateUser1">修改userRef1(直接改属性)</button>
    <hr />
    <p>userRef2姓名:{{ userRef2.name }}</p>
    <button @click="updateUser2">修改userRef2(直接重新赋值)</button>
  </div>
</template>

点击第一个按钮,userRef1的新旧值一样;点击第二个按钮,userRef2的新旧值完全不同,而且引用也不一样,完美!

所以如果你的业务场景允许直接重新赋值数组或对象,那这是最简单的获取旧值的方法,不需要深拷贝,性能也最好。

Vue3 watch获取旧值的最佳实践

最后咱们来总结一下,不同的场景应该用哪种方法:

  1. 监听单个原始值类型(ref定义的或者reactive点出来的):直接用第二个形参,最简单。
  2. 监听ref定义的数组或对象,且业务场景允许直接重新赋值:直接重新赋值,不需要深拷贝,不需要deep,性能最好。
  3. 监听整个reactive对象,或者ref定义的数组/对象且业务场景不允许直接重新赋值
    • 如果属性不多,且需要精准监听:分别监听每个属性。
    • 如果属性很多,或者需要一次性监听所有属性:用函数返回值的形式加getter里的深拷贝(优先用structuredClone(),其次用lodash.cloneDeep(),最后用JSON.parse(JSON.stringify()))。
  4. 需要自动监听所有用到的响应式数据,或者需要结合异步操作:用watchEffect加onInvalidate加手动缓存旧值。

好了,关于Vue3 watch获取旧值的所有内容就讲完了,希望能帮到你!要是还有什么问题,欢迎在评论区留言讨论。

版权声明

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

热门