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

Vue3怎么同时监听多个响应式数据?常见场景+性能优化+避坑指南全解

terry 2天前 阅读数 303 #Vue

在Vue3的开发里,监听单个响应式数据变化是个常规操作,但实际做项目时,我们经常会遇到要等两个甚至更多数据凑齐、或者其中任意一个/指定组合变了才触发逻辑的情况——比如电商页要根据「用户选中的分类」+「当前的排序方式」+「输入的关键词」一起刷新商品列表,又比如表单里要等「手机号」「验证码」「勾选协议」这三个条件都满足,才让「提交按钮」亮起来,这时候用Vue3原有的watch挨个写,要么代码冗余,要么逻辑容易串,今天就把这些场景的解决方案、性能细节、踩过的坑都掰开揉碎说清楚。

Vue3监听多个响应式数据的核心方法有哪些?

很多刚从Vue2转过来的开发者,可能第一反应是去找类似Vue2里$watch的数组写法——没错,Vue3的watch确实保留了监听源数组的形式,但除此之外还有watchEffect(及其变体),甚至有时候用计算属性做个“中间桥梁”会更丝滑,咱们先逐个拆解这些方法的用法、适用场景和优缺点。

用watch的“数组监听源”功能

这是最直观、最贴近Vue2开发习惯的方式,直接把要监听的多个响应式对象、ref、computed或者getter函数,放在watch的第一个参数里组成数组就行。

举个电商页刷新的小例子:假设我们有selectedCategory(ref存当前选中的分类ID,初始0代表全部)、sortBy(ref存排序方式,price_asc'价格升序)、searchKeyword(ref存搜索框的内容,初始空串)这三个数据,要在任意一个变了之后,过500ms防抖查询一次商品

import { ref, watch } from 'vue'
import { getProductList } from '@/api/product'
const selectedCategory = ref(0)
const sortBy = ref('price_asc')
const searchKeyword = ref('')
const productList = ref([])
const loading = ref(false)
// 防抖函数,直接用社区通用的就行,这里简化写个
let timer = null
function debounce(fn, delay) {
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), delay)
  }
}
// 数组监听源的核心写法
watch(
  // 第一个参数:要监听的源数组
  [selectedCategory, sortBy, searchKeyword],
  // 第二个参数:回调函数,这里能拿到「新值数组」和「旧值数组」!!顺序和监听源数组一一对应
  debounce(async ([newCat, newSort, newKey], [oldCat, oldSort, oldKey]) => {
    // 注意:这里可以加个小判断,比如只有关键词变了超过2个字符、或者分类/排序确实变了才查,避免无效请求
    if (
      (newKey.length >= 2 && newKey !== oldKey) || 
      newCat !== oldCat || 
      newSort !== oldSort
    ) {
      loading.value = true
      try {
        const res = await getProductList({
          categoryId: newCat,
          sort: newSort,
          keyword: newKey.trim()
        })
        productList.value = res.data
      } catch (err) {
        console.error('获取商品列表失败', err)
      } finally {
        loading.value = false
      }
    }
  }, 500),
  // 第三个参数:可选配置项,和监听单个源时完全一样
  {
    immediate: true, // 组件刚挂载就先执行一次,获取初始列表
    deep: false // 这里监听的都是ref的基本值,不需要deep
  }
)

这里有个细节很多新手容易漏:数组监听源的回调函数的新/旧值,也是按顺序排列的数组,所以解构的时候最好和监听源的顺序对应上,不然逻辑会乱。

那这个方法的优缺点是什么呢?

  • 优点:逻辑清晰,能拿到每个源的新、旧值(这点很重要!),可以用配置项控制是否立即执行、是否深度监听,完全是Vue2数组watch的升级,但更规范
  • 缺点:默认是“或”监听——任意一个源变了就触发回调,如果需要“与”监听(比如两个源同时变、或者都满足某个条件才触发),得自己在回调里加判断;另外如果监听源里有深层对象/数组,单独给某一个开deep或者单独给某一个关,是不行的,配置项是全局生效的

用watchEffect(或watchPostEffect/watchSyncEffect)监听依赖的多个数据

