Vue3中怎么同时监听多个数据值?有没有更好的写法?
前阵子帮朋友改电商后台的Vue3代码,发现他把用户切换搜索条件的5个ref拆成了5个独立的watch,每个都调同一段接口请求代码,接口直接被刷炸了——后端同事找过来的时候脸都是绿的,其实很多人刚从Vue2转过来,要么只会写最基础的数组组合,要么不知道处理重复调用的坑,要么对监听computed返回值还是多个独立值有疑问,甚至不知道watch和watchEffect在多值场景下怎么选,今天就把这些问题一次性说透,顺便讲几个能提升代码质量的小技巧。
最直接的写法:把要监听的值塞进数组里
先从新手入门级的开始讲起,在Vue2里,你可能见过有人写$watch然后第二个参数传数组或者写deep,但实际Vue2原生没太方便的多独立值批量监听(除非自己封装),但到了Vue3,watch的第一个参数直接就能放数组了,这是最基础、也是官方文档首先提到的方法。
比如你有搜索关键词、分类ID、价格区间上下限、是否只看有货这5个ref:
import { ref, watch } from 'vue'
const searchKeyword = ref('')
const categoryId = ref(0)
const priceMin = ref(0)
const priceMax = ref(9999)
const onlyHasStock = ref(false)
现在要让这5个里面任何一个变了都触发搜索,就可以这么写:
watch(
[searchKeyword, categoryId, priceMin, priceMax, onlyHasStock],
([newKeyword, newCategoryId, newPriceMin, newPriceMax, newOnlyHasStock], [oldKeyword, oldCategoryId, oldPriceMin, oldPriceMax, oldOnlyHasStock]) => {
// 这里写调接口的逻辑
console.log('新参数:', newKeyword, newCategoryId, newPriceMin, newPriceMax, newOnlyHasStock)
console.log('旧参数:', oldKeyword, oldCategoryId, oldPriceMin, oldPriceMax, oldOnlyHasStock)
}
)
这里要注意几个细节,很多新手容易踩坑:第一个是数组的顺序是一一对应的,你new和old的解构变量顺序不能乱,不然拿到的旧分类ID可能是旧价格上限;第二个是这个写法默认只监听浅层的响应式变化,比如你priceMin是ref(0),直接改priceMin.value没问题,但如果是ref({ start: 0, end: 9999 })这种嵌套对象,改priceMin.value.start是不会触发的,这时候得加第三个配置参数{ deep: true };第三个是默认不会立即执行回调,也就是说组件刚挂载的时候不会调搜索,得等用户至少改了一个参数才会动,如果你想要组件一加载就先查一次默认数据,加个{ immediate: true }就行。
不过这种写法虽然直接,但也有不少问题,刚才朋友那个炸接口的例子就是其一——如果用户快速在搜索框打字,或者连续点分类下拉框,watch会触发很多次,每次都请求接口,不仅前端性能有问题(比如多次渲染列表导致卡顿),还会给后端造成压力,那这个时候怎么优化呢?别急,后面会讲防抖节流结合watch的做法,还有更优雅的computed前置筛选的写法。
进阶优化一:用computed先预处理,只监听真正需要触发的变化
刚才说的朋友那个电商后台的例子,他的5个ref里有个onlyHasStock,其实后台接口的默认值是true,用户第一次切换到false或者再切回来才需要调接口,其他时候(比如连续切换true又切回false但中间有搜索词的变化)可能是正常的,但快速操作的时候还是会有问题,不过还有一种更常见的场景:比如你监听的多个值里,有些组合变化是不需要触发操作的,或者需要把多个值合并成一个对象再判断逻辑,这时候用computed先把它们预处理一下,再监听computed的返回值,会比直接监听多个ref更清晰,也更容易加判断逻辑。
举个具体的例子,还是刚才的电商搜索场景,假设只有当搜索关键词至少有2个字符,或者分类ID不为0,或者价格区间和默认值不一样,或者onlyHasStock和默认值不一样的时候才触发搜索,那可以这么写:
import { ref, computed, watch } from 'vue'
// 先定义默认值,方便后面对比
const DEFAULT_CATEGORY = 0
const DEFAULT_PRICE_MIN = 0
const DEFAULT_PRICE_MAX = 9999
const DEFAULT_ONLY_STOCK = true
const searchKeyword = ref('')
const categoryId = ref(DEFAULT_CATEGORY)
const priceMin = ref(DEFAULT_PRICE_MIN)
const priceMax = ref(DEFAULT_PRICE_MAX)
const onlyHasStock = ref(DEFAULT_ONLY_STOCK)
// 用computed预处理搜索条件,同时加个是否有效的标志
const validSearchParams = computed(() => {
const isKeywordValid = searchKeyword.value.length >= 2
const isCategoryChanged = categoryId.value !== DEFAULT_CATEGORY
const isPriceChanged = priceMin.value !== DEFAULT_PRICE_MIN || priceMax.value !== DEFAULT_PRICE_MAX
const isStockChanged = onlyHasStock.value !== DEFAULT_ONLY_STOCK
// 只有至少满足一个条件才返回完整的参数,否则返回null
if (isKeywordValid || isCategoryChanged || isPriceChanged || isStockChanged) {
return {
keyword: searchKeyword.value.trim(), // 顺便把搜索词的空格去掉
categoryId: categoryId.value,
priceMin: priceMin.value,
priceMax: priceMax.value,
onlyHasStock: onlyHasStock.value
}
}
return null
})
// 现在只监听validSearchParams这个computed值
watch(validSearchParams, (newParams, oldParams) => {
// 如果预处理后是null,直接return,不调接口
if (!newParams) return
// 这里可以再加个对比:只有新参数和旧参数不一样才调(比如搜索词从“手机”改成“手机壳”是需要的,但从“手机”改成“手机”(用户按了删除又输入回来)就不需要)
// 因为computed默认是缓存的,只有依赖项变化才会重新计算,所以oldParams和newParams如果一样的话其实不会触发watch,但为了保险可以再写一下
if (JSON.stringify(newParams) === JSON.stringify(oldParams)) return
// 防抖的代码后面加
console.log('发起搜索,参数:', newParams)
})
这种写法的好处是:第一,逻辑拆分得很清楚,预处理的逻辑全在computed里,watch只负责处理有效的搜索;第二,computed有缓存,只有依赖的ref变化才会重新计算,比直接在watch里写一堆if判断更高效;第三,可以在computed里顺便做一些数据清洗的工作,比如trim搜索词、检查价格区间的大小(比如用户把priceMin设成1000,priceMax设成500,就自动把它们反过来),让watch里的代码更简洁。
进阶优化二:结合防抖节流,避免接口被刷
刚才朋友那个炸接口的问题,核心原因就是没有加防抖,那什么是防抖什么是节流?防抖就是“等用户操作停下来一段时间之后再执行”,比如搜索框打字,等用户停了300ms再发请求;节流就是“每隔一段时间最多执行一次”,比如滚动监听,每隔200ms最多计算一次位置,在多值监听的搜索场景里,防抖更常用,因为用户的操作是连续的,停下来之后才真正需要结果。
那在Vue3里怎么结合防抖和watch呢?这里要注意,不能直接在watch的回调里写setTimeout然后clearTimeout,因为每次watch触发都会创建一个新的setTimeout,clearTimeout可能清不到上一个,正确的做法是先封装一个防抖函数,或者用第三方库(比如lodash的debounce,但要注意正确引入和使用,不然容易失效),然后把回调函数传给防抖函数,再把防抖后的函数作为watch的第二个参数。
先讲自己封装防抖函数的做法,这样不用引入第三方库,代码体积更小:
// 封装一个通用的防抖函数
function debounce(fn, delay = 300) {
let timer = null
return function(...args) {
// 每次调用都清除上一个定时器
clearTimeout(timer)
// 延迟delay毫秒后执行fn
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
// 然后修改刚才的watch
const handleSearch = debounce((params) => {
console.log('发起防抖后的搜索,参数:', params)
// 这里写真实的接口请求,比如axios.get('/api/products', { params })
}, 500) // 搜索场景建议延迟300-500ms,用户体验比较好
watch(validSearchParams, (newParams) => {
if (!newParams) return
// 注意:如果用了第三方库的debounce,比如lodash的,要确保是普通函数不是箭头函数,不然apply绑定this没用,但这里我们自己封装的如果不传this的话其实也没问题,但还是按通用写法来
handleSearch(newParams)
})
如果是用lodash的话,要注意安装和引入:
npm install lodash-es --save # 推荐用lodash-es,支持tree-shaking,打包体积小
import { debounce } from 'lodash-es'
// 然后和刚才一样,先定义handleSearch,再传进watch
这里有个坑,很多人刚用Vue3的时候会在watch的回调里直接写debounce,
// ❌ 错误写法!
watch(validSearchParams, debounce((newParams) => {
if (!newParams) return
console.log('发起搜索')
}, 500))
这种写法为什么错呢?因为watch每次重新渲染或者重新初始化的时候(虽然组件没卸载一般不会,但如果是用v-if切换的组件可能会),都会创建一个新的debounce函数,timer也会变成新的,clearTimeout就清不到上一次组件实例里的timer了,还是会导致接口重复触发,所以一定要把debounce后的函数定义在watch外面,最好是在组件setup的顶层,这样整个组件生命周期里只有一个timer,能正确清除。
多来源监听怎么办?比如props、data、pinia/vuex混着来
刚才讲的都是监听组件内部的ref或者computed,但实际开发中,我们可能需要同时监听组件的props、内部的ref、还有pinia/vuex里的状态,比如还是那个电商搜索的例子,假设商品列表的排序方式是从父组件传过来的props(sortBy,price_asc'或者'sales_desc'),同时还要监听pinia里的“是否只看我的收藏”这个状态,那这种情况怎么处理?
其实很简单,watch的第一个参数的数组里,不管是props的属性、ref、computed、还是pinia的state(如果是用useStore的话,直接传store.state.xxx或者computed(() => store.state.xxx)就行),都可以混着放。
比如先看pinia的store:
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
onlyMyFavorites: false
})
})
然后是商品列表组件:
import { ref, computed, watch } from 'vue'
import { useUserStore } from '@/stores/user'
// 定义props
const props = defineProps({
sortBy: {
type: String,
default: 'default'
}
})
const userStore = useUserStore()
// 其他内部ref和之前一样,validSearchParams也可以加上sortBy和onlyMyFavorites
const searchKeyword = ref('')
// ... 其他默认值和ref
const validSearchParams = computed(() => {
// 之前的条件
const isKeywordValid = searchKeyword.value.length >= 2
const isCategoryChanged = categoryId.value !== DEFAULT_CATEGORY
const isPriceChanged = priceMin.value !== DEFAULT_PRICE_MIN || priceMax.value !== DEFAULT_PRICE_MAX
const isStockChanged = onlyHasStock.value !== DEFAULT_ONLY_STOCK
// 新增的props和pinia的条件
const isSortChanged = props.sortBy !== 'default'
const isFavoritesChanged = userStore.onlyMyFavorites
if (isKeywordValid || isCategoryChanged || isPriceChanged || isStockChanged || isSortChanged || isFavoritesChanged) {
return {
keyword: searchKeyword.value.trim(),
categoryId: categoryId.value,
priceMin: priceMin.value,
priceMax: priceMax.value,
onlyHasStock: onlyHasStock.value,
sortBy: props.sortBy,
onlyMyFavorites: userStore.onlyMyFavorites
}
}
return null
})
// 这里也可以直接不写validSearchParams,把所有要监听的混在数组里,但还是推荐用computed
// watch([searchKeyword, categoryId, () => props.sortBy, () => userStore.onlyMyFavorites], ...)
// 注意:props的属性和pinia的state如果是直接访问的话,要写成函数形式,不然watch监听的是初始值,不会响应变化!!!这个是超级大坑,很多人都会踩!
// 还是用validSearchParams更方便,不需要注意这个函数形式的问题,因为computed已经处理好了
watch(validSearchParams, (newParams) => {
if (!newParams) return
handleSearch(newParams)
})
刚才括号里提到的那个超级大坑一定要记住:如果watch的第一个参数里直接放props.xxx或者store.state.xxx,是不会响应变化的!因为props.xxx本身不是ref或reactive的响应式引用,只是一个普通的值,所以必须写成函数形式,) => props.sortBy,() => userStore.onlyMyFavorites,这样watch会自动把这个函数的返回值作为监听目标,当返回值变化时触发回调,但如果是用computed的话,就不需要注意这个问题,因为computed本身就是响应式的,依赖项变化会自动更新返回值,watch监听computed就相当于监听所有依赖项的变化。
watch和watchEffect在多值场景下怎么选?
很多刚转Vue3的人都会搞混watch和watchEffect,这里简单对比一下,然后说多值场景下各自适合的情况。
watch是“显式监听”,你要明确告诉它要监听哪些值,它才会去监听;而watchEffect是“隐式监听”,它会自动收集回调函数里用到的所有响应式数据,只要其中一个变了,就会触发回调。
那多值场景下怎么选呢?
适合用watch的情况
刚才讲的电商搜索场景就非常适合用watch,原因有几个:第一,我们需要监听的是特定的几个值,不是回调里用到的所有值(比如回调里可能用到loading这个ref,但我们不需要监听loading的变化);第二,我们需要拿到旧值和新值做对比;第三,我们不需要立即执行(虽然可以加immediate,但显式加更清楚);第四,我们可以加deep和immediate的配置,更灵活。
适合用watchEffect的情况
比如有个场景:你要把用户的搜索条件自动保存到localStorage里,不管哪个搜索条件变了,都要保存,而且不需要旧值,也不需要配置什么deep或者immediate(因为watchEffect默认会立即执行一次),这时候用watchEffect就比watch方便很多,不用把所有搜索条件都列在数组里,它会自动收集。
举个例子:
import { ref, watchEffect } from 'vue'
const searchKeyword = ref('')
const categoryId = ref(0)
const priceMin = ref(0)
const priceMax = ref(9999)
const onlyHasStock = ref(false)
// 自动保存到localStorage
watchEffect(() => {
const params = {
keyword: searchKeyword.value,
categoryId: categoryId.value,
priceMin: priceMin.value,
priceMax: priceMax.value,
onlyHasStock: onlyHasStock.value
}
localStorage.setItem('searchParams', JSON.stringify(params))
})
// 组件挂载的时候可以从localStorage里读取
import { onMounted } from 'vue'
onMounted(() => {
const savedParams = localStorage.getItem('searchParams')
if (savedParams) {
const parsed = JSON.parse(savedParams)
searchKeyword.value = parsed.keyword
categoryId.value = parsed.categoryId
priceMin.value = parsed.priceMin
priceMax.value = parsed.priceMax
onlyHasStock.value = parsed.onlyHasStock
}
})
这种写法是不是比watch简洁很多?不用列5个ref在数组里,也不用加immediate,默认就会执行一次,但这里要注意,watchEffect里不要写太多复杂的逻辑,也不要写调接口的代码(除非你确实需要所有用到的响应式数据变化都调接口,但一般这种情况很少,而且容易导致性能问题),最好只写数据同步、日志记录这种简单的操作。
总结一下多值监听的最佳实践
现在把刚才讲的内容整理一下,给大家几个多值监听的最佳实践:
- 优先用computed预处理:如果要监听的多个值需要合并、清洗、或者加判断逻辑,优先用computed先预处理,再监听computed的返回值,这样逻辑更清晰,性能也更好。
- 注意props和外部状态的监听方式:如果不用computed,直接监听props.xxx或者pinia/vuex的state.xxx,一定要写成函数形式,不然不会响应变化。
- 结合防抖节流:搜索、输入这种连续操作的场景,一定要加防抖;滚动、拖拽这种高频但不需要每次都执行的场景,加节流,避免接口被刷或者前端性能问题。
- 选对watch和watchEffect:显式监听特定值、需要旧值、需要配置deep/immediate的情况用watch;自动收集依赖、不需要旧值、需要立即执行的简单操作(比如数据同步、日志记录)用watchEffect。
- 避免重复逻辑:如果多个独立watch触发的是同一段逻辑,一定要合并成一个多值监听,不然代码冗余,而且容易出现时序问题(比如多个watch同时触发,接口返回的顺序可能不对)。
最后再提醒大家一下,刚才那个炸接口的朋友,我帮他把5个独立watch合并成了一个带computed预处理和防抖的watch之后,接口瞬间就不炸了,前端的列表渲染也流畅了很多,所以多值监听虽然看起来简单,但里面的细节和坑还是不少的,大家在开发的时候一定要注意这些问题,不然不仅自己的代码质量不高,还可能影响到同事的工作。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


