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

Vue3怎么同时watch多个响应式属性?不同场景的写法和避坑指南全整理

terry 1小时前 阅读数 14 #Vue

刚开始用Vue3的组合式API写项目时,我经常碰到需要监听多个变量联动变化的需求:比如电商网站里,用户切换商品SKU的颜色和尺寸时,要同时触发价格更新、库存检查和SKU图切换;或者后台管理系统中,筛选条件的时间范围、状态下拉、关键词输入任意一个变了,都要重新请求列表接口,这时候如果单独写好几个watch函数,不仅代码冗余,还可能因为触发顺序的问题出bug——比如先更新了状态,还没等到时间范围的新值就去调接口了,今天就把我摸索出来的所有实用写法、适用场景和踩过的坑,一次性说清楚。

基础写法:用数组包多个源

最直接的同时监听多个属性的方法,就是把这些源放在一个数组里传给watch的第一个参数,不管是ref、reactive的属性、计算属性还是getter函数返回的响应式值,都可以混着放进去。

举个最基础的电商SKU例子:假设页面有colorRef(商品颜色)、sizeRef(商品尺寸)两个ref,还有对应的reactive对象skuObj,属性是color和size,同时还有个根据这俩算出来的computed:isStockAvailableRef(计算是否有库存),现在想把这四个混着监听,每次变了都打印下所有值。

那代码可以这么写:

import { ref, reactive, computed, watch } from 'vue';
export default {
  setup() {
    // 定义几个不同类型的源
    const colorRef = ref('红色');
    const sizeRef = ref('M');
    const skuObj = reactive({ color: '红色', size: 'M' });
    const isStockAvailableRef = computed(() => 
      colorRef.value === '红色' && sizeRef.value === 'L'
    );
    // 数组混放监听
    watch(
      [colorRef, sizeRef, () => skuObj.color, isStockAvailableRef],
      ([newColor, newSize, newSkuColor, newStockVal], [oldColor, oldSize, oldSkuColor, oldStockVal]) => {
        console.log('新值数组:', [newColor, newSize, newSkuColor, newStockVal]);
        console.log('旧值数组:', [oldColor, oldSize, oldSkuColor, oldStockVal]);
        // 这里放你的业务逻辑,比如调价格接口
      }
    );
    return { colorRef, sizeRef, skuObj, isStockAvailableRef };
  }
};

这里有个小细节要注意:如果数组里直接放reactive对象本身,比如[skuObj],那默认是浅监听——只有当整个对象被替换(比如skuObj = reactive(...))才会触发,对象内部的属性变化是不会响应的,如果想监听reactive的整个对象变化,要么给watch加第三个参数{ deep: true },要么像例子里那样,把内部属性拆成getter函数() => skuObj.color一个个单独列出来。

基础写法的适用场景

这种数组写法最常用,适合所有需要“任意一个源变了就执行回调”的场景,而且回调里能拿到所有源的新值和旧值,逻辑可以写得很灵活,比如刚才提到的SKU场景,拿到新旧尺寸后,还可以判断是不是从缺货变到有货,要不要加个小弹窗提示用户。

进阶写法:用watchEffect结合解构,自动追踪依赖

如果你觉得写数组太麻烦,或者不确定到底哪些源会用到,可以试试watchEffect,它的核心逻辑是“第一次执行回调时自动收集所有用到的响应式依赖,之后只要有一个依赖变了,就重新执行回调”。

那刚才的SKU例子用watchEffect怎么写呢?可以先解构出需要的值,或者直接在回调里用:

import { ref, reactive, computed, watchEffect } from 'vue';
export default {
  setup() {
    const colorRef = ref('红色');
    const sizeRef = ref('M');
    const skuObj = reactive({ color: '红色', size: 'M' });
    const isStockAvailableRef = computed(() => 
      colorRef.value === '红色' && sizeRef.value === 'L'
    );
    // 直接用值,自动追踪
    watchEffect(() => {
      console.log('当前用到的源:');
      console.log('colorRef.value', colorRef.value);
      console.log('sizeRef.value', sizeRef.value);
      console.log('skuObj.color', skuObj.color);
      console.log('isStockAvailableRef.value', isStockAvailableRef.value);
      // 业务逻辑
    });
    return { colorRef, sizeRef, skuObj, isStockAvailableRef };
  }
};

这里有没有发现和基础写法的区别?第一,watchEffect不需要传依赖数组;第二,第一次渲染组件时它会自动执行一次(watch默认只有源变了才执行,除非加第三个参数{ immediate: true });第三,它的回调函数里拿不到旧值——只能拿到当前的新值;第四,如果想停止监听,需要提前存下来它的返回值,然后调用:

