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

Vue3里watch居然不能直接监听Set?常见踩坑、核心原理与3种实用替代方案全解析

terry 2小时前 阅读数 23 #Vue
文章标签 Vue3Set监听

最近在做标签批量筛选、购物车商品收藏这类项目时,不少刚转Vue3的朋友都踩过一个坑:明明给Set加了响应式,用watch监听它的时候,不管是add、delete还是clear,控制台打印都好好的,可watch回调就是死活不触发,甚至有时候还会报“无效的侦听源”这种摸不着头脑的错,今天咱们就把这个问题聊透,从踩坑复盘、Vue3响应式的底层差异,到能覆盖80%开发场景的替代方案,连性能优化的小细节都给你扒得明明白白。

为什么我明明用了ref/reactive包Set,watch还是监听不到?

要解决这个问题,得先从Vue3对Set的响应式处理逻辑说起,不能上来就甩方案——知其然更要知其所以然,以后碰到类似的问题(比如Map)你也能自己搞定。

先踩个经典的坑:用watch直接侦听整个ref(reactive的Set属性)

很多朋友刚转Vue3时,习惯了用watch监听数组的push/pop这些方法,心想Set也是集合,肯定差不多,于是就写出了这样的代码:

import { ref, watch, onMounted } from 'vue'
const favoriteTags = ref(new Set(['前端', 'Vue', 'JavaScript']))
// 试图直接监听整个Set
watch(favoriteTags, (newVal, oldVal) => {
  console.log('收藏标签变了!新标签:', [...newVal])
  console.log('旧标签:', [...oldVal])
})
onMounted(() => {
  // 模拟1秒后添加新标签
  setTimeout(() => {
    favoriteTags.value.add('React')
    console.log('打印下当前Set值看看:', [...favoriteTags.value])
  }, 1000)
})

如果你把这段代码复制到Vue3的项目里跑,控制台只会输出“打印下当前Set值看看:['前端', 'Vue', 'JavaScript', 'React']”,watch的回调连影儿都没有。

踩坑后的错误尝试:加deep: true

这时候不少人会想到:哦,是不是Set是个引用类型,得用deep侦听深层变化?那好,给watch加个deep: true试试:

watch(favoriteTags, (newVal, oldVal) => {
  // 回调逻辑不变
}, { deep: true })

结果呢?还是没触发!这是为啥?咱们平时用ref/reactive包数组,push/pop之后加不加deep都能触发(如果是直接替换整个数组不加也能,只改内部索引的话Vue3 3.2+用reactive可以直接监听到,ref得加.value但也不需要deep,除非数组里套了对象/数组这种多层结构),怎么到Set这儿连deep都没用了?

核心原因:Vue3对Set/Map的响应式处理是“代理方法”,而非“递归追踪内部值”

这就得对比Vue3对“普通引用类型(Object/Array)”和“ES6内置集合类型(Set/Map/WeakSet/WeakMap)”的响应式实现差异了——这部分内容虽然有点底层,但只要搞懂,以后再也不会踩类似的坑。

先回忆下Vue3的响应式核心原理:用Proxy代理目标对象,然后通过get/set/deleteProperty这些拦截器,在读取/修改/删除属性时,收集依赖(track)和触发更新(trigger)。

对普通Object/Array的处理

比如你有一个const state = reactive({ tags: ['前端', 'Vue'] }),当你访问state.tags时,Proxy会触发get拦截器,把state.tags作为依赖收集到它的父级state的依赖列表里;当你执行state.tags.push('React')时,数组的push方法实际上会先读取数组的length属性(get拦截器触发),然后修改索引2的位置(set拦截器触发),最后修改length属性(set拦截器再次触发)——不管是哪一步,只要触发了父级依赖对应的更新逻辑,就能让watch或者模板里的视图更新。

如果是const tags = ref(['前端', 'Vue']),本质上是ref内部把这个数组传给了reactive,生成一个proxy对象作为ref.value的值,后续的逻辑和reactive一样,只是多了一层对ref.value的get/set拦截。

那为什么直接侦听整个普通引用类型的ref/reactive,加deep: true有用?因为deep侦听的核心逻辑是:在track阶段,递归遍历整个目标对象/数组,把所有内部的属性、子数组的元素都作为依赖收集起来;在trigger阶段,只要有任何一个依赖的属性变化,就会触发回调。

