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

如何在Vue3里同时监听多个ref数据?有没有什么坑要避开?

terry 1小时前 阅读数 23 #Vue

前阵子帮朋友改小商城的购物车模块,遇到个棘手的事儿:要同步判断“商品选择状态是否全选”和“计算实时总价”,两个逻辑都依赖多个商品的selectedcount这俩ref数组——直接分开写watch会有重复遍历的问题,效率太低,而且有时候数据更新不同步会导致全选框和总价飘,后来翻了Vue官方的文档再实际踩了踩坑,终于搞明白几种好用的监听方式,还有容易忽略的细节,今天就整理出来,分享给同样在做Vue3项目的小伙伴。

把多个ref打包成数组传给watch

这应该是最常用、最容易想到的方法了,就像你把需要同步检查的快递单子叠成一摞给快递员,他会一次性看完所有单子,有一个更新就喊你,具体写法很简单,把要监听的ref变量放到一个普通数组里作为watch的第一个参数就行。

举个刚才说的购物车小例子吧,假设我们有三个ref:cartList(商品列表)、useCoupon(是否用优惠券,布尔值)、couponValue(优惠券金额),需要这三个里任意一个变了都重新算总价:

import { ref, watch } from 'vue'
const cartList = ref([
  { id: 1, name: '无线耳机', price: 299, count: 2, selected: true },
  { id: 2, name: '手机壳', price: 39, count: 1, selected: false }
])
const useCoupon = ref(false)
const couponValue = ref(50)
const totalPrice = ref(0)
// 打包成数组监听
watch([cartList, useCoupon, couponValue], () => {
  let sum = 0
  cartList.value.forEach(item => {
    if (item.selected) sum += item.price * item.count
  })
  if (useCoupon.value && sum > 100) {
    sum = Math.max(0, sum - couponValue.value)
  }
  totalPrice.value = sum
}, { immediate: true }) // 加上immediate初始化也触发,不然页面刚加载总价是0哦

这里要注意的是,数组里的每个ref,不管是基本类型还是对象/数组类型,默认都是“浅监听”——比如cartList里如果只改了某个商品的count属性,Vue3默认是不会触发监听的,除非你手动开启deep: true,不过刚才的例子里我把cartList整个对象数组放进去,如果要监听内部属性变化,必须加第三个配置对象里的deep: true,对吧?

对了,开启deep之后的回调函数里,第一个参数是监听数组里每个数据变化后的值组成的新数组,第二个参数是变化前的旧值组成的旧数组——不过旧数组里如果是对象/数组类型的ref,旧值其实和新值引用的是同一个堆内存地址,没用deep对比的话根本拿不到真正的历史状态,这个后面讲坑的时候会再提。

用computed先把多个ref关联起来再监听

这种方式适合需要依赖多个ref先做一步简单筛选/计算,然后只对筛选后的结果变化做响应的场景,比如购物车全选框,我们其实只关心“已选商品数”和“总商品数”是否相等,不用每次商品的名字、没选中的count变了都触发判断。

那怎么写呢?先写一个computed属性把已选商品数和总商品数关联起来,比如直接返回已选数 === 总商品数这个布尔值,然后watch这个computed就行。

import { ref, computed, watch } from 'vue'
// 复用上面的cartList
const isAllSelected = ref(false)
// 先computed筛选出需要的关联逻辑
const allSelectedCheck = computed(() => {
  if (cartList.value.length === 0) return false
  return cartList.value.every(item => item.selected)
})
// 只监听computed的变化,效率更高
watch(allSelectedCheck, (newVal) => {
  isAllSelected.value = newVal
}, { immediate: true })

这种写法比直接监听整个cartList数组要省资源,因为computed本身有缓存机制,只有它内部依赖的那些ref(这里是每个item的selected)变了,computed才会重新计算,然后才会触发watch——刚才说的“商品名字、没选中的count变了”的情况,allSelectedCheck根本不会动,watch也就白嫖似的不干活,项目大的时候这点优化还是挺有用的。

单独设置每个ref的监听逻辑,再用防抖/节流避免重复触发?

其实不太推荐这种方式,但如果是那种多个ref更新逻辑完全独立,只有极个别场景需要临时触发同一个回调的情况,可以试试,不过刚才说的购物车场景肯定不适合,分开写的话,比如用户快速连点两次“全选”,又改了一次“某个商品的count”,可能会连续三次重新计算总价,体验不好。

