Vue3怎么同时watch多个响应式属性?不同场景的写法和避坑指南全整理
刚开始用Vue3的组合式API写项目时,我经常碰到需要监听多个变量联动变化的需求:比如电商网站里,用户切换商品SKU的颜色和尺寸时,要同时触发价格更新、库存检查和SKU图切换;或者后台管理系统中,筛选条件的时间范围、状态下拉、关键词输入任意一个变了,都要重新请求列表接口,这时候如果单独写好几个watch函数,不仅代码冗余,还可能因为触发顺序的问题出bug——比如先更新了状态,还没等到时间范围的新值就去调接口了,今天就把我摸索出来的所有实用写法、适用场景和踩过的坑,一次性说清楚。
基础写法:用数组包多个源
最直接的同时监听多个属性的方法,就是把这些源放在一个数组里传给watch的第一个参数,不管是ref、reactive的属性、计算属性还是getter函数返回的响应式值,都可以混着放进去。
举个最基础的电商SKU例子:假设页面有colorRef(商品颜色)、sizeRef(商品尺寸)两个ref,还有对应的reactive对象skuObj,属性是color和size,同时还有个根据这俩算出来的computed:isStockAvailableRef(计算是否有库存),现在想把这四个混着监听,每次变了都打印下所有值。
那代码可以这么写:
import { ref, reactive, computed, watch } from 'vue';
export default {
setup() {
// 定义几个不同类型的源
const colorRef = ref('红色');
const sizeRef = ref('M');
const skuObj = reactive({ color: '红色', size: 'M' });
const isStockAvailableRef = computed(() =>
colorRef.value === '红色' && sizeRef.value === 'L'
);
// 数组混放监听
watch(
[colorRef, sizeRef, () => skuObj.color, isStockAvailableRef],
([newColor, newSize, newSkuColor, newStockVal], [oldColor, oldSize, oldSkuColor, oldStockVal]) => {
console.log('新值数组:', [newColor, newSize, newSkuColor, newStockVal]);
console.log('旧值数组:', [oldColor, oldSize, oldSkuColor, oldStockVal]);
// 这里放你的业务逻辑,比如调价格接口
}
);
return { colorRef, sizeRef, skuObj, isStockAvailableRef };
}
};
这里有个小细节要注意:如果数组里直接放reactive对象本身,比如[skuObj],那默认是浅监听——只有当整个对象被替换(比如skuObj = reactive(...))才会触发,对象内部的属性变化是不会响应的,如果想监听reactive的整个对象变化,要么给watch加第三个参数{ deep: true },要么像例子里那样,把内部属性拆成getter函数() => skuObj.color一个个单独列出来。
基础写法的适用场景
这种数组写法最常用,适合所有需要“任意一个源变了就执行回调”的场景,而且回调里能拿到所有源的新值和旧值,逻辑可以写得很灵活,比如刚才提到的SKU场景,拿到新旧尺寸后,还可以判断是不是从缺货变到有货,要不要加个小弹窗提示用户。
进阶写法:用watchEffect结合解构,自动追踪依赖
如果你觉得写数组太麻烦,或者不确定到底哪些源会用到,可以试试watchEffect,它的核心逻辑是“第一次执行回调时自动收集所有用到的响应式依赖,之后只要有一个依赖变了,就重新执行回调”。
那刚才的SKU例子用watchEffect怎么写呢?可以先解构出需要的值,或者直接在回调里用:
import { ref, reactive, computed, watchEffect } from 'vue';
export default {
setup() {
const colorRef = ref('红色');
const sizeRef = ref('M');
const skuObj = reactive({ color: '红色', size: 'M' });
const isStockAvailableRef = computed(() =>
colorRef.value === '红色' && sizeRef.value === 'L'
);
// 直接用值,自动追踪
watchEffect(() => {
console.log('当前用到的源:');
console.log('colorRef.value', colorRef.value);
console.log('sizeRef.value', sizeRef.value);
console.log('skuObj.color', skuObj.color);
console.log('isStockAvailableRef.value', isStockAvailableRef.value);
// 业务逻辑
});
return { colorRef, sizeRef, skuObj, isStockAvailableRef };
}
};
这里有没有发现和基础写法的区别?第一,watchEffect不需要传依赖数组;第二,第一次渲染组件时它会自动执行一次(watch默认只有源变了才执行,除非加第三个参数{ immediate: true });第三,它的回调函数里拿不到旧值——只能拿到当前的新值;第四,如果想停止监听,需要提前存下来它的返回值,然后调用:
const stopWatchEffect = watchEffect(() => { /* ... */ });
// 比如组件卸载时停止,不过组合式API里setup返回的stop函数会自动在onUnmounted时执行,不用手动写
onUnmounted(() => stopWatchEffect());
进阶写法的适用场景和注意事项
watchEffect适合业务逻辑里用到的依赖源比较多、动态性强的场景——比如后台管理系统的复杂筛选,筛选条件可能根据权限动态添加或者隐藏,用watchEffect就不用每次修改筛选框的配置时,还要去改依赖数组。
但也要注意它的几个“坑”:
- 不能拿旧值:如果你的业务逻辑需要对比新旧值,比如判断关键词输入的变化幅度,或者统计筛选条件切换的次数,那watchEffect就不行了,得用watch。
- 自动收集依赖的范围:只有在回调函数同步执行的代码里用到的响应式值才会被追踪,异步代码里的不算,比如下面这个代码:
watchEffect(() => { // 这里的colorRef会被追踪,变了就会重新调接口 const color = colorRef.value; setTimeout(() => { // 这里的sizeRef不会被追踪,变了不会触发watchEffect console.log(sizeRef.value); }, 1000); });如果异步代码里也需要追踪某个源,那得把那个源移到同步代码里先“碰一下”,比如
const size = sizeRef.value;。 - 第一次默认执行:有时候我们第一次渲染时不想调接口,比如关键词输入为空时,调接口会返回所有数据,但我们想等用户输入至少3个字符再调,这时候要么在回调开头加个判断逻辑,要么用watch加
immediate: true然后手动控制是否执行业务。
特殊写法:用getter函数返回一个包含多个属性的对象
这种写法其实是基础数组写法的变形——不是把多个源放在数组里,而是放在一个对象里,然后用getter函数返回这个对象,它的好处是回调里拿到的新值和旧值也是对象,属性名对应着你定义的源,不用记数组的索引顺序,代码可读性更高。
还是刚才的SKU例子,改成对象写法:
import { ref, reactive, computed, watch } from 'vue';
export default {
setup() {
const colorRef = ref('红色');
const sizeRef = ref('M');
const skuObj = reactive({ color: '红色', size: 'M' });
const isStockAvailableRef = computed(() =>
colorRef.value === '红色' && sizeRef.value === 'L'
);
// 用getter返回包含多个属性的对象
watch(
() => ({
color: colorRef.value,
size: sizeRef.value,
skuColor: skuObj.color,
isStock: isStockAvailableRef.value
}),
(newVals, oldVals) => {
console.log('新值对象:', newVals);
console.log('旧值对象:', newVals.color); // 直接用属性名,不用索引0
console.log('旧值对象的旧尺寸:', oldVals.size);
// 业务逻辑
}
);
return { colorRef, sizeRef, skuObj, isStockAvailableRef };
}
};
这里又有个和基础数组写法类似的细节:getter返回的对象本身是普通对象,所以默认也是浅监听——只有当对象的属性值变了(比如colorRef.value从红色变成蓝色)才会触发,如果属性值是对象或者数组,内部变化默认不触发,比如把skuObj整个放进去:
// 这样默认不会监听skuObj内部的color和size变化
() => ({
color: colorRef.value,
sku: skuObj
})
如果想监听skuObj内部的变化,要么加{ deep: true },要么把skuObj拆成内部属性。
特殊写法的适用场景
这种对象getter写法最适合依赖源比较多(超过3个)的场景——数组索引记起来太麻烦,用对象属性名一眼就能看明白哪个新值对应哪个旧值,比如刚才提到的复杂后台筛选,筛选条件有10个,用对象写法的话,回调里的逻辑清晰得多。
避坑指南:这5个点90%的新手都会踩
刚才写每种写法的时候,其实已经提到了一些小细节,现在把它们整理成5个最容易踩的“大坑”,大家一定要注意:
避坑1:reactive对象直接传给watch的第一个参数
很多新手会这么写:
const skuObj = reactive({ color: '红色', size: 'M' });
watch(skuObj, (newVal, oldVal) => { /* ... */ });
以为这样就能监听color和size的变化,但实际上,watch默认是按值比较监听源的——对于普通对象/数组,是浅比较;对于ref,是比较value;对于getter函数返回的值,也是浅比较,而reactive对象本身是一个Proxy代理,它的引用地址是不会变的,所以直接传reactive对象的话,除非你把整个对象替换掉(比如skuObj = reactive(...),但reactive定义的对象不能直接替换,否则会失去响应式),否则永远不会触发watch。
正确的做法有两个:
- 拆成getter函数:
() => skuObj.color,() => skuObj.size,放在数组里。 - 加
{ deep: true }:但要注意,如果reactive对象很大很深,加deep会有性能问题,因为每次内部属性变化,Vue都要递归遍历整个对象比较新旧值。
避坑2:ref定义的数组/对象直接修改内部元素,watch没加deep
和避坑1类似,ref定义的数组/对象,比如const listRef = ref([1,2,3]),如果直接修改内部元素:listRef.value.push(4),因为listRef.value的引用地址没变,所以默认的watch也不会触发。
正确的做法:
- 加
{ deep: true }。 - 或者重新赋值整个数组/对象:
listRef.value = [...listRef.value, 4](数组),listRef.value = { ...listRef.value, name: '新名字' }(对象)——这种方法不用加deep,性能更好,但如果数组/对象很大,重新赋值的内存消耗会比直接修改大一点,大家可以根据实际情况选择。
避坑3:watchEffect的异步依赖问题
刚才已经举过例子了,再强调一遍:只有在watchEffect回调函数同步执行的代码里用到的响应式值才会被追踪,异步代码里的不算,如果异步代码里需要用到某个源,一定要在同步代码里先访问它一下,“碰一碰”让Vue知道这是个依赖。
避坑4:忘记加immediate,第一次渲染时业务逻辑不执行
watch默认只有当监听源发生变化时才会执行回调,第一次渲染组件时不会执行,但有些场景第一次渲染时就需要执行,比如SKU页面,刚打开就要显示默认颜色和尺寸的价格、库存;或者后台管理系统,刚打开就要显示默认筛选条件的列表。
这时候只要给watch加第三个参数{ immediate: true }就行:
watch(
[colorRef, sizeRef],
(newVals, oldVals) => {
// 第一次执行时,oldVals是undefined或者null?注意这里哦!
if (oldVals) {
// 有旧值,说明是用户切换的
}
// 业务逻辑
},
{ immediate: true }
);
这里还有个小细节:加了immediate后,第一次执行回调时,旧值数组(或者旧值对象)会是全undefined或者全null?不对,Vue3.3+之前是全undefined,Vue3.3+之后改成了全null?或者我记错了?其实不管是啥,加个判断逻辑就行,比如if (oldVals.every(val => val !== undefined))(数组写法),或者if (oldVals.color !== undefined)(对象写法),就能区分是第一次执行还是用户切换触发的。
避坑5:频繁触发导致的性能问题(防抖节流)
比如关键词输入的场景,用户每敲一个字就触发watch,然后就去调接口,这样会给服务器造成很大的压力,也会让页面出现卡顿的加载动画,这时候就需要给watch加防抖——等待用户停止输入一段时间(比如500毫秒)后,再去调接口。
Vue3的组合式API里没有内置防抖节流函数,但我们可以自己写一个简单的,或者用第三方库比如lodash的debounce和throttle。
用lodash debounce的例子:
import { ref, watch } from 'vue';
import debounce from 'lodash/debounce';
export default {
setup() {
const keywordRef = ref('');
const statusRef = ref('all');
// 定义防抖后的业务函数
const fetchList = debounce((keyword, status) => {
console.log('调接口,参数:', keyword, status);
// 这里写真实的接口请求代码
}, 500);
// 监听关键词和状态,调用防抖函数
watch(
[keywordRef, statusRef],
([newKeyword, newStatus]) => {
fetchList(newKeyword, newStatus);
}
);
// 组件卸载时要取消防抖,防止内存泄漏
onUnmounted(() => {
fetchList.cancel();
});
return { keywordRef, statusRef };
}
};
这里一定要注意:组件卸载时要调用防抖函数的cancel方法,否则防抖函数里的定时器还在运行,可能会造成内存泄漏,如果是自己写的防抖函数,也要记得在onUnmounted里清除定时器。
不同场景选哪种写法?
最后给大家整理一张“选型表”,方便大家快速找到适合自己的写法:
| 场景描述 | 推荐写法 | 核心优势 |
|---|---|---|
| 需要对比新旧值,依赖源≤3个 | 基础数组写法 | 代码简单,逻辑清晰 |
| 需要对比新旧值,依赖源>3个 | 对象getter写法 | 不用记数组索引,可读性高 |
| 不需要对比新旧值,依赖源多且动态 | watchEffect写法 | 自动追踪依赖,不用手动维护数组 |
| 不需要对比新旧值,依赖源固定且第一次要执行 | watch数组+immediate写法 或者 watchEffect+开头判断 | 两种都可以,看个人习惯 |
| 频繁触发的场景(比如关键词输入、窗口 resize) | 任意watch/watchEffect写法+防抖节流 | 提高性能,减少服务器压力 |
好了,以上就是我关于Vue3同时watch多个响应式属性的所有整理,都是我在项目里踩过坑、试过多次才总结出来的实用内容,如果大家还有其他问题,或者有更好的写法,欢迎在评论区留言交流!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



