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

Vue3怎么同时监听两个响应式值的变化?有哪些好用的技巧和避坑点?

terry 3小时前 阅读数 37 #Vue
文章标签 多源监听

平时用Vue3做项目时,经常会碰到“多个状态一变就要触发同一段逻辑”的场景——比如表单里的“用户名”和“密码”都填完才允许登录按钮亮,或者筛选器里的“价格区间”和“品牌筛选”同时生效时重新请求商品列表,这时候核心需求就是同时监听两个(甚至更多)响应式值的变化,那Vue3的watch有哪些直接或者间接的实现方式?每种方式又有什么适用场景和容易踩的坑?今天咱们就把这个事儿聊透,连Vue3刚出的小细节也会提到。

直接用watch的第一个参数传数组就行?但要注意数组里的元素要求

很多刚从Vue2转过来的开发者可能第一反应是查watch的文档,没错,Vue3官方确实保留了同时监听多个值的原生支持——就是把要监听的所有响应式值打包成一个响应式数组传给watch的第一个参数。

这里要先插一句关键的“数组元素规则”:不是随便把变量放进去就行,必须是「能触发Vue响应式更新的引用」或者「能直接访问到响应式值的函数」,咱们先举两个最常用的合法例子:

第一个例子是监听ref对象:假设我们做一个用户反馈的小模块,有两个必填的反馈分类(用ref定义的字符串)和反馈内容(用ref定义的字符串,长度超过50才有效),两个都满足就提交草稿到本地存储,这时候代码可以这么写:

import { ref, watch } from 'vue'
const category = ref('')
const content = ref('')
// 直接传ref对象组成的数组
watch([category, content], ([newCategory, newContent], [oldCategory, oldContent]) => {
  console.log('分类更新前:', oldCategory, '更新后:', newCategory)
  console.log('内容更新前:', oldContent, '更新后:', newContent)
  // 这里的new和old都是数组,顺序和第一个参数的数组一一对应
  if (newCategory && newContent.length > 50) {
    localStorage.setItem('feedbackDraft', JSON.stringify({ category: newCategory, content: newContent }))
    alert('草稿已自动保存!')
  }
})

这个写法很直观对吧?顺序对应也清晰,适合新手入门。

第二个例子是监听reactive里的某个属性或者经过计算的响应式值:比如做电商筛选,商品数据是reactive里的productList,筛选条件是priceRange(reactive里的对象,有min和max)和selectedBrands(reactive里的数组),我们要监听的不是整个reactive或者priceRange对象本身(要是直接传priceRange引用,对象内部没换地址的话可能不会触发),而是priceRange.minpriceRange.maxselectedBrands.length的变化——这时候就得用函数来“包裹”住我们真正关心的路径或者计算值,代码如下:

import { reactive, watch } from 'vue'
const state = reactive({
  productList: [],
  priceRange: { min: 0, max: 1000 },
  selectedBrands: ['Apple']
})
// 传能访问到具体属性/计算值的函数数组
watch([
  () => state.priceRange.min,
  () => state.priceRange.max,
  () => state.selectedBrands
], ([newMin, newMax, newBrands], [oldMin, oldMax, oldBrands]) => {
  console.log('筛选条件变了,重新请求数据...')
  // 这里可以写请求接口的逻辑
  // fetchProducts({ priceMin: newMin, priceMax: newMax, brands: newBrands })
})

这里特意提一下,如果要监听的是reactive里的数组本身(比如selectedBrands),用函数返回数组引用是可以的,因为Vue3对数组的push、pop、splice这些方法都做了代理,数组引用虽然没变,但代理捕获到了内部操作,所以会触发watch;但如果是监听reactive里的普通对象(比如priceRange),只返回对象引用的话,除非把整个priceRange对象重新赋值(比如state.priceRange = { min: 500, max: 2000 }),否则内部min和max的变化不会触发,这是个超级常见的新手坑!

除了传数组,还有没有其他方式实现同时监听两个值?

当然有,传数组是官方最推荐的“官方写法”,但有些场景下用间接的方式可能更优雅或者更符合项目的需求。

用computed先把两个值合并成一个新的响应式值,再监听这个computed

这个方式适合“两个值的变化对我们的逻辑影响是等价的”或者“逻辑只关心两个值的组合结果”的场景,比如刚才的用户反馈草稿例子,我们只需要“分类不为空且内容超过50”这个组合结果,就可以这么写:

import { ref, computed, watch } from 'vue'
const category = ref('')
const content = ref('')
// 先计算一个“是否满足保存条件”的响应式布尔值
const canSaveDraft = computed(() => category.value && content.value.length > 50)
// 再监听这个computed值的变化
watch(canSaveDraft, (newVal, oldVal) => {
  if (newVal && !oldVal) {
    // 只有从“不能保存”变成“能保存”的时候才自动存
    localStorage.setItem('feedbackDraft', JSON.stringify({ category: category.value, content: content.value }))
    alert('草稿已自动保存!')
  }
})

这个写法的好处是逻辑分离得更清楚:computed负责“判断条件”,watch负责“执行动作”;而且如果后面还需要用到“是否满足保存条件”这个值(比如绑定到按钮的disabled属性上),直接用canSaveDraft就行,不用重复写判断逻辑。

给每个值单独写一个watch,然后共用同一个回调函数

这个方式适合“两个值的触发时机可能有细微差别,需要单独做一些前置处理,最后再执行核心逻辑”的场景,比如刚才的电商筛选例子,假设价格区间的min和max需要先做范围校验(比如min不能大于max,不能小于0),品牌筛选需要先去重,之后再一起请求数据,就可以这么写:

import { reactive, watch } from 'vue'
const state = reactive({
  productList: [],
  priceRange: { min: 0, max: 1000 },
  selectedBrands: ['Apple', 'Apple'] // 故意加了重复的
})
// 先写一个核心的“请求商品”函数
const fetchFilteredProducts = () => {
  // 这里可以先做统一的处理,比如把state里的价格区间和去重后的品牌取出来
  const { priceRange, selectedBrands } = state
  const validMin = Math.max(0, Math.min(priceRange.min, priceRange.max))
  const validMax = Math.max(validMin, priceRange.max)
  const validBrands = [...new Set(selectedBrands)]
  console.log('用校验后的条件请求数据:', { validMin, validMax, validBrands })
  // fetchProducts(...)
}
// 给价格区间的min单独写watch,做前置校验
watch(() => state.priceRange.min, (newMin) => {
  if (newMin > state.priceRange.max) {
    state.priceRange.max = newMin // 保证max不小于min
  }
  fetchFilteredProducts()
})
// 给价格区间的max单独写watch,做前置校验
watch(() => state.priceRange.max, (newMax) => {
  if (newMax < state.priceRange.min) {
    state.priceRange.min = newMax // 保证min不大于max
  }
  fetchFilteredProducts()
})
// 给品牌筛选单独写watch,做前置去重
watch(() => state.selectedBrands, (newBrands) => {
  const uniqueBrands = [...new Set(newBrands)]
  if (uniqueBrands.length !== newBrands.length) {
    state.selectedBrands = uniqueBrands // 只有有重复才更新,避免死循环
  }
  fetchFilteredProducts()
})

这个写法的好处是每个前置处理和对应的触发源绑定得很紧密,修改起来更灵活;但要注意一个问题:如果两个值同时变化(比如用户一次性输入了min和max后点击了“确定筛选”按钮,同时修改了两个值),那么每个watch都会触发一次,核心的fetchFilteredProducts函数也会被调用两次——这时候就需要用到防抖或者节流了,不过防抖节流不是今天的重点,后面可以单独讲。

用watch监听两个值时,还有哪些必须要注意的避坑点?

刚才已经提过“reactive普通对象的直接引用不触发内部属性变化”的坑,除此之外,还有几个非常容易踩的,咱们再列出来重点说。

避坑点一:数组形式的watch回调里的oldValue,什么时候是旧值什么时候是新值?

这个问题很多开发者都没注意到,其实Vue3官方文档里明确写了:如果watch的第一个参数是对象类型的响应式值(包括reactive对象、ref的value是对象/数组) 组成的数组,那么回调里的oldValue其实和newValue是同一个引用——也就是说,oldValue里的内容其实已经被更新了,你根本拿不到真正的旧值!

咱们举个例子验证一下:

import { ref, watch } from 'vue'
const user = ref({ name: '张三', age: 18 })
const hobbies = ref(['篮球', '游泳'])
watch([user, hobbies], ([newUser, newHobbies], [oldUser, oldHobbies]) => {
  console.log('user的newVal和oldVal是同一个引用吗?', newUser === oldUser) // 输出true!
  console.log('hobbies的newVal和oldVal是同一个引用吗?', newHobbies === oldHobbies) // 输出true!
  console.log('oldUser的name:', oldUser.name) // 输出李四!已经被更新了
  console.log('oldHobbies的第三个元素:', oldHobbies[2]) // 输出唱歌!已经被更新了
})
// 现在修改user的name和hobbies的内容
user.value.name = '李四'
hobbies.value.push('唱歌')