const stopWatchEffect = watchEffect(() => { /* ... */ });
// 比如组件卸载时停止,不过组合式API里setup返回的stop函数会自动在onUnmounted时执行,不用手动写
onUnmounted(() => stopWatchEffect());

进阶写法的适用场景和注意事项

watchEffect适合业务逻辑里用到的依赖源比较多、动态性强的场景——比如后台管理系统的复杂筛选,筛选条件可能根据权限动态添加或者隐藏,用watchEffect就不用每次修改筛选框的配置时,还要去改依赖数组。

但也要注意它的几个“坑”:

  1. 不能拿旧值:如果你的业务逻辑需要对比新旧值,比如判断关键词输入的变化幅度,或者统计筛选条件切换的次数,那watchEffect就不行了,得用watch。
  2. 自动收集依赖的范围:只有在回调函数同步执行的代码里用到的响应式值才会被追踪,异步代码里的不算,比如下面这个代码:
    watchEffect(() => {
      // 这里的colorRef会被追踪,变了就会重新调接口
      const color = colorRef.value;
      setTimeout(() => {
        // 这里的sizeRef不会被追踪,变了不会触发watchEffect
        console.log(sizeRef.value);
      }, 1000);
    });

    如果异步代码里也需要追踪某个源,那得把那个源移到同步代码里先“碰一下”,比如const size = sizeRef.value;

  3. 第一次默认执行:有时候我们第一次渲染时不想调接口,比如关键词输入为空时,调接口会返回所有数据,但我们想等用户输入至少3个字符再调,这时候要么在回调开头加个判断逻辑,要么用watch加immediate: true然后手动控制是否执行业务。

特殊写法:用getter函数返回一个包含多个属性的对象

这种写法其实是基础数组写法的变形——不是把多个源放在数组里,而是放在一个对象里,然后用getter函数返回这个对象,它的好处是回调里拿到的新值和旧值也是对象,属性名对应着你定义的源,不用记数组的索引顺序,代码可读性更高。

还是刚才的SKU例子,改成对象写法:

import { ref, reactive, computed, watch } from 'vue';
export default {
  setup() {
    const colorRef = ref('红色');
    const sizeRef = ref('M');
    const skuObj = reactive({ color: '红色', size: 'M' });
    const isStockAvailableRef = computed(() => 
      colorRef.value === '红色' && sizeRef.value === 'L'
    );
    // 用getter返回包含多个属性的对象
    watch(
      () => ({
        color: colorRef.value,
        size: sizeRef.value,
        skuColor: skuObj.color,
        isStock: isStockAvailableRef.value
      }),
      (newVals, oldVals) => {
        console.log('新值对象:', newVals);
        console.log('旧值对象:', newVals.color); // 直接用属性名,不用索引0
        console.log('旧值对象的旧尺寸:', oldVals.size);
        // 业务逻辑
      }
    );
    return { colorRef, sizeRef, skuObj, isStockAvailableRef };
  }
};

这里又有个和基础数组写法类似的细节:getter返回的对象本身是普通对象,所以默认也是浅监听——只有当对象的属性值变了(比如colorRef.value从红色变成蓝色)才会触发,如果属性值是对象或者数组,内部变化默认不触发,比如把skuObj整个放进去:

// 这样默认不会监听skuObj内部的color和size变化
() => ({
  color: colorRef.value,
  sku: skuObj
})

如果想监听skuObj内部的变化,要么加{ deep: true },要么把skuObj拆成内部属性。

特殊写法的适用场景

这种对象getter写法最适合依赖源比较多(超过3个)的场景——数组索引记起来太麻烦,用对象属性名一眼就能看明白哪个新值对应哪个旧值,比如刚才提到的复杂后台筛选,筛选条件有10个,用对象写法的话,回调里的逻辑清晰得多。

避坑指南:这5个点90%的新手都会踩

刚才写每种写法的时候,其实已经提到了一些小细节,现在把它们整理成5个最容易踩的“大坑”,大家一定要注意:

避坑1:reactive对象直接传给watch的第一个参数

很多新手会这么写:

const skuObj = reactive({ color: '红色', size: 'M' });
watch(skuObj, (newVal, oldVal) => { /* ... */ });

以为这样就能监听color和size的变化,但实际上,watch默认是按值比较监听源的——对于普通对象/数组,是浅比较;对于ref,是比较value;对于getter函数返回的值,也是浅比较,而reactive对象本身是一个Proxy代理,它的引用地址是不会变的,所以直接传reactive对象的话,除非你把整个对象替换掉(比如skuObj = reactive(...),但reactive定义的对象不能直接替换,否则会失去响应式),否则永远不会触发watch。

正确的做法有两个:

  1. 拆成getter函数:() => skuObj.color() => skuObj.size,放在数组里。
  2. { deep: true }:但要注意,如果reactive对象很大很深,加deep会有性能问题,因为每次内部属性变化,Vue都要递归遍历整个对象比较新旧值。

