Vue3怎么同时监听两个变量?几种场景下的最佳实践分别是什么?
今天整理最近群里和后台留言的问题,发现Vue3里watch监听多变量这块儿提问率特别高——有人直接搜“能不能watch两个data”,有人疑惑数组监听会不会只触发一次,还有人纠结 computed 和 watch 同时监听两个变量到底选哪个,刚好最近自己做小电商后台库存联动模块时踩过一两个小坑,今天就把所有场景和注意事项串起来讲明白,都是亲测有效的代码和经验,看完肯定能解决90%的类似需求。
Vue3同时监听两个变量的核心:只有数组能当watch的第一个“源参数”
很多刚从Vue2转过来的朋友,或者刚入门的新手,上来可能会写这样的代码:
import { ref, watch } from 'vue'
const a = ref(1)
const b = ref(2)
// 错误写法!!
watch(a, b, () => { console.log('a或b变了') })
这段代码完全跑不起来,Vue3会直接报错“Expected a function or ref or reactive object or array of these as the first argument”——说白了,第一个参数只能放单个“可响应源”,或者把多个可响应源装在数组里传进去,单个可响应源是什么?ref值、reactive对象、或者返回这些值的getter函数,记住这三个就行。
那正确的基础写法应该是这样?先别急,别急着复制粘贴,先看下面两个小细节,这是最容易忽略但影响结果的地方。
细节1:数组监听默认是“浅层”还是“深层”?
假设我们要监听的不是两个ref的普通数字,而是两个reactive数组的元素变化,或者reactive对象的某个嵌套属性,
import { reactive, watch } from 'vue'
const cart = reactive({ goods: [{ id:1, price:10 }] })
const inventory = reactive({ stock: [100] })
// 写法A:直接把reactive对象放数组里
watch([cart, inventory], () => {
console.log('写法A触发')
})
// 写法B:把getter函数放数组里,只取goods和stock的引用
watch([() => cart.goods, () => inventory.stock], () => {
console.log('写法B触发')
})
// 写法C:加deep:true
watch([() => cart.goods, () => inventory.stock], () => {
console.log('写法C触发')
}, { deep:true })
现在我们分别做几个操作,看看哪个写法触发:
- 直接替换整个cart对象的goods数组:
cart.goods = [{id:2, price:20}]- 写法A:触发(因为替换整个reactive属性引用了)
- 写法B:触发(替换了goods数组的引用)
- 写法C:触发(替换引用肯定触发)
- 只修改cart.goods[0].price:
cart.goods[0].price = 15- 写法A:触发?不对不对等下——哦对,直接传reactive对象到watch数组,默认就是深层监听!因为Vue3的reactive本身就是代理整个对象树,所以只要是cart或者inventory里的任何东西变,写法A都会触发。
- 写法B:不触发!因为只监听了goods数组的引用,price是数组里的元素,属于内部变化,引用没改,所以getter返回的东西没变,watch就不会理你。
- 写法C:触发了!因为加了deep:true,会递归监听getter返回值的内部变化。 那这里我们该怎么选?别贪方便直接选写法A,除非你真的需要监听cart和inventory的所有变动——不然如果这两个对象特别大,比如cart有一百条商品记录,库存有几十个仓库的信息,随便改个小细节写法A都触发,会造成不必要的性能损耗,写法B适合只关心数组/对象本身替换的情况,写法C是精准深层监听嵌套属性,性能会比写法A好很多,因为它只监听你getter返回的那个数组/对象的内部,不是整个reactive大对象。
细节2:能不能拿到两个变量的新值和旧值?
当然能!而且Vue3非常贴心,旧值和新值的顺序,和你数组里放可响应源的顺序是完全一致的,这点比有些人猜测的“谁先变谁在前”靠谱多了,比如我们把刚才的ref数字例子补全:
import { ref, watch } from 'vue'
const a = ref(1)
const b = ref(2)
watch([a, b], (newVals, oldVals) => {
console.log('a的新值:', newVals[0])
console.log('b的新值:', newVals[1])
console.log('a的旧值:', oldVals[0])
console.log('b的旧值:', oldVals[1])
})
// 改a
a.value = 3
// 控制台输出:
// a的新值:3
// b的新值:2
// a的旧值:1
// b的旧值:2
// 再同时改a和b(注意这里是同步改,不是异步!异步的话后面单独讲)
a.value = 5
b.value = 6
// 控制台只输出一次:
// a的新值:5
// b的新值:6
// a的旧值:3
// b的旧值:2
这里有个小亮点——同步修改数组里的多个可响应源,watch只会触发一次回调!这是Vue3的响应式更新队列机制起的作用,把同一事件循环里的所有响应式更新都收集起来,等事件循环结束后,统一触发一次回调,这样又省了性能,又避免了重复逻辑执行,那异步修改呢?比如setTimeout里同时改?那肯定是两次单独的同步修改,触发两次?等下等下试一下:
setTimeout(() => {
a.value = 7
b.value = 8
}, 1000)
// 控制台1秒后只输出一次!
// 哦对!因为setTimeout的回调函数也是一个单独的事件循环,在这个循环里,先改a再改b,还是同一批更新,所以还是只触发一次。
// 那如果两个setTimeout呢?
setTimeout(() => { a.value = 9 }, 2000)
setTimeout(() => { b.value = 10 }, 2001)
// 这里就是两个不同的事件循环了,所以触发两次回调,第一次只newVals[0]变,第二次只newVals[1]变。
这点要记牢,面试或者实际开发中判断触发次数很有用。
只需要两个变量都满足某个条件才触发?用getter函数返回布尔值
刚才说的基础写法是“任意一个变量变就触发”,但有时候我们有更严格的需求——比如电商后台的“提交商品审核”按钮,只有当商品名称输入框不为空,且商品价格大于0的时候,按钮才会变亮,同时可以记录“用户准备好了提交”的状态(用watch监听这个状态变化),这里有两种思路:
思路1:直接用computed存条件,watch监听computed
很多人会选这个,因为逻辑分开更清晰:
import { ref, computed, watch } from 'vue'
const productName = ref('')
const productPrice = ref(0)
// computed自动追踪依赖,productName或productPrice变就重新计算
const canSubmit = computed(() => productName.value.trim()!== '' && productPrice.value > 0)
// watch监听computed的变化,只有canSubmit从false变true,或者反过来的时候触发
watch(canSubmit, (newVal) => {
console.log('现在能不能提交?', newVal)
// 这里可以加你想执行的逻辑,比如调整按钮样式、埋点等
})
思路2:直接把getter函数返回布尔值当watch的源参数
如果你不需要把canSubmit这个值在模板里用(比如模板直接用productName和productPrice写条件),那可以不用computed,直接写:
import { ref, watch } from 'vue'
const productName = ref('')
const productPrice = ref(0)
// 直接传返回布尔值的getter
watch(() => productName.value.trim()!== '' && productPrice.value > 0, (newVal) => {
console.log('现在能不能提交?', newVal)
})
那这两个思路性能上有区别吗?其实几乎没有,因为computed本身也是通过内部的watchEffect实现的依赖追踪,只是computed会把计算结果缓存起来,只有依赖变的时候才重新计算,而思路2的getter每次watch检查的时候都会执行一次——不过检查的频率很低,Vue3的更新队列已经帮你优化过了,所以不用纠结,看个人习惯和需求(要不要在模板复用canSubmit)就行。
需要拿到两个变量的变化详情,但只想监听其中一个变量触发?比如用另一个变量当“开关”
这个场景有点绕,但实际开发中挺常见的——比如做一个“实时草稿保存”功能,用户输入的内容(content)变的时候,本来应该自动保存,但用户可以通过“暂停自动保存”的开关(isPaused)来控制:只有isPaused是false的时候,content变才保存;isPaused是true的时候,不管content怎么变都不保存,而且还要注意,不要写成同时监听content和isPaused——因为如果用户只是把开关从暂停改回来(isPaused变false),这时候content没变,我们不需要重新保存,不然会浪费服务器资源。
那该怎么写?很多新手可能会在同时监听的回调里加if判断:
// 新手容易踩的坑
watch([content, isPaused], (newVals) => {
if (!newVals[1]) {
console.log('保存草稿:', newVals[0])
}
})
这段代码的问题是什么?刚才我们说过,开关从true改false的时候,newVals[0](content)没变,但watch还是会触发一次回调,这时候if判断通过,就会保存一次一模一样的草稿,完全没必要,那怎么解决?只监听content,但在回调里加开关的判断不就行了?哦对!哦我刚才怎么没想到新手会绕这个弯子——其实这个场景根本不需要同时监听两个变量,只需要把另一个变量当普通的条件判断就行:
// 正确写法
watch(content, (newVal) => {
if (!isPaused.value) {
console.log('保存草稿:', newVal)
}
}, { immediate: false, deep: true }) // 草稿内容可能是富文本的对象,所以加deep:true
哦对哦,原来这么简单!但刚才新手绕的那个弯子,其实也可以延伸出另一个有用的场景——如果我们想让content变或者开关从true改false(这时候虽然content没变,但可能用户暂停前的最后一条草稿没保存?不过刚才说了最好不要,但如果确实有这个需求),那该怎么只触发一次content变和开关从true→false的回调,开关从false→true不触发?
这时候可以用数组监听,但在源参数里加个“过滤后的开关状态”——比如只监听isPaused从true变false的瞬间,把它包装成一个getter,当isPaused从true变false时返回true,其他时候返回false,然后同时监听content和这个getter:
import { ref, watch, watchEffect } from 'vue'
const content = ref('')
const isPaused = ref(true)
// 定义一个变量,用来记录上一次isPaused的值,避免用闭包太麻烦
let lastPaused = true
// watchEffect先初始化lastPaused
watchEffect(() => {
lastPaused = isPaused.value
})
// 包装过滤后的开关getter
const isResume = () => {
const current = isPaused.value
const res = lastPaused &&!current
// 这里必须在return前更新lastPaused吗?不对,因为watchEffect已经在追踪isPaused了,而且会在watch的getter执行之后再执行?哦等下Vue3的响应式更新顺序是:先执行依赖收集的副作用(比如watch的getter),再执行更新的副作用(比如watchEffect的回调),所以如果这里用watchEffect初始化lastPaused,可能会有顺序问题——那不如直接用闭包把lastPaused包在getter里?对,这样更安全,顺序也可控:
}
// 重新写包装,把lastPaused放在闭包里:
const createResumeChecker = () => {
let last = true
return () => {
const current = isPaused.value
const res = last &&!current
last = current
return res
}
}
const isResume = createResumeChecker()
// 现在同时监听content和isResume
watch([content, isResume], (newVals, oldVals) => {
// 两种情况执行保存:1.content真的变了(oldVals[0]!== newVals[0])且isPaused是false;2.isResume返回true(刚才的坑场景)
const isContentChanged = oldVals[0]!== newVals[0]
const justResumed = newVals[1]
if ((isContentChanged &&!isPaused.value) || justResumed) {
console.log('保存草稿:', newVals[0])
}
}, { deep: true, immediate: false })
这段代码虽然复杂了一点,但确实解决了刚才的特殊需求——不过还是那句话,如果不是特别必要,尽量不要搞这么复杂,普通场景下把另一个变量当条件判断就行。
需要同时监听两个变量,并且只在初始化的时候执行一次?或者每次都先执行一次?
这个其实不是同时监听两个变量的特殊场景,而是watch的通用配置项——immediate,同时监听的时候也能用,用法一模一样,刚才的实时草稿保存功能,如果我们想在组件刚挂载的时候,就先保存一次当前的空草稿(或者用户上次退出时留下的草稿),那可以加immediate:true:
watch([content, isPaused], (newVals) => {
if (!newVals[1]) {
console.log('保存草稿:', newVals[0])
}
}, { deep: true, immediate: true }) // 组件刚挂载就会执行一次回调
这里要注意,immediate:true的时候,oldVals数组里的所有值都是undefined,因为还没有发生过响应式更新,所以如果你的回调里用到了oldVals,最好加个判断:
watch([content, isPaused], (newVals, oldVals) => {
if (oldVals.some(v => v === undefined)) {
console.log('这是初始化触发的回调')
// 只保存草稿,不做其他需要oldVals的操作,比如埋点记录变化内容
} else {
console.log('这是变量变化触发的回调')
console.log('变化内容:a从', oldVals[0], '变到', newVals[0], ';b从', oldVals[1], '变到', newVals[1])
}
}, { deep: true, immediate: true })
什么时候用computed,什么时候用同时监听两个变量的watch?
最后我们来总结一下这个关键问题,很多人刚接触这两个API的时候会混淆:
用computed的情况
你需要根据两个变量计算出一个新的可响应值,并且这个值要在模板里复用,或者要被其他computed/watch依赖——比如刚才的canSubmit,商品总价=商品单价×商品数量”,这些都是典型的computed场景,因为核心是“得到一个新值”。
用同时监听两个变量的watch的情况
你需要在两个变量中的任意一个或同时变化时,执行一段副作用逻辑——比如发送网络请求(保存草稿、获取推荐数据)、修改DOM(虽然Vue3不推荐直接改DOM,但有时候确实需要)、埋点、调用第三方库的API等,核心是“执行动作”。
再举个例子区分一下:
- 商品单价(price)和数量(count)变,得到总价(total):用computed,因为total是新值。
- 商品单价(price)和数量(count)变,同时要根据总价(total)获取对应的折扣券列表:这里总价可以用computed,然后watch监听computed的total,发送请求;也可以直接同时监听price和count,在回调里计算total然后发送请求——两种都可以,但如果折扣券列表还需要在其他地方用total,那优先用computed+watch computed的方式,逻辑更清晰。
好啦,今天关于Vue3同时监听两个变量的所有内容就讲到这里,从基础写法、小细节,到三个常见场景(任意触发、条件触发、开关触发),再到和computed的区别,都讲得很细了,而且都是亲测有效的代码,如果还有其他Vue3的问题,欢迎在评论区留言,我会尽量整理成文章回答大家。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