那怎么才能拿到对象/数组类型响应式值的真正旧值呢?有两个办法: 第一个办法是给watch加deep: trueflush: 'sync'?不对不对,加deep只能让Vue3监听对象内部的深层变化,但oldValue还是同一个引用;加flush: 'sync'只是让watch的回调同步执行,也解决不了引用的问题。 第二个正确的办法是用computed先把对象/数组做一个“深拷贝”,然后监听这个computed值——不过深拷贝要注意性能问题,如果对象/数组特别大,频繁深拷贝会影响页面的流畅度,咱们举个例子:

import { ref, computed, watch } from 'vue'
import { cloneDeep } from 'lodash-es' // 可以用lodash的深拷贝,也可以自己写
const user = ref({ name: '张三', age: 18 })
// 先computed一个深拷贝的user
const copiedUser = computed(() => cloneDeep(user.value))
// 再监听这个copiedUser
watch(copiedUser, (newUser, oldUser) => {
  console.log('user的newVal和oldVal是同一个引用吗?', newUser === oldUser) // 输出false!
  console.log('oldUser的name:', oldUser.name) // 输出张三!真正的旧值
  console.log('newUser的name:', newUser.name) // 输出李四!真正的新值
}, { deep: true }) // 这里还是要加deep,因为copiedUser的value是对象
// 修改user的name
user.value.name = '李四'

如果不想用lodash-es,自己写一个简单的深拷贝也可以,但要注意处理循环引用、Date对象、RegExp对象这些特殊情况,不过一般的项目场景下,lodash-es的cloneDeep已经足够用了。

避坑点二:数组形式的watch,什么时候会触发回调?

数组形式的watch的触发逻辑其实很简单:只要数组里有任何一个元素的值发生了变化(当然要符合前面说的“能触发响应式更新”的要求),就会触发回调函数。

但这里有个小细节:如果数组里有多个元素同时变化,回调只会触发一次——比如刚才的ref组成的数组例子,如果我们同时修改category和content的值,回调只会打印一次日志,而不是两次,这个细节其实很贴心,避免了不必要的重复执行。

咱们举个例子验证一下:

import { ref, watch } from 'vue'
const a = ref(1)
const b = ref(2)
watch([a, b], ([newA, newB]) => {
  console.log('a或b变了,新值是:', newA, newB)
})
// 同时修改a和b的值
a.value = 3
b.value = 4
// 输出一次:a或b变了,新值是: 3 4

避坑点三:要不要加immediate: true?

这个其实不只是监听两个值的坑,是整个watch的通用坑,但很多开发者在监听两个值的时候容易忽略,immediate: true的作用是让watch的回调在组件初始化的时候就执行一次,而不是等到第一次变化才执行。

比如刚才的电商筛选例子,如果用户进入页面的时候已经有默认的筛选条件(比如Apple品牌、0-1000元),我们需要组件一挂载就请求默认的商品列表,这时候就必须加immediate: true,不然用户进入页面会看到空白的商品列表。

咱们修改一下刚才的电商筛选例子(传函数数组的版本),加上immediate: true:

import { reactive, watch } from 'vue'
const state = reactive({
  productList: [],
  priceRange: { min: 0, max: 1000 },
  selectedBrands: ['Apple']
})
watch([
  () => state.priceRange.min,
  () => state.priceRange.max,
  () => state.selectedBrands
], ([newMin, newMax, newBrands]) => {
  console.log('筛选条件变了(包括初始化),重新请求数据...')
  // fetchProducts(...)
}, { immediate: true }) // 加上immediate: true

这样组件一挂载,回调就会执行一次,请求默认的商品列表。

三种实现方式的适用场景

最后咱们再总结一下三种实现方式的适用场景,方便大家根据项目需求选择:

  1. 直接传ref对象或函数数组(官方推荐):适合大多数场景,逻辑简单直观,顺序对应清晰,修改起来方便。
  2. 用computed先合并再监听:适合“只关心两个值的组合结果”或者“组合结果需要复用”的场景,逻辑分离更清楚。
  3. 给每个值单独写watch,共用回调:适合“每个值需要单独做前置处理”的场景,修改前置处理更灵活,但要注意同时变化时的重复执行问题(可以加防抖节流)。

好啦,今天关于Vue3同时监听两个响应式值的内容就聊到这里,要是还有其他问题,欢迎在评论区留言讨论!

版权声明

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

热门