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

Vue3 watch多个变量怎么用才顺手?从新手入门到进阶避坑全解析

terry 1小时前 阅读数 23 #Vue

作为日常开发里用Vue3占大头的前端,我最近在后台收到不下10条类似的问题:之前Vue2里watch可以写个数组同时监听几个值,但Vue3好像有变化?或者说怎么组合监听更灵活?今天就把我踩过的坑、实际项目里总结的最优解全说透,连新手刚上手容易写错的地方都标出来了。

先理清楚Vue3 watch的基础逻辑:别直接照搬Vue2数组写法

很多朋友刚转Vue3会下意识写这样的代码:

// 错误(或者说不符合Vue3最新习惯且功能受限的写法)
import { watch, ref, reactive } from 'vue'
const count1 = ref(0)
const count2 = ref(0)
watch([count1, count2], (newVal, oldVal) => {
  console.log(newVal, oldVal)
})

先别急,这段代码不是不能跑——确实能同时触发,newVal和oldVal也是对应的两个值组成的数组,但为什么说“不符合习惯功能受限”?首先得明白Vue3的watch和Vue2的本质区别:Vue2的watch是基于选项式API,数组写法是官方给的“批量监听同类型简单依赖”的捷径;Vue3的组合式API核心是响应式引用(ref/reactive)组合函数(computed/watchEffect/watch) ,它的watch数组写法本质是“监听多个独立响应式源的整体更新”,但遇到嵌套对象、或者需要区分“哪个值触发了更新”这类场景就很麻烦,甚至没法直接用。

那先从最基础的“多个ref简单值监听”开始讲最优解,再一步步进阶。

第一个场景:多个独立的ref/reactive“浅值”,只想知道有没有变

这里说的“浅值”,就是ref存的数字、字符串、布尔值、null、undefined,或者reactive存的顶层非对象/数组属性,比如做登录表单的实时保存草稿功能,只需要监听用户名、密码、手机号这三个顶层ref有没有变动,不管变动顺序和新旧值细节,那就可以直接用数组写法,但这个时候可以加个参数优化体验。

先给个正确的、日常够用的代码示例:

import { watch, ref } from 'vue'
// 登录表单的顶层ref
const loginForm = {
  username: ref(''),
  password: ref(''),
  phone: ref('')
}
// 批量监听,优化点1:deep设为false(虽然默认就是,但显式写出来更清晰,嵌套属性时再改)
// 优化点2:immediate设为true?看需求,比如用户刚进入页面如果有本地存储的草稿,直接读取后也要触发一次保存判断
watch(
  [loginForm.username, loginForm.password, loginForm.phone],
  () => {
    // 这里写保存草稿的逻辑,比如localStorage.setItem
    console.log('表单有变动,准备保存草稿')
  },
  { immediate: false, deep: false }
)

如果没有本地存储触发需求,默认参数就行,这个场景下数组写法没问题,简洁高效,新手入门可以先这么用。

但很多朋友会遇到第二个场景:想知道到底是哪个值触发了更新,这时候数组写法的newVal/oldVal虽然是数组,但新手容易搞混顺序——比如你把监听数组里的loginForm.phone和password换位置,oldVal和newVal的下标也会跟着变,维护起来很头疼。

第二个场景:多个独立的响应式源,需要区分触发源和对应的新旧值

这个场景我之前做电商的商品筛选器遇到过:筛选器里有“价格区间”“品牌多选”“分类单选”三个模块,每个模块变动都要触发接口请求,但前端要记录“当前是哪个筛选条件在改”,防止重复提交防抖后的参数(比如用户先改价格,刚过300ms要提交,又快速选了品牌,这时候防抖重置,但记录下来最后改的是品牌,接口返回后可以滚动到品牌模块高亮)。

这时候Vue3的watch有个隐藏的小技巧吗?不对,是官方推荐的“用函数式返回响应式源”——单个源的时候函数式可以处理嵌套,但多个源的时候,函数式可以搭配一些逻辑来辅助判断,不过更直接的是,把多个独立源拆成多个watch?或者写一个watch,用一个对象把响应式源和对应标识绑起来?