避坑2:ref定义的数组/对象直接修改内部元素,watch没加deep

和避坑1类似,ref定义的数组/对象,比如const listRef = ref([1,2,3]),如果直接修改内部元素:listRef.value.push(4),因为listRef.value的引用地址没变,所以默认的watch也不会触发。

正确的做法:

  1. { deep: true }
  2. 或者重新赋值整个数组/对象:listRef.value = [...listRef.value, 4](数组),listRef.value = { ...listRef.value, name: '新名字' }(对象)——这种方法不用加deep,性能更好,但如果数组/对象很大,重新赋值的内存消耗会比直接修改大一点,大家可以根据实际情况选择。

避坑3:watchEffect的异步依赖问题

刚才已经举过例子了,再强调一遍:只有在watchEffect回调函数同步执行的代码里用到的响应式值才会被追踪,异步代码里的不算,如果异步代码里需要用到某个源,一定要在同步代码里先访问它一下,“碰一碰”让Vue知道这是个依赖。

避坑4:忘记加immediate,第一次渲染时业务逻辑不执行

watch默认只有当监听源发生变化时才会执行回调,第一次渲染组件时不会执行,但有些场景第一次渲染时就需要执行,比如SKU页面,刚打开就要显示默认颜色和尺寸的价格、库存;或者后台管理系统,刚打开就要显示默认筛选条件的列表。

这时候只要给watch加第三个参数{ immediate: true }就行:

watch(
  [colorRef, sizeRef],
  (newVals, oldVals) => {
    // 第一次执行时,oldVals是undefined或者null?注意这里哦!
    if (oldVals) {
      // 有旧值,说明是用户切换的
    }
    // 业务逻辑
  },
  { immediate: true }
);

这里还有个小细节:加了immediate后,第一次执行回调时,旧值数组(或者旧值对象)会是全undefined或者全null?不对,Vue3.3+之前是全undefined,Vue3.3+之后改成了全null?或者我记错了?其实不管是啥,加个判断逻辑就行,比如if (oldVals.every(val => val !== undefined))(数组写法),或者if (oldVals.color !== undefined)(对象写法),就能区分是第一次执行还是用户切换触发的。

避坑5:频繁触发导致的性能问题(防抖节流)

比如关键词输入的场景,用户每敲一个字就触发watch,然后就去调接口,这样会给服务器造成很大的压力,也会让页面出现卡顿的加载动画,这时候就需要给watch加防抖——等待用户停止输入一段时间(比如500毫秒)后,再去调接口。

Vue3的组合式API里没有内置防抖节流函数,但我们可以自己写一个简单的,或者用第三方库比如lodash的debounce和throttle。

用lodash debounce的例子:

import { ref, watch } from 'vue';
import debounce from 'lodash/debounce';
export default {
  setup() {
    const keywordRef = ref('');
    const statusRef = ref('all');
    // 定义防抖后的业务函数
    const fetchList = debounce((keyword, status) => {
      console.log('调接口,参数:', keyword, status);
      // 这里写真实的接口请求代码
    }, 500);
    // 监听关键词和状态,调用防抖函数
    watch(
      [keywordRef, statusRef],
      ([newKeyword, newStatus]) => {
        fetchList(newKeyword, newStatus);
      }
    );
    // 组件卸载时要取消防抖,防止内存泄漏
    onUnmounted(() => {
      fetchList.cancel();
    });
    return { keywordRef, statusRef };
  }
};

这里一定要注意:组件卸载时要调用防抖函数的cancel方法,否则防抖函数里的定时器还在运行,可能会造成内存泄漏,如果是自己写的防抖函数,也要记得在onUnmounted里清除定时器。

不同场景选哪种写法?

最后给大家整理一张“选型表”,方便大家快速找到适合自己的写法:

场景描述 推荐写法 核心优势
需要对比新旧值,依赖源≤3个 基础数组写法 代码简单,逻辑清晰
需要对比新旧值,依赖源>3个 对象getter写法 不用记数组索引,可读性高
不需要对比新旧值,依赖源多且动态 watchEffect写法 自动追踪依赖,不用手动维护数组
不需要对比新旧值,依赖源固定且第一次要执行 watch数组+immediate写法 或者 watchEffect+开头判断 两种都可以,看个人习惯
频繁触发的场景(比如关键词输入、窗口 resize) 任意watch/watchEffect写法+防抖节流 提高性能,减少服务器压力

好了,以上就是我关于Vue3同时watch多个响应式属性的所有整理,都是我在项目里踩过坑、试过多次才总结出来的实用内容,如果大家还有其他问题,或者有更好的写法,欢迎在评论区留言交流!

版权声明

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

热门