和watch不同,watchEffect不需要显式指定监听源——它会自动收集回调函数里用到的所有响应式数据作为依赖,只要依赖里的任意一个变了,就会重新执行回调。

刚才的电商页例子,用watchEffect改写会简洁一点吗?看代码:

import { ref, watchEffect } from 'vue'
import { getProductList } from '@/api/product'
const selectedCategory = ref(0)
const sortBy = ref('price_asc')
const searchKeyword = ref('')
const productList = ref([])
const loading = ref(false)
let timer = null
function debounce(fn, delay) {
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), delay)
  }
}
// watchEffect的写法
watchEffect(
  debounce(async (onCleanup) => {
    // 这里不需要显式写监听源,直接用就行,自动收集selectedCategory、sortBy、searchKeyword
    const cat = selectedCategory.value
    const sort = sortBy.value
    const key = searchKeyword.value.trim()
    // 加同样的无效请求过滤逻辑
    if (
      (key.length >= 2) || 
      cat !== 0 || // 这里用初始值做对比,因为watchEffect拿不到旧值!!这个是关键区别
      sort !== 'price_asc'
    ) {
      loading.value = true
      // onCleanup是watchEffect独有的清理函数钩子,组件卸载前、或者下次执行回调前都会触发
      // 可以用来取消上一次的请求、清除计时器,非常实用!
      onCleanup(() => {
        clearTimeout(timer)
        // 假设getProductList支持取消请求,比如用axios的CancelToken或者AbortController,这里也可以写
      })
      try {
        const res = await getProductList({
          categoryId: cat,
          sort: sort,
          keyword: key
        })
        productList.value = res.data
      } catch (err) {
        console.error('获取商品列表失败', err)
      } finally {
        loading.value = false
      }
    }
  }, 500)
)

这里重点说下watchEffect的几个核心特点和适用场景:

  • 自动收集依赖:非常适合那种“依赖多但变化逻辑简单,不需要单独跟踪每个源的旧值”的场景——比如刚才的例子里如果不需要对比旧值只需要拿到最新的凑齐条件,就可以用;再比如要同步保存用户的「个人设置」到localStorage里,设置里有主题色、字体大小、是否开启自动播放,不管哪个变了都写一遍localStorage,用watchEffect比数组watch省事儿
  • 拿不到单个源的旧值:这是它和数组watch最大的区别,如果你的逻辑必须依赖旧值,当分类从0(全部)变成其他ID时,自动清空搜索框”,那就不能用watchEffect了,必须用数组watch
  • 有清理函数钩子onCleanup:这点数组watch虽然也有(watch的回调第三个参数就是onCleanup),但很多人容易忘,watchEffect因为是直接传进第一个参数的回调里,反而更显眼——在处理请求取消、事件监听移除、计时器清除这些场景时非常有用
  • 执行时机可以调整:默认是pre执行(在Vue的DOM更新前),如果改成watchPostEffect就是post执行(DOM更新后),watchSyncEffectsync执行(响应式数据变化后立即执行,不进Vue的微任务队列,慎用,容易影响性能)
  • 没有immediate配置项:因为它默认就是组件刚挂载就会先执行一次收集依赖并触发回调——这点要注意,如果不需要立即执行,得自己加个判断条件(比如加个isMounted的ref,组件挂载后设为true,watchEffect里先判断isMounted.value才执行)

用computed做“中间桥梁”,再用watch监听这个computed

这个方法看起来绕了一层,但在处理“指定组合条件的与/或监听”“多个深层对象/数组但只需要监听其中某几个字段的变化”“需要对多个源的值做简单计算后再监听结果”这三个场景时,特别好用,甚至是最优解。

先举个“与监听”的例子:表单里的提交按钮,要等「手机号通过正则校验」「验证码不为空且长度6位」「已勾选用户协议」这三个条件同时满足才亮——我们可以用computed先计算出一个canSubmit的布尔值,然后再监听这个布尔值,或者直接把它绑定在按钮的disabled上(其实直接绑定更简单,但如果同时满足条件后还要做其他操作,比如弹个提示框“您可以提交了”,或者给按钮加个动画,就需要监听computed了):