对ES6内置集合类型的处理

但Set/Map这些集合类型不一样,它们不是通过“索引/键名访问内部值”来实现操作的,而是通过自身的方法(add/delete/clear/get/set/has/size等),如果Vue3用处理普通Object/Array的方式直接代理Set/Map,会有两个问题:

  1. 直接访问内部值是不可能的——Set内部的元素没有键名,数组的push虽然也用到了length,但本质上还是索引操作,Set的add完全不依赖索引;
  2. 性能问题太严重——如果有一个包含10000个元素的Set,用deep侦听要递归遍历10000次,太浪费资源了。

那Vue3是怎么处理的呢?它采用了一种“代理特定方法”的思路:

  1. 先给Set/Map生成一个Proxy对象;
  2. 在get拦截器里判断:如果访问的是Set/Map的方法(add/delete/clear/size等)或者hasOwnProperty、toString这些特殊方法,就返回一个绑定了当前Proxy的函数
  3. 当调用这些绑定后的方法时,比如执行favoriteTags.value.add('React'),绑定函数会先调用原始Set的add方法,然后再手动触发更新(trigger),同时也会在读取size、has这些属性时手动收集依赖(track)。

重点来了!这个手动收集依赖和触发更新,只和“当前Set/Map的实例本身”有关,和内部的元素没有任何关系,也就是说,Vue3不会递归遍历Set内部的元素去收集依赖,哪怕你加了deep: true也没用——因为Set内部没有可枚举的“键名”给你递归遍历啊!

那为什么我们打印ref(reactive(Set))的内容,模板里用v-for也能正常显示呢?哦,模板里用v-for遍历Set的时候,本质上是先调用了Set的values()方法或者Symbol.iterator迭代器(和调用size、has一样会触发get拦截器收集依赖),当调用add/delete/clear时手动触发的更新,刚好能触发这个模板依赖的重新执行,所以视图会变;但watch侦听的是整个Set的ref/reactive,它的track阶段不是通过调用values()/size/has来收集的,而是试图递归遍历内部元素或者检查引用地址——引用地址没变(因为add/delete是原地修改),内部元素又没有可追踪的依赖,所以watch自然不会触发。

那报“无效的侦听源”又是怎么回事?

还有一种情况,有些朋友可能写错了侦听源,

// 直接侦听原始Set
const favoriteTags = new Set(['前端', 'Vue'])
watch(favoriteTags, () => {})

这时候Vue3会直接报错“Invalid watch source: Set(2) A watch source can only be a ref, reactive object, getter/effect function, or an array of these types.”,意思就是说,watch的侦听源只能是ref、reactive对象、getter/effect函数,或者这些类型的数组——原始Set不在范围内,肯定不行。

3种能覆盖80%开发场景的Vue3监听Set变化的实用方案

搞懂了原理,接下来就是解决方案了,咱们从“最简单无脑”到“性能最优、功能最全”,给大家介绍3种方案,每一种都有对应的适用场景,你们可以根据自己的项目情况来选。

把Set转成数组,用computed包装后再监听

这应该是最简单的一种方案了——既然Vue3对数组的监听这么成熟,那我们干脆把Set实时转成数组,然后用computed把这个数组包起来,最后直接监听computed返回的值就行了。

具体实现代码

import { ref, computed, watch, onMounted } from 'vue'
const favoriteTagsSet = ref(new Set(['前端', 'Vue', 'JavaScript']))
// 用computed把Set实时转成数组
const favoriteTagsArr = computed(() => [...favoriteTagsSet.value])
// 直接监听computed返回的数组
watch(favoriteTagsArr, (newVal, oldVal) => {
  console.log('收藏标签变了!新标签:', newVal)
  console.log('旧标签:', oldVal)
})
onMounted(() => {
  // 模拟1秒后添加新标签
  setTimeout(() => {
    favoriteTagsSet.value.add('React')
  }, 1000)
  // 模拟2秒后删除旧标签
  setTimeout(() => {
    favoriteTagsSet.value.delete('JavaScript')
  }, 2000)
  // 模拟3秒后清空标签
  setTimeout(() => {
    favoriteTagsSet.value.clear()
  }, 3000)
})