不过为了覆盖全面,还是提一下大概的思路:比如可以定义一个公共的updateTotal函数,然后每个ref单独写watch的时候都调用它,再给这个函数加个lodash的debounce或者throttle,但真的不如前两种方式优雅,而且要额外引入工具库(虽然VueUse也有现成的,但还是没必要)。

踩过的三个大坑,一定要记牢

刚才说过要讲坑,这三个都是我实际写代码或者帮别人debug遇到的,特别是第一个,新手特别容易踩。

第一个坑:对象/数组类型的ref,浅监听没用,深监听旧值拿不到

刚才第一种方式里提过,数组里的对象/数组类型ref,默认是“浅监听”——Vue3只会检查这个ref的引用地址有没有变,比如你直接把cartList.value = []或者cartList.value.push(...)(哦不对,Vue3里ref包裹的数组,push、pop这种方法会自动触发响应式更新,引用地址其实没变?不对不对,等下我再确认一下刚才的代码逻辑——哦对,刚才的购物车例子里,如果只改cartList.value[0].count = 3,因为引用地址没变,默认的watch是不会触发的,必须加deep: true

但是加了deep: true之后,回调函数的第二个参数(旧值数组里的对象/数组项)和第一个参数(新值数组里的)引用地址是一样的,所以你直接打印新旧值对比,会发现它们是一样的!那怎么拿到真正的历史状态呢?

可以用computed+JSON.parse(JSON.stringify())的方式浅拷贝一下再监听,比如刚才的cartList:

const cartListCopy = computed(() => JSON.parse(JSON.stringify(cartList.value)))
watch([cartListCopy, useCoupon, couponValue], (newVals, oldVals) => {
  console.log('旧的购物车:', oldVals[0]) // 现在能拿到真正的旧值了
  // 计算总价的逻辑不变
}, { immediate: true })

不过要注意,JSON深拷贝有局限性,比如不能拷贝函数、正则表达式、Symbol这些特殊类型,如果你的数据里有这些,就得用lodash的cloneDeep或者自己写递归深拷贝了。

第二个坑:immediate和deep的组合使用要注意时机

比如刚才的allSelectedCheck例子,如果同时加了immediate: truedeep: true(虽然这个例子不需要deep),初始化的时候会触发一次watch,这个没问题,但如果你的初始化逻辑依赖于DOM渲染后的状态,比如要修改某个DOM元素的样式,这时候immediate触发的时候DOM可能还没挂载,得用nextTick包一下初始化的逻辑。

第三个坑:监听多个基本类型ref的时候,不要随便用箭头函数的简写

哦这个是语法坑,不是Vue的坑,但新手也容易写错,比如刚才的例子里,如果把第一个参数写成() => [cartList.value, useCoupon.value, couponValue.value],这其实是对的,相当于把ref解包后的值打包成一个新的数组传给watch,这时候Vue会监听这个函数返回值的变化——不管是基本类型还是对象类型,都相当于“自定义监听源”,但如果是直接写[cartList, useCoupon, couponValue],Vue会自动解包ref,不需要.value,两种写法都是可以的,但要注意不要混用,比如写成[cartList.value, useCoupon, couponValue],那第一个项就变成了普通的基本类型/对象值,不是响应式的了,后面两个useCoupon和couponValue虽然是ref,但Vue可能会检测出第一个项的问题,导致监听失效或者报错。

总结一下三种方式的适用场景

  • 打包成数组直接watch:适合所有需要同步监听多个ref的场景,不管有没有依赖逻辑,但如果依赖逻辑比较复杂或者只需要监听部分属性变化,效率不如第二种。
  • computed关联后再watch:适合需要先对多个ref做一步筛选/计算,只对结果变化做响应的场景,效率最高,推荐优先使用。
  • 单独watch加防抖/节流:不推荐常规使用,只有极个别独立更新但需要临时同步的场景才用。

最后再给大家留个小作业吧:如果你的项目里有一个表单,包含姓名、手机号、邮箱三个ref,需要三个都填写正确(正则验证通过)之后才能点击提交按钮,你会用哪种方式呢?欢迎在评论区留言讨论哦!

版权声明

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

热门