import { ref, computed, watch } from 'vue'
const phone = ref('')
const code = ref('')
const agreeProtocol = ref(false)
const submitBtnActive = ref(false) // 可以用来控制按钮的动画,比如淡入
// 第一步:用computed计算中间条件
const canSubmit = computed(() => {
  // 校验手机号的正则(国内通用的简化版)
  const phoneReg = /^1[3-9]\d{9}$/
  return (
    phoneReg.test(phone.value.trim()) && 
    code.value.trim().length === 6 && 
    agreeProtocol.value === true
  )
})
// 第二步:监听canSubmit这个computed
watch(canSubmit, (newVal) => {
  if (newVal) {
    submitBtnActive.value = true
    // 可以加其他操作,比如弹个非阻塞的toast
  } else {
    submitBtnActive.value = false
  }
})

再举个“只监听多个深层对象的某几个字段”的例子:比如有个userProfile的reactive对象,存了用户的姓名、年龄、性别、职业、收货地址(收货地址又是个深层对象,有省、市、区、街道),现在只需要监听「姓名」「职业」「收货地址的省」这三个字段的变化,不管其他的变不变——如果用数组watch监听整个reactive对象,需要开deep: true,这会监听整个对象的所有字段,性能不好;如果用数组watch监听三个getter函数,专门取这三个字段,也可以,但用computed做个“字段集合体”再监听,代码更简洁,也一样不用开全局deep(因为computed的依赖收集只会追踪用到的字段):

import { reactive, computed, watch } from 'vue'
const userProfile = reactive({
  name: '张三',
  age: 25,
  gender: '男',
  job: '前端开发',
  address: {
    province: '广东省',
    city: '深圳市',
    district: '南山区',
    street: '科技园路'
  }
})
// 只收集需要的字段
const watchedProfileFields = computed(() => ({
  name: userProfile.name,
  job: userProfile.job,
  province: userProfile.address.province
}))
// 监听这个computed,这里要注意!!!
// 因为watchedProfileFields返回的是一个新对象,所以默认的watch只会比较引用地址,每次变化都会触发?不对不对,等下,我写个测试
// 哦不,computed的缓存机制是:如果依赖的字段没变,返回的对象虽然每次都是新引用?不对不对,再仔细想——不,返回的是新对象的话,缓存机制会失效吗?
// 对,没错!如果computed返回的是引用类型(对象、数组),哪怕依赖的字段没变,每次访问computed.value都会得到一个新的引用,所以直接监听这个computed,不管有没有开deep,引用地址变了都会触发回调——这是个大坑!!
// 那怎么解决?很简单,要么把computed的返回值改成基本值的组合(比如字符串拼接,但如果字段复杂就不行),要么用JSON.stringify转成字符串监听,要么给watch加个配置项`flush: 'sync'`?不对不对,应该用`watch`的第三个参数里的`deep: true`?或者……哦不,等下,用`watchEffect`直接监听这些字段,或者用数组watch的getter函数形式是不是更好?
// 哦刚才说的是用computed做中间桥梁,那怎么处理这个引用类型的缓存问题?
// 对了,可以用Vue3.3+新增的`computed`的`equals`选项!!!这个是解决这个问题的关键!!
// 假设我们项目用的是Vue3.3及以上版本:
const watchedProfileFields = computed({
  get() {
    return {
      name: userProfile.name,
      job: userProfile.job,
      province: userProfile.address.province
    }
  },
  // equals函数:用来比较新旧值是否相等,返回true的话就不触发依赖更新和watch回调
  equals(newVal, oldVal) {
    // 这里可以用JSON.stringify,但要注意如果有循环引用的话会报错,我们这个例子没有,直接用
    return JSON.stringify(newVal) === JSON.stringify(oldVal)
  }
})
// 现在再监听这个computed,就只会在三个字段真的变了的时候触发回调了
watch(watchedProfileFields, (newVal, oldVal) => {
  console.log('需要关注的用户信息变了', newVal, oldVal)
  // 这里可以做其他操作,比如同步到后端
})

如果项目用的是Vue3.3以下的版本,没有equals选项怎么办?那就直接用数组watch的getter函数形式,其实也很简洁,刚才差点忘了:

// Vue3.3以下的替代方案
watch(
  // 用三个getter函数,分别取需要的字段
  [
    () => userProfile.name,
    () => userProfile.job,
    () => userProfile.address.province
  ],
  ([newName, newJob, newProvince], [oldName, oldJob, oldProvince]) => {
    console.log('需要关注的用户信息变了', { newName, newJob, newProvince }, { oldName, oldJob, oldProvince })
  }
)

这个替代方案也有优点:不需要处理引用类型的缓存问题,能拿到每个字段的新、旧值,不用开deep(因为getter函数只会追踪用到的字段的变化),性能很好。

这些方法该怎么选?一张对比表帮你快速决策

刚才讲了三种核心方法,还有数组watch的getter函数变体,很多开发者可能还是不知道该选哪个,我整理了一张对比维度的表,大家可以根据自己的实际场景对照着选:

对比维度 数组watch(基本值/ref) 数组watch(getter函数取深层字段) watchEffect/watchPostEffect computed中间桥梁+watch(Vue3.3+推荐equals)
是否能显式指定监听源 ❌(自动收集) ✅(通过computed的get)
是否能拿到单个源的旧值 ❌(只能拿到computed整体的新、旧值)
是否能全局开/关deep ❌(不需要,getter只追踪指定字段) ❌(自动只追踪用到的字段) ❌(同上)
是否有清理函数钩子 ✅(第三个参数) ✅(第一个参数回调的第一个) ✅(watch的第三个参数)
是否支持immediate配置 ❌(默认立即执行) ✅(watch的配置)
适用场景 任意/多个基本值/ref的或监听
需要每个源的旧值
不需要深层监听或可以全局deep
多个深层对象/数组的指定字段的或监听
需要每个字段的旧值
不需要全局deep(性能好)
依赖多但变化逻辑简单
不需要单个源的旧值
需要自动清理资源(请求取消、事件移除)
不需要手动控制依赖
指定组合条件的与/或监听
需要对多个源的值做简单计算后再监听结果
Vue3.3+想用equals优化引用类型的依赖追踪

监听多个响应式数据时的常见避坑指南

讲完了方法和选择,接下来必须说说实际开发中踩过的那些坑,这些坑很多新手甚至有经验的开发者都会犯,大家一定要注意:

数组watch的配置项deep是全局生效的

刚才在讲数组watch的优缺点时提到过,这里再单独强调一遍——如果你的监听源数组里,有一个是深层对象/数组需要开deep,其他都是基本值/ref不需要,那全局开deep会导致其他基本值/ref的监听也会走深层比对的逻辑(虽然基本值/ref的深层比对和浅层比对没区别,但代码语义不好,而且如果监听源多了,会不会有微小的性能损耗?理论上会一点,所以最好用getter函数的形式单独处理需要深层监听的源,或者把需要深层监听的源和不需要的分开写两个watch)。

举个例子:监听源是selectedCategory(ref基本值)和userFilters(reactive深层对象,存了价格区间、品牌列表),如果全局开deep:

// 错误/不推荐的写法
watch(
  [selectedCategory, userFilters],
  () => { /* 查询商品 */ },
  { deep: true }
)

可以改成两个分开的watch:

// 推荐的写法1:分开写
watch(selectedCategory, () => { /* 查询商品 */ })
watch(userFilters, () => { /* 查询商品 */ }, { deep: true })
// 推荐的写法2:用getter函数数组(如果查询逻辑完全一样,代码更简洁)
watch(
  [
    selectedCategory,
    () => userFilters // 这里单独给这个getter开deep?不对,数组watch的配置项还是全局的,哦对了,刚才说的分开写更语义化,或者把userFilters里需要的字段单独用getter取出来
  ],
  () => { /* 查询商品 */ },
  { deep: true } // 哦还是全局的,那如果getter只取userFilters里的价格区间和品牌列表,开global deep也没关系,因为getter只追踪这两个字段的变化
)

computed返回引用类型时,默认会每次访问都返回新引用,导致watch频繁触发

