Vue3 里怎么用 watch 同时监听多个数据源?
在写Vue3项目的时候,很多同学会碰到需要同时监听多个数据变化的场景,比如表单里多个字段联动、页面状态和路由参数一起变化时执行逻辑……那Vue3的watch到底怎么实现多数据源监听?不同情况要注意什么?今天就把这块儿掰开揉碎讲清楚。
watch监听多个数据源的基础写法
Vue3的watch API支持把多个监听源放到一个数组里,不管这些源是ref、reactive的属性,还是函数返回的响应式值,只要有一个变化,回调就会触发,先看最基础的例子:
import { ref, watch } from 'vue'
// 定义两个ref变量
const searchKey = ref('')
const page = ref(1)
// 把要监听的源放进数组
watch([searchKey, page], (newValues, oldValues) => {
console.log('搜索关键词或页码变了:', newValues, oldValues)
// newValues是 [searchKey新值, page新值]
// oldValues是 [searchKey旧值, page旧值]
})
// 模拟数据变化
searchKey.value = 'Vue3' // 触发回调
page.value = 2 // 触发回调
这里有几个关键点:
- 监听源是数组形式,数组里可以放
ref、返回响应式值的函数(() => reactiveObj.prop)、computed等; - 回调函数的第一个参数
newValues是新值数组,顺序和监听源数组一一对应;第二个参数oldValues是旧值数组,顺序也完全一致; - 只要数组里任意一个源发生变化,回调就会执行。
不同数据源类型的监听细节
Vue3里响应式数据分ref、reactive、computed等类型,不同类型在多数据源监听时,写法和注意事项不一样,得“对症下药”。
监听ref类型的多个数据源
ref是最常用的基础响应式类型(比如基本类型string/number,或对象/数组),监听多个ref时,直接把ref变量放进数组即可:
const count = ref(0)
const flag = ref(false)
watch([count, flag], (newVals, oldVals) => {
// count或flag变化时触发
})
但要注意:如果ref包裹的是对象/数组(比如const list = ref([])),修改对象内部属性(list.value.push(1))不会改变list的引用,这时候watch默认不会触发(因为ref的value引用没变化),这时候需要配合{ deep: true }开启深层监听:
const list = ref([{ id: 1, name: 'A' }])
watch([list], (newVal, oldVal) => {
console.log('list内部变化了')
}, { deep: true }) // 开启深层监听
list.value[0].name = 'B' // 触发回调
监听reactive对象的多个属性
reactive用于创建深层响应式对象(比如复杂的用户信息、表单数据),但监听reactive对象时,不能直接把对象属性丢进数组,因为reactive的属性不是ref,Vue无法跟踪“属性访问”的依赖,这时候得用函数返回值的形式:
const user = reactive({
name: '张三',
settings: {
darkMode: false,
lang: 'zh'
}
})
// 错误写法:直接传user.name,无法触发监听
watch([user.name, user.settings.darkMode], () => {})
// 正确写法:用函数返回要监听的属性
watch([() => user.name, () => user.settings.darkMode], (newVals, oldVals) => {
console.log('用户名或深色模式变了', newVals, oldVals)
})
如果想监听整个reactive对象的所有变化(比如user的任意属性修改),可以直接传reactive对象,并开启deep: true:
watch(user, (newUser, oldUser) => {
console.log('user任意属性变化', newUser, oldUser)
}, { deep: true })
user.settings.lang = 'en' // 触发回调
但这种写法性能开销大(因为深层遍历整个对象),所以更推荐“精确监听需要的属性”,减少不必要的响应式跟踪。
结合computed的情况
computed本身是“响应式的计算结果”,可以直接当作监听源,比如有个计算属性整合了多个数据,同时监听计算属性和其他源:
const first = ref('')
const last = ref('')
const fullName = computed(() => `${first.value} ${last.value}`)
watch([fullName, someOtherRef], (newVals, oldVals) => {
// fullName变化(即first或last变化),或someOtherRef变化时触发
})
computed作为监听源时,逻辑和ref类似——它的变化由依赖的响应式数据驱动,watch能自动感知。
多数据源监听时的回调逻辑设计
当多个数据源同时变化时,怎么在回调里区分“到底是哪个数据变了”?怎么处理新旧值?这里有几个实用技巧:
区分变化的数据源
回调里的newValues和oldValues是数组,顺序和监听源数组一一对应,比如监听[A, B, C],newValues[0]是A的新值,newValues[1]是B的新值……所以可以通过数组索引判断哪个源变化:
watch([name, age, city], (newVals, oldVals) => {
if (newVals[0] !== oldVals[0]) {
console.log('name变了')
}
if (newVals[1] !== oldVals[1]) {
console.log('age变了')
}
// ...
})
但要注意:如果多个源同时变化(比如一次操作里name和age都改了),回调只会执行一次(因为Vue的响应式更新是“批处理”的,多个依赖变化会合并触发)。
处理对象/数组的新旧值
如果监听源是对象或数组(比如ref包裹的对象、reactive的属性),oldValues和newValues可能是同一个引用(因为修改对象内部属性不会改变引用),这时候要拿到“真正的旧值”,需要手动处理:
- 开启
deep: true+ 深拷贝旧值const info = ref({ city: '北京' }) watch([info], (newVal, oldVal) => { // 深拷贝旧值(比如用JSON.parse(JSON.stringify(oldVal))) const oldInfo = JSON.parse(JSON.stringify(oldVal[0])) console.log('旧城市:', oldInfo.city, '新城市:', newVal[0].city) }, { deep: true })
info.value.city = '上海' // 触发回调,能拿到正确旧值
- 方法二:拆分监听具体属性(推荐)
直接监听基本类型的属性(() => info.value.city`),这样新旧值是基本类型,不会有引用问题:
```js
watch([() => info.value.city], (newCity, oldCity) => {
console.log('城市从', oldCity[0], '变到', newCity[0])
})
实际项目中的应用场景
光讲语法不够,结合真实场景才能理解“为什么需要多数据源监听”,分享3个常见场景,看看watch怎么解决问题。
表单多字段联动验证
注册/登录表单里,往往需要同时验证“用户名、密码、确认密码”等多个字段,实时更新按钮状态,用watch监听多个输入框的变化:
<template>
<div class="form">
<input v-model="username" placeholder="请输入用户名" />
<input v-model="password" type="password" placeholder="请输入密码" />
<input v-model="confirmPwd" type="password" placeholder="请确认密码" />
<button :disabled="!canSubmit">提交</button>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const username = ref('')
const password = ref('')
const confirmPwd = ref('')
const canSubmit = ref(false)
// 同时监听三个输入框
watch([username, password, confirmPwd], () => {
// 验证逻辑:用户名非空 + 密码≥6位 + 两次密码一致
const nameValid = username.value.trim().length > 0
const pwdValid = password.value.length >= 6
const pwdSame = password.value === confirmPwd.value
canSubmit.value = nameValid && pwdValid && pwdSame
})
</script>
这样用户每输入一个字符,都会实时验证,按钮状态自动更新,体验更流畅。
页面状态与路由参数联动
列表页中,“搜索关键词”(前端状态)和“当前页码”(路由参数)变化时,都需要重新请求接口,用watch同时监听ref和路由参数:
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const searchKey = ref('')
watch([searchKey, () => route.query.page], (newVals, oldVals) => {
const newKey = newVals[0]
const newPage = newVals[1]
// 调用接口请求数据
fetchList(newKey, newPage)
})
// 模拟搜索关键词变化
searchKey.value = 'Vue' // 触发请求
// 模拟路由参数变化(比如用户点击分页按钮,路由page从1→2)
// 路由变化时,route.query.page自动更新,触发watch
这里route.query.page是路由的响应式数据,用函数() => route.query.page包裹,确保watch能跟踪其变化。
多状态控制复杂UI
后台管理系统中,“深色模式”(darkMode)和“侧边栏展开状态”(isSidebarOpen)变化时,需要同时更新页面样式和布局,用watch监听两个状态:
import { reactive, watch } from 'vue'
const appState = reactive({
darkMode: false,
isSidebarOpen: true
})
watch([() => appState.darkMode, () => appState.isSidebarOpen], () => {
// 更新页面样式(比如给body加dark类)
document.body.classList.toggle('dark', appState.darkMode)
// 更新侧边栏样式(比如宽度、动画)
updateSidebarStyle(appState.isSidebarOpen)
})
// 模拟主题切换
appState.darkMode = true // 触发样式更新
// 模拟侧边栏收起
appState.isSidebarOpen = false // 触发样式更新
这种场景下,多个状态共同决定UI,watch能统一处理变化后的逻辑,避免代码分散。
常见问题与避坑指南
实际开发中,很多同学会碰到“监听不触发”“新旧值一样”“重复执行”等问题,这里总结5个高频坑,教你快速解决。
监听reactive对象时,回调不触发?
原因:监听reactive对象的单个属性时,没用到“函数返回值”的形式,导致Vue无法跟踪依赖。
错误示例:
const user = reactive({ name: '张三' })
watch([user.name], () => {}) // 错误!user.name不是ref,watch无法跟踪
正确写法:
watch([() => user.name], () => {}) // 用函数返回要监听的属性
多个数据源同时变化,回调执行多次?
Vue的响应式系统是批处理更新的——同一轮事件循环中,多个依赖变化会合并触发,所以回调只会执行一次。
watch([a, b], () => {
console.log('触发')
})
a.value = 1
b.value = 2
// 回调只执行一次,因为a和b的变化在同一轮更新中
如果回调执行多次,大概率是因为代码里有异步操作(比如setTimeout、Promise.then)导致更新分散到多轮,这时候可以用watch的flush选项控制执行时机(比如flush: 'sync'强制同步执行,但谨慎使用,会影响性能)。
oldVal和newVal完全一样?
原因:监听的是对象/数组的引用,修改内部属性不会改变引用,所以新旧值是同一个对象。
解决方法:
- 拆分监听具体属性(比如监听
() => obj.prop,prop是基本类型); - 开启
deep: true并深拷贝旧值(适合监听整个对象的场景); - 用
ref包裹对象时,修改整个ref的value(比如obj.value = { ...obj.value, prop: 'new' }),这样引用变化,watch能拿到新值。
怎么停止watch的监听?
watch调用后会返回一个停止函数,调用它就能停止监听,通常在组件卸载时执行:
import { onUnmounted, watch } from 'vue'
const stopWatch = watch([a, b], () => {})
onUnmounted(() => {
stopWatch() // 组件卸载时停止监听,避免内存泄漏
})
监听computed时不触发?
computed的变化由依赖的响应式数据驱动,如果computed没触发,先检查:
- 依赖的响应式数据是否真的变化了?
computed是否有缓存(比如依赖没变化时,computed不会重新计算)?
举个例子:
const num = ref(1)
const double = computed(() => num.value * 2)
watch(double, () => {
console.log('double变了')
})
num.value = 2 // 触发double更新,watch回调执行
num.value = 2 // 依赖没变化,computed不更新,watch不触发
和watchEffect的区别(延伸知识点)
很多同学分不清watch和watchEffect,这里简单对比“多数据源监听”场景下的用法:
| 特性 | watch | watchEffect |
|---|---|---|
| 监听源 | 必须显式声明要监听的源 | 自动收集依赖(回调里用到的响应式数据) |
| 新旧值 | 能拿到新旧值 | 拿不到旧值,只有当前值 |
| 执行时机 | 数据源变化时执行 | 初始化时执行一次,之后数据源变化时执行 |
| 精确性 | 只监听声明的源,更可控 | 依赖自动收集,可能包含多余数据源 |
举个例子,用watchEffect实现“监听name和age”:
const name = ref('')
const age = ref(0)
watchEffect(() => {
console.log('name或age变了:', name.value, age.value)
})
这种写法更简洁,但无法区分到底是name还是age变化,也拿不到旧值,所以场景不同,选择不同:
- 需要精确控制监听源、需要新旧值 → 用
watch; - 只需要“响应式数据变化时执行逻辑”,不需要区分源 → 用
watchEffect。
看完这些,再碰到“同时监听多个数据”的需求,是不是心里有底了?记住核心逻辑:用数组装监听源,区分`ref`/`reactive`/`computed`的写法差异,处理好新旧值和回调逻辑,实际项目里多试试不同场景,踩过坑才记得牢~如果还有其他疑问,评论区随时聊~
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。