拆成多个watch?也行,但如果三个模块的触发逻辑大部分一样(比如都是先收集所有筛选条件,再加个标识,然后防抖请求),拆成三个就要写三次重复代码,维护成本高,官方更推荐的是组合式API的核心思想:把公共逻辑抽成组合函数,或者用函数式监听+自定义触发判断

先给个“自定义触发判断”的代码示例,不需要抽组合函数,适合逻辑不复杂的情况:

import { watch, ref, reactive } from 'vue'
// 分类单选(ref)
const selectedCategory = ref('all')
// 品牌多选(reactive的数组,属于复杂浅值?不对,reactive的顶层数组本身是响应式的,但监听整个数组的话,push/splice才会触发,修改数组里的元素如果是简单值不会?哦对,这里先讲简单的独立源,品牌多选我们先用ref包一个数组试试,后面再讲reactive的嵌套对象/数组)
const selectedBrands = ref([])
// 价格区间(ref包对象)
const priceRange = ref({ min: 0, max: 9999 })
// 自定义的触发源标识
let lastTriggerSource = ''
// 这里注意!不要直接监听priceRange对象,否则修改min/max不会触发(除非加deep),但单独监听min/max用函数式
watch(
  [
    () => selectedCategory.value,
    () => selectedBrands.value,
    () => priceRange.value.min,
    () => priceRange.value.max
  ],
  (newValArr, oldValArr) => {
    // 用对比新旧值数组的方式找触发源,这里的下标顺序不能乱,要和上面的监听函数顺序一致
    const triggerIndex = newValArr.findIndex((val, idx) => val !== oldValArr[idx])
    // 匹配触发源标识
    const sourceMap = ['category', 'brand', 'priceMin', 'priceMax']
    lastTriggerSource = sourceMap[triggerIndex]
    console.log(`触发源是:${lastTriggerSource}`)
    // 这里写收集条件+防抖请求的逻辑
  },
  { deep: true }
)

哦对了,这里priceRange用了函数式单独监听min和max,所以deep要不要设?如果是监听priceRange.value对象本身,修改min/max必须加deep,但单独监听min和max的话,deep可以去掉,省性能,上面的代码我是故意留了个小冗余,让大家对比。

不过对比新旧值数组的方式下标不能乱,万一后面加了新的筛选条件,销量排序”,就要同时改监听函数数组、sourceMap数组,还有后面的逻辑,还是有点麻烦,那有没有更灵活的方式?有!就是用对象形式包裹响应式源和标识,然后watch整个对象(加deep) ?不对,watch整个对象的话,标识如果不变的话不会频繁触发,但响应式源变了,整个对象的引用会不会变?哦,ref包的对象本身引用不变,但reactive的属性变了会触发,那我们可以用一个reactive的“监听配置对象”来做?

等下,更简单的是,把每个响应式源和对应的触发逻辑(或者标识)抽成一个小函数,然后用watchEffect?不对,watchEffect会自动收集所有依赖,但没法区分触发源,除非手动维护依赖的状态,哦,刚才那个方法其实可以优化一下,用Object.entries的方式生成监听函数数组和sourceMap,这样加新条件只需要加一个键值对:

import { watch, ref, reactive } from 'vue'
const selectedCategory = ref('all')
const selectedBrands = ref([])
const priceRange = ref({ min: 0, max: 9999 })
let lastTriggerSource = ''
// 配置对象,加新条件只需要在这里加!
const watchConfig = {
  category: () => selectedCategory.value,
  brand: () => selectedBrands.value,
  priceMin: () => priceRange.value.min,
  priceMax: () => priceRange.value.max
}
// 自动生成监听源数组和标识数组
const watchSources = Object.values(watchConfig)
const sourceMap = Object.keys(watchConfig)
watch(
  watchSources,
  (newValArr, oldValArr) => {
    const triggerIndex = newValArr.findIndex((val, idx) => val !== oldValArr[idx])
    lastTriggerSource = sourceMap[triggerIndex]
    console.log(`触发源是:${lastTriggerSource}`)
  }
)

这个就灵活多了!不管加多少新的独立筛选条件,只需要在watchConfig里加键值对就行,维护成本低,新手也能看懂。

第三个场景:监听reactive对象的多个顶层/嵌套属性,或者整个对象