刚才在讲computed中间桥梁时也提到过这个坑,这里再补充一下Vue3.3以下的其他替代方案(除了刚才说的数组watch getter函数):

  • 方案A:把computed的返回值改成字符串拼接(如果字段简单的话)

    // Vue3.3以下,字段简单的替代方案
    const watchedProfileFieldsStr = computed(() => {
      return `${userProfile.name}-${userProfile.job}-${userProfile.address.province}`
    })
    watch(watchedProfileFieldsStr, (newVal, oldVal) => {
      const [newName, newJob, newProvince] = newVal.split('-')
      console.log('需要关注的用户信息变了', { newName, newJob, newProvince })
    })

    这个方案的缺点是:如果字段里有连字符(或者你选的分隔符),就会出错;如果字段是复杂的引用类型(比如数组),就没法拼接。

  • 方案B:用lodash的isEqual函数手动比较新旧值(在watch的回调里加)

    // Vue3.3以下,用lodash的替代方案
    import { isEqual } from 'lodash-es'
    const watchedProfileFields = computed(() => ({
      name: userProfile.name,
      job: userProfile.job,
      province: userProfile.address.province
    }))
    // 这里oldVal第一次是undefined,所以要加个判断
    watch(watchedProfileFields, (newVal, oldVal) => {
      if (oldVal && isEqual(newVal, oldVal)) return
      console.log('需要关注的用户信息变了', newVal, oldVal)
    })

    这个方案的缺点是:需要引入lodash-es(虽然很多项目已经有了,但如果没有的话,增加了包体积);而且watch还是会被频繁触发(因为computed返回的是新引用),只是在回调里过滤掉了,没有从根源上解决问题,Vue3.3+的equals选项是在computed的依赖追踪阶段就过滤掉了,性能更好。

watchEffect里不要写不必要的响应式数据访问

因为watchEffect是自动收集依赖的,所以如果在回调里不小心访问了不需要监听的响应式数据,就会导致不必要的回调执行——比如刚才的电商页例子里,如果在watchEffect的回调里不小心写了console.log(productList.value),那每次商品列表更新(不管是因为查询条件变了,还是其他原因,比如手动清空了列表),watchEffect都会重新执行,导致循环请求!!

举个循环请求的错误例子:

// 错误的写法!!会导致循环请求
watchEffect(
  debounce(async () => {
    const cat = selectedCategory.value
    const sort = sortBy.value
    const key = searchKeyword.value.trim()
    if (
      (key.length >= 2) || 
      cat !== 0 || 
      sort !== 'price_asc'
    ) {
      loading.value = true
      try {
        const res = await getProductList({
          categoryId: cat,
          sort: sort,
          keyword: key
        })
        productList.value = res.data // 更新productList
        console.log(productList.value) // 这里访问了productList,导致productList变化时又触发watchEffect
      } catch (err) {
        console.error('获取商品列表失败', err)
      } finally {
        loading.value = false
      }
    }
  }, 500)
)

怎么避免?很简单:在watchEffect的回调里,只写必须用到的响应式数据访问,其他的(比如打印、调试)要么写在watchEffect外面,要么用onTrack/onTrigger钩子来调试(Vue3的响应式系统自带的,专门用来追踪依赖收集和触发的过程)

onTrack/onTrigger的用法:

import { ref, watchEffect } from 'vue'
const a = ref(1)
const b = ref(2)
watchEffect((onCleanup, onTrack, onTrigger) => {
  // onTrack:当某个响应式数据被收集为依赖时触发
  onTrack((e) => {
    console.log('依赖被收集', e)
    // e里有target(目标对象)、key(被访问的键)、type(访问类型:get/has/iterate)
  })
  // onTrigger:当某个响应式数据变化导致回调触发时触发
  onTrigger((e) => {
    console.log('回调被触发', e)
    // e里有target、key、type(变化类型:set/add/delete/clear)、newValue、oldValue
  })
  // 这里只访问a,所以只会收集a为依赖,b变化不会触发回调
  console.log('a的值是', a.value)
})

不要在watch/watchEffect的回调里频繁修改响应式数据