这段代码跑起来之后,控制台会依次输出添加、删除、清空后的标签变化,完美解决了watch不触发的问题。

方案一的适用场景

这种方案适合Set内部元素数量不多(一般100个以内),而且需要获取新旧值的具体差异(比如哪些元素加了、哪些元素删了)的场景——因为computed返回的是一个全新的数组,watch的newVal和oldVal可以直接用来做对比(比如用lodash的difference方法,或者自己写个循环)。

方案一的注意事项

  1. 性能问题:如果Set内部的元素特别多(比如1000个以上),每次Set变化都会触发computed重新执行,生成一个全新的数组,这会占用额外的内存和CPU资源;
  2. 新旧值的引用地址:因为computed每次都返回新数组,所以newVal和oldVal的引用地址永远不一样,哪怕你只是不小心触发了一次Set的操作(比如add了一个已经存在的元素),watch也会触发——不过这个问题可以通过在watch里加个逻辑判断,对比newVal和oldVal的JSON.stringify或者长度+元素的has关系来解决;
  3. 如果Set内部的元素是引用类型(比如对象),直接转成数组的computed还是不会自动追踪对象内部的属性变化——除非你给watch加deep: true,但这时候又回到了之前的性能问题,而且computed本身也不会自动触发更新(因为computed只追踪数组的引用地址或者内部的索引/键名,不会追踪数组里对象的内部属性,除非你在computed里读取了这些属性)。

用watchEffect监听Set的size属性或者遍历过程

原理里咱们说过,Vue3在读取Set的size、has、values()、entries()、Symbol.iterator这些方法或者属性时,会手动收集依赖;在调用add/delete/clear这些方法时,会手动触发更新,那我们能不能利用这一点,直接用watchEffect来监听这些读取操作?

具体实现代码1:监听size属性

import { ref, watchEffect, onMounted } from 'vue'
const favoriteTagsSet = ref(new Set(['前端', 'Vue', 'JavaScript']))
// 用watchEffect监听size属性的读取
watchEffect(() => {
  console.log('当前收藏标签的数量:', favoriteTagsSet.value.size)
  // 在这里写你的更新逻辑,比如调用后端接口同步标签
})
onMounted(() => {
  // 模拟1秒后添加新标签
  setTimeout(() => {
    favoriteTagsSet.value.add('React')
  }, 1000)
  // 模拟2秒后删除旧标签
  setTimeout(() => {
    favoriteTagsSet.value.delete('JavaScript')
  }, 2000)
  // 模拟3秒后添加已经存在的标签
  setTimeout(() => {
    favoriteTagsSet.value.add('Vue') // 注意:这次size不会变,所以watchEffect不会触发
  }, 3000)
  // 模拟4秒后清空标签
  setTimeout(() => {
    favoriteTagsSet.value.clear()
  }, 4000)
})

这段代码跑起来之后,控制台会输出添加后的数量(4)、删除后的数量(3)、清空后的数量(0),但添加已经存在的Vue标签时,size还是3,所以watchEffect不会触发——这其实是个优点,因为添加已存在的元素本来就不算变化嘛。

具体实现代码2:监听遍历过程

如果你的更新逻辑需要用到Set内部的所有元素,而不仅仅是数量,那可以在watchEffect里遍历一下Set,

import { ref, watchEffect, onMounted } from 'vue'
const favoriteTagsSet = ref(new Set(['前端', 'Vue', 'JavaScript']))
// 模拟一个存储标签字符串的变量
let currentTagsStr = ''
// 用watchEffect监听遍历过程
watchEffect(() => {
  currentTagsStr = ''
  // 遍历Set,收集依赖
  for (const tag of favoriteTagsSet.value) {
    currentTagsStr += tag + '、'
  }
  // 去掉最后一个顿号
  currentTagsStr = currentTagsStr.slice(0, -1)
  console.log('当前收藏标签:', currentTagsStr)
  // 在这里写你的更新逻辑,比如渲染到某个非响应式的DOM元素上
})
onMounted(() => {
  // 模拟操作和之前一样
})

这段代码的效果和方案一差不多,但不需要用computed包装,代码更简洁一点。

方案二的适用场景