刚才第二个场景里提到了reactive的嵌套属性,这个也是很多朋友踩坑的重灾区,比如我们把刚才的登录表单和商品筛选器都改成reactive形式:

import { watch, reactive } from 'vue'
// 登录表单reactive
const loginForm = reactive({
  username: '',
  password: '',
  phone: '',
  extra: {
    rememberMe: false,
    autoLogin: false
  }
})
// 商品筛选器reactive
const productFilter = reactive({
  selectedCategory: 'all',
  selectedBrands: [],
  priceRange: { min: 0, max: 9999 },
  sort: { field: 'sales', order: 'desc' }
})

首先讲监听reactive对象的多个顶层属性:可以用数组,把每个属性用函数式返回吗?可以!和刚才的独立ref一样的写法:

// 监听loginForm的username、password、phone三个顶层属性
watch(
  [() => loginForm.username, () => loginForm.password, () => loginForm.phone],
  () => {
    console.log('登录表单基础信息有变动')
  }
)

也可以监听整个loginForm对象的顶层属性(不包括嵌套的extra) ,这时候不需要加deep吗?对!reactive对象本身的顶层属性变动,即使不加deep也会触发watch,但要注意,newVal和oldVal是同一个对象引用!这个是Vue3的设计,因为reactive是 Proxy 代理,不会像ref那样存副本,所以如果需要对比oldVal和newVal的嵌套属性,或者顶层属性的具体值,必须手动深拷贝一份:

import { watch, reactive } from 'vue'
import { cloneDeep } from 'lodash-es' // 推荐用lodash-es的深拷贝,轻量且支持tree-shaking
const loginForm = reactive({
  username: '',
  password: '',
  phone: '',
  extra: {
    rememberMe: false,
    autoLogin: false
  }
})
// 先存一份初始的深拷贝作为oldVal
let oldLoginForm = cloneDeep(loginForm)
watch(
  loginForm,
  (newVal) => {
    console.log('新的登录表单:', newVal)
    console.log('旧的登录表单:', oldLoginForm)
    // 更新oldVal
    oldLoginForm = cloneDeep(newVal)
  },
  { deep: true } // 这里加deep的话,嵌套的extra属性变动也会触发
)

哦对了,这里必须提一句:如果只需要监听reactive对象的部分嵌套属性,比如productFilter的priceRange.min和priceRange.max,还有sort.order,那就还是用函数式返回这些属性,不需要监听整个对象加deep,省性能:

watch(
  [
    () => productFilter.priceRange.min,
    () => productFilter.priceRange.max,
    () => productFilter.sort.order
  ],
  () => {
    console.log('商品筛选器的价格或排序有变动')
  }
)

第四个场景:监听“多个条件同时满足才触发”或者“只要有一个满足就触发”之外的逻辑?

比如刚才的商品筛选器,我想监听“价格区间的min大于等于0且max大于min,同时selectedBrands不为空”的时候才触发接口请求,怎么办?哦,这个其实是组合条件,不是单纯的“监听多个源”,但很多朋友会把它和“监听多个源”搞混,这里顺便提一下。

这种情况不要用watch的数组参数加逻辑判断触发接口,因为数组参数只要有一个源变就会触发回调,回调里再判断条件的话,会有很多不必要的执行,正确的做法是用computed先把组合条件算出来,然后只监听这个computed的返回值

import { watch, reactive, computed } from 'vue'
const productFilter = reactive({
  selectedCategory: 'all',
  selectedBrands: [],
  priceRange: { min: 0, max: 9999 }
})
// 组合条件:selectedBrands不为空 且 priceRange.max > priceRange.min
const shouldRequestFilter = computed(() => {
  return productFilter.selectedBrands.length > 0 && productFilter.priceRange.max > productFilter.priceRange.min
})
// 只监听shouldRequestFilter,只有当它从false变成true,或者true变成true但背后的条件变了?不对,computed是缓存的,只有背后的依赖变了且返回值变了才会更新,所以这里的watch只会在shouldRequestFilter的返回值变的时候触发?或者加个flush参数?哦,computed的返回值如果是布尔值,从false变true触发一次,true变false又触发一次,但如果是true但背后的价格区间变了(比如min从0变10,max从100变200,满足条件的情况下),computed的返回值还是true,所以watch不会触发?对!如果这种情况也想触发,那就要把computed改成返回一个对象,对象里包含满足条件的所有参数,这样每次参数变了,computed的返回值引用也会变,watch就会触发:
const requestParams = computed(() => {
  if (productFilter.selectedBrands.length === 0 || productFilter.priceRange.max <= productFilter.priceRange.min) {
    return null
  }
  return {
    category: productFilter.selectedCategory,
    brands: productFilter.selectedBrands,
    priceMin: productFilter.priceRange.min,
    priceMax: productFilter.priceRange.max
  }
})
watch(
  requestParams,
  (newParams) => {
    if (newParams) {
      console.log('满足条件,准备请求商品列表:', newParams)
      // 这里写请求逻辑
    }
  },
  { immediate: true } // 加immediate,刚进入页面如果有本地存储的满足条件的参数,直接请求
)