如果在watch/watchEffect的回调里修改了自己监听的响应式数据,就会导致无限循环——比如刚才的电商页例子里,如果在回调里写了searchKeyword.value = '',而searchKeyword又是监听源之一,就会无限触发查询。

还有一种情况是修改了其他watch的监听源,导致两个watch互相触发,虽然不是严格的无限循环(加个判断就能停),但也会影响性能和逻辑的清晰度——所以最好在修改响应式数据前,先考虑清楚会不会触发其他watch的回调,如果会,能不能用更合理的方式组织代码(比如用computed做中间桥梁,或者把两个逻辑合并到一个watch里)。

防抖/节流函数要写在watch/watchEffect的外面

刚才的例子里,我都是把防抖函数写在watch/watchEffect的外面,然后把防抖后的函数传给watch/watchEffect——如果把防抖函数写在里面,每次watch/watchEffect的回调执行时,都会创建一个新的防抖函数实例,导致防抖失效!!

举个防抖失效的错误例子:

// 错误的写法!!防抖失效
watch(
  [selectedCategory, sortBy, searchKeyword],
  // 把debounce写在里面,每次回调执行都会创建新的timer!
  async ([newCat, newSort, newKey]) => {
    let timer = null // 每次都是新的timer变量
    const debouncedFn = debounce(async () => {
      // 查询商品
    }, 500)
    debouncedFn()
  },
  { immediate: true }
)

所以一定要记住:防抖/节流函数必须写在watch/watchEffect的外面,或者用闭包把timer变量保存在外面

监听多个响应式数据时的性能优化小技巧

除了刚才避坑里提到的一些优化(比如用getter函数取深层字段避免全局deep、用Vue3.3+的equals选项优化computed引用类型的依赖追踪、不要在watchEffect里访问不必要的响应式数据),还有两个实用的性能优化小技巧:

flush: 'post'避免在DOM更新前执行不必要的操作

如果你的监听逻辑不需要在DOM更新前执行(比如不需要修改响应式数据来影响当前的DOM更新),可以用flush: 'post'配置项(watch的配置项,或者直接用watchPostEffect),让监听逻辑在DOM更新后执行——这样可以避免在DOM更新的“关键路径”上执行不必要的操作,提升页面的渲染性能。

比如刚才的表单提交按钮动画,动画是在DOM更新后才需要触发的,所以可以用flush: 'post'

watch(
  canSubmit,
  (newVal) => {
    if (newVal) {
      submitBtnActive.value = true
    } else {
      submitBtnActive.value = false
    }
  },
  { flush: 'post' }
)

shallowRef/shallowReactive减少不必要的依赖追踪

如果你的响应式数据里,有一些深层字段是不需要监听的(比如从后端拿到的一些静态配置数据,只是用来展示,不会修改),可以用shallowRef/shallowReactive来创建这些数据——shallowRef只监听ref.value的变化,不监听其内部的深层变化;shallowReactive只监听对象第一层属性的变化,不监听深层属性的变化。

比如刚才的电商页例子里,如果从后端拿到的商品列表productList只是用来展示,不会在前端修改(修改的话也是直接替换整个数组),可以用shallowRef来创建:

import { shallowRef } from 'vue'
const productList = shallowRef([])

这样可以减少Vue响应式系统的依赖追踪负担,提升页面的性能——特别是当productList的数组很大、每个商品对象又有很多深层字段时,效果会更明显。

Vue3监听多个响应式数据的方法有很多,核心是根据自己的实际场景选择合适的方法:

  • 如果需要显式指定监听源、拿到单个源的旧值,选数组watch(基本值/ref或者getter函数)
  • 如果依赖多但变化逻辑简单、不需要单个源的旧值、需要自动清理资源,选watchEffect/watchPostEffect
  • 如果需要指定组合条件的与/或监听、需要对多个源的值做简单计算后再监听结果,选computed中间桥梁+watch(Vue3.3+记得用equals选项)

也要注意刚才提到的那些避坑指南和性能优化小技巧,避免写出有bug或者性能差的代码。

建议大家在实际开发中多尝试不同的方法,找到最适合自己项目的那一种——毕竟适合自己的才是最好的。

版权声明

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

热门