这种方案适合Set内部元素数量不限(只要遍历过程不耗时太长),而且不需要获取新旧值的具体差异(或者可以自己在逻辑里保存旧值来对比)的场景——尤其是当你只需要知道Set有没有变化,或者只需要用到Set的最新值时,这种方案比方案一性能更好,因为它不需要每次都生成一个全新的数组。

方案二的注意事项

  1. 无法直接获取新旧值:watchEffect不像watch那样有newVal和oldVal参数,如果你需要对比新旧值,得自己在外面保存一个旧值变量,每次watchEffect执行时先把旧值存下来,再更新旧值;
  2. watchEffect的执行时机:watchEffect会在组件初始化时自动执行一次,如果你不需要初始化时的那次执行,可以用watchEffect的第二个参数配置{ flush: 'post' }或者{ flush: 'sync' },不过sync会影响性能,一般不推荐;如果你完全不想让它初始化时执行,可以用watch的getter函数形式,监听size属性或者遍历过程,这就是咱们接下来要讲的方案三。

用watch的getter函数形式,监听size属性或者遍历过程

方案三其实是方案二的“升级可控版”——它既保留了方案二不需要computed、不需要生成新数组的优点,又能像方案一那样获取新旧值,还能通过配置控制初始化时是否执行。

具体实现代码1:监听size属性,获取新旧值,不初始化执行

import { ref, watch, onMounted } from 'vue'
const favoriteTagsSet = ref(new Set(['前端', 'Vue', 'JavaScript']))
// 用watch的getter函数形式,监听size属性
watch(
  () => favoriteTagsSet.value.size,
  (newSize, oldSize) => {
    console.log('收藏标签数量变了!新数量:', newSize)
    console.log('旧数量:', oldSize)
    // 在这里写你的更新逻辑
  },
  {
    immediate: false // 不初始化时执行,默认是false,这里写出来是为了强调
  }
)
onMounted(() => {
  // 模拟操作和之前一样
})

这段代码跑起来之后,控制台只会输出添加、删除、清空后的数量变化,初始化时不会有任何输出——完美解决了watchEffect初始化执行的问题。

具体实现代码2:监听遍历过程,获取新旧值的Set实例,不初始化执行

如果你的更新逻辑需要用到新旧值的整个Set实例,而不仅仅是数量,那可以在getter函数里直接返回Set的ref.value,但这时候要注意——因为Set是原地修改的,ref.value的引用地址永远没变,所以watch的newVal和oldVal会是同一个对象!这时候怎么办呢?我们可以在getter函数里返回一个Set的浅拷贝,或者自己在外面保存旧值。

写法1:返回浅拷贝(需要自己处理性能问题)
import { ref, watch, onMounted } from 'vue'
const favoriteTagsSet = ref(new Set(['前端', 'Vue', 'JavaScript']))
watch(
  // 在getter函数里调用size或者遍历,收集依赖,然后返回浅拷贝
  () => {
    favoriteTagsSet.value.size // 或者 for...of 遍历一下,二选一就行
    return new Set(favoriteTagsSet.value) // 浅拷贝一个新的Set实例
  },
  (newSet, oldSet) => {
    console.log('收藏标签变了!新标签:', [...newSet])
    console.log('旧标签:', [...oldSet])
    // 在这里写你的更新逻辑
  },
  {
    immediate: false
  }
)
onMounted(() => {
  // 模拟操作和之前一样
})

这种写法的newVal和oldVal是两个不同的Set实例,可以直接用来对比,但每次Set变化都会生成一个新的浅拷贝Set实例,性能比方案二的watchEffect差一点,和方案一差不多。

写法2:自己保存旧值(性能最优)
import { ref, watch, onMounted } from 'vue'
const favoriteTagsSet = ref(new Set(['前端', 'Vue', 'JavaScript']))
// 自己保存一个旧值变量
let oldFavoriteTags = new Set(favoriteTagsSet.value)
watch(
  // 在getter函数里调用size或者遍历,收集依赖,但不返回新实例
  () => favoriteTagsSet.value.size,
  (newSize) => {
    const newFavoriteTags = favoriteTagsSet.value
    console.log('收藏标签变了!新标签:', [...newFavoriteTags])
    console.log('旧标签:', [...oldFavoriteTags])
    // 在这里写你的更新逻辑
    // 最后更新旧值变量
    oldFavoriteTags = new Set(newFavoriteTags)
  },
  {
    immediate: false
  }
)
onMounted(() => {
  // 模拟操作和之前一样
  // 注意:如果需要监听添加已存在元素的情况(虽然Set本身不认为这是变化),可以把getter函数改成返回[...favoriteTagsSet.value].join(',')
})