这个方法太实用了!我之前做很多复杂的表单提交、筛选器请求都用这个,避免了不必要的回调执行,代码也更清晰,逻辑和监听分离。

最后再讲几个新手必踩的坑,避坑指南来了

坑1:直接监听reactive对象的顶层属性(不用函数式)

比如这样写:

// 错误!
watch(loginForm.username, () => {})

为什么错误?因为loginForm.username是一个普通的字符串(或者数字、布尔值),不是响应式引用,watch只能监听响应式引用(ref/reactive)或者返回响应式引用/值的函数,正确的写法是用函数式返回:() => loginForm.username

坑2:监听ref包的对象/数组时不加deep,或者直接监听.value

先看不加deep的情况:

// 不加deep的话,只有当ref的.value引用变了才会触发,比如selectedBrands.value = [],但push/splice/修改数组元素不会
const selectedBrands = ref([])
watch(selectedBrands, () => {
  console.log('selectedBrands变了')
})

再看直接监听.value的情况:

// 错误!和坑1一样,.value是普通值
watch(selectedBrands.value, () => {})

正确的写法是:如果要监听ref包的对象/数组的内部变化,加deep;如果只监听引用变化,不用加deep但别直接修改内部,直接替换整个.value。

坑3:newVal和oldVal是同一个引用(reactive对象/ref包的对象/数组加deep的情况)

刚才讲过了,必须手动深拷贝一份oldVal来对比,推荐用lodash-es的cloneDeep,别自己写深拷贝(容易漏掉Symbol、Date、RegExp这些特殊类型)。

坑4:滥用watchEffect代替watch

watchEffect会自动收集所有依赖,只要依赖变了就会触发,不需要手动指定依赖源,适合不需要区分新旧值、不需要手动控制触发时机的场景(比如自动保存草稿,但刚才的自动保存其实用watch加数组更清晰),但如果需要区分触发源、需要对比新旧值、需要手动控制触发时机(比如加防抖节流,watch的回调函数可以套防抖节流,watchEffect套的话会有问题,因为watchEffect每次依赖变都会重新执行,包括重新创建防抖节流函数),那就必须用watch。

比如刚才的商品筛选器请求,套防抖节流的话,watch的写法是这样的:

import { watch, reactive, computed } from 'vue'
import { debounce } from 'lodash-es'
const productFilter = reactive({/* 省略 */})
const requestParams = computed(() => {/* 省略 */})
// 套防抖,300ms内只执行最后一次
const debounceRequest = debounce((params) => {
  console.log('请求商品列表:', params)
}, 300)
watch(
  requestParams,
  (newParams) => {
    if (newParams) {
      debounceRequest(newParams)
    }
  },
  { immediate: true }
)
// 组件卸载时记得取消防抖,防止内存泄漏
import { onUnmounted } from 'vue'
onUnmounted(() => {
  debounceRequest.cancel()
})

如果用watchEffect套debounce的话,每次productFilter的依赖变了,watchEffect都会重新执行,重新创建debounceRequest函数,之前的防抖就失效了,所以这个场景必须用watch。

好了,今天关于Vue3 watch多个变量的内容就讲完了,从新手入门的数组写法,到需要区分触发源的进阶写法,再到组合条件的computed+watch写法,还有四个必踩的坑,全是我实际项目里的经验总结,如果还有什么不懂的,或者有其他Vue3的问题,欢迎在后台留言,我会一一解答。

版权声明

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

热门