这种写法的性能是最优的——因为它只在需要的时候(更新旧值变量)才生成一个新的浅拷贝Set实例,平时只调用size属性收集依赖,不会占用额外的内存和CPU资源;而且可以自己控制是否监听添加已存在元素的情况——如果需要的话,把getter函数改成返回[...favoriteTagsSet.value].join(',')或者用其他方式生成一个唯一的字符串标识Set的内容就行,但这时候要注意,如果Set内部的元素是引用类型,join(',')可能无法正确标识内部属性的变化。

方案三的适用场景

这种方案适合所有场景——不管你是需要获取新旧值、不需要初始化执行、还是对性能有很高的要求,都可以用这种方案,只是需要根据具体情况调整getter函数和逻辑。

方案三的注意事项

  1. 如果Set内部的元素是引用类型,以上所有方案都无法自动追踪对象内部的属性变化——除非你给每个元素都加了ref/reactive,并且在getter函数里读取了这些元素的内部属性;
  2. 如果需要监听WeakSet/WeakMap的变化,以上所有方案都不适用——因为WeakSet/WeakMap没有size属性,也无法遍历,而且它们的内部元素是弱引用的,随时可能被垃圾回收器回收,所以Vue3也没有给它们做响应式处理。

性能优化的小细节:如何让监听Set变化的代码更高效?

虽然前面介绍的3种方案已经能解决大部分问题了,但如果你的Set内部元素特别多(比如10000个以上),或者Set的变化频率特别高(比如用户拖动标签时每10ms就变化一次),那还是得注意一下性能优化的小细节。

尽量避免用computed包装成数组,也避免在getter函数里每次都生成浅拷贝

如果不需要获取新旧值的具体差异,或者可以自己保存旧值,那尽量用方案二的watchEffect或者方案三的只监听size属性的写法——因为这两种写法不需要每次都生成新的数组或Set实例,能节省大量的内存和CPU资源。

如果必须用computed或者生成浅拷贝,尽量用惰性求值的方式

惰性求值就是“只有当真正需要的时候才计算”——比如在方案三里,只有当Set的size变化时,才生成旧值的浅拷贝,而不是每次getter函数执行时都生成。

如果Set内部的元素是引用类型,尽量只监听你需要的属性变化

比如你有一个const userTags = ref(new Set([{ id: 1, name: '前端', isActive: true }, { id: 2, name: 'Vue', isActive: false }])),如果你只需要监听isActive属性的变化,那可以给每个元素都加ref/reactive,然后在getter函数里遍历Set并读取每个元素的isActive属性——这样只有当isActive属性变化时,watch才会触发,而不会因为name属性的变化或者id属性的变化(虽然id一般不会变)触发。

如果Set的变化频率特别高,尽量用防抖(debounce)或者节流(throttle)

比如用户在输入框里输入标签时,每输入一个字符就会调用add方法把候选标签加入到Set里,这时候可以用防抖,等用户停止输入500ms之后再触发watch的回调——这样能减少不必要的计算和后端接口调用。

Vue3监听Set变化的最佳实践是什么?

最后咱们来总结一下,根据不同的场景,应该选择什么样的方案:

  1. Set内部元素不多,需要获取新旧值的具体差异,而且代码要简单——选方案一(把Set转成数组,用computed包装后再监听);
  2. Set内部元素不限,不需要获取新旧值,或者可以自己保存旧值,而且不需要初始化时执行——选方案三的只监听size属性、自己保存旧值的写法(性能最优);
  3. Set内部元素不限,不需要获取新旧值,或者可以自己保存旧值,而且初始化时执行也没关系——选方案二的watchEffect写法(代码最简洁);
  4. Set内部元素是引用类型,需要监听对象内部的属性变化——给每个元素加ref/reactive,然后在getter函数里遍历Set并读取需要监听的属性。

好啦,今天关于Vue3 watch Set的问题就聊到这里了,希望这篇文章能帮你解决开发中的实际问题,如果你还有其他关于Vue3的问题,欢迎在评论区留言哦!

版权声明

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

热门