Vue3中怎么高效监听多个props?不同场景下选哪种方式最靠谱?
很多刚从Vue2转过来的前端开发者,或者学Vue3组件传值监听刚入门的朋友,应该都碰到过这个场景:父组件传了不止一个props给子组件,子组件要在这些props变化的时候做统一的逻辑处理,比如发请求、更新本地状态、联动第三方库组件,这时候直接用单个watch去写N次?显然太啰嗦了,那有没有更优雅的方式?不同方式之间有啥坑?今天就拆解透这个问题,结合真实开发的场景给你实用的方案。
先快速回忆单个props的Vue3 watch怎么写?
在说多props之前,得先把单个props的基础写法捋清楚,不然多props的进阶逻辑容易混。
Vue3的组合式API里有两种监听方法:watch和watchEffect。watch是“懒执行”的——只有监听的源变化了才会触发回调;watchEffect是“立即执行+自动追踪依赖”的——初始化页面就会跑一次,之后用到的响应式数据(包括props)变化都会自动触发,不用显式列出来。
单个props的watch写法大概是这样的:
假设父组件传了个userId: Number的props,子组件要在它变的时候查用户信息。
<script setup>
import { watch, ref } from 'vue'
const props = defineProps(['userId'])
const userInfo = ref(null)
// 单个源,懒执行,有旧值新值
watch(
() => props.userId, // 这里注意:如果是基本类型的props,直接写props.userId也行?但最好用getter函数,因为组合式API里的props是响应式对象,但解构赋值(除非用toRefs/toRef)会丢失响应性,用getter更稳妥,不管后续改不改结构都没问题
(newId, oldId) => {
console.log(`用户ID从${oldId}变成了${newId}`)
// 这里写查用户的接口逻辑
}
)
</script>
单个源的watchEffect写法更短,但没有旧值,而且不能指定懒执行:
<script setup>
import { watchEffect, ref } from 'vue'
const props = defineProps(['userId'])
const userInfo = ref(null)
watchEffect(() => {
// 直接在这里用props.userId,系统会自动追踪
console.log(`当前用户ID是${props.userId}`)
// 查接口
})
</script>
Vue3 watch多props的核心4种写法,附适用场景和避坑指南
好了,基础铺垫完了,现在进入正题:多props监听。
写法1:数组形式的watch(最常用的Vue2转Vue3平滑过渡方案)
Vue2里很多人已经习惯了用数组列多个源,比如watch: { 'a,b'(newVal, oldVal) { ... } },Vue3完美保留了这个特性,但参数格式有点不一样——Vue2数组形式的旧值新值是对应的数组,比如a变了但b没动,b的旧值新值也是一样的;Vue3组合式API的数组形式也是这样。
适用场景
需要获取所有监听源的新值+旧值,或者监听源有基本类型和对象/数组混合的情况(这时候用getter函数统一处理所有源的响应性问题),而且要懒执行(初始化不触发,只在变化时触发)。
代码示例
还是刚才的用户查询,但这次父组件除了传userId,还传了个lang: String(切换语言的时候用户简介要变)、userOptions: Object(比如要不要显示头像、联系方式的配置)。
<script setup>
import { watch, ref, toRefs } from 'vue'
// 假设toRefs在这里只是做个对比的演示
const props = defineProps(['userId', 'lang', 'userOptions'])
// 如果要解构,必须用toRefs/toRef,不然基本类型和对象的属性变化会丢失响应性
// const { userId, lang } = toRefs(props)
// const isShowAvatar = toRef(props.userOptions, 'isShowAvatar')
const userInfo = ref(null)
const translatedBio = ref('')
watch(
// 数组里可以放:响应式对象的getter函数、ref变量、toRef/toRefs生成的ref变量
[() => props.userId, () => props.lang, () => props.userOptions.isShowAvatar],
// 回调的第一个参数是所有源的新值组成的数组,顺序和上面的数组一致
// 第二个参数是所有源的旧值组成的数组,顺序也一致
([newUserId, newLang, newShowAvatar], [oldUserId, oldLang, oldShowAvatar]) => {
// 可以先判断哪些源真的变了,避免不必要的逻辑执行
if (newUserId !== oldUserId) {
console.log('userId变了,重新查整个用户')
// 查完整用户信息的接口,返回的结果里包含不同语言的bio数组
}
if (newLang !== oldLang) {
console.log('lang变了,切换用户简介语言')
// 如果userInfo已经有数据了,直接切对应语言的bio,不用重新查接口
if (userInfo.value?.bioArr) {
translatedBio.value = userInfo.value.bioArr.find(b => b.lang === newLang)?.content || ''
}
}
if (newShowAvatar !== oldShowAvatar) {
console.log('是否显示头像的配置变了')
// 更新本地的UI控制变量
}
},
// 可选的配置项:immediate(立即执行)、deep(深层监听)
{ immediate: true } // 这里加immediate的话,初始化页面就会跑一次,把默认的lang对应的bio先取出来
)
</script>
避坑指南
- 必须注意数组顺序:回调里的新值旧值数组,顺序严格对应上面的监听源数组,千万不能写错,不然逻辑会全乱。
- 对象/数组属性监听的响应性问题:如果要监听props里对象的某个属性(比如上面的
userOptions.isShowAvatar),千万不能直接写props.userOptions.isShowAvatar,必须用getter函数() => props.userOptions.isShowAvatar——因为直接写的话,相当于把这个属性的初始值取出来当成了普通的基本类型变量,后续对象属性变化不会触发监听;同理,如果是监听整个对象/数组的变化但不需要深层监听,直接写() => props.userOptions;如果需要深层监听(比如整个对象的任何属性变了都触发),可以用() => props.userOptions再加上{ deep: true }配置。 - 所有源变化才触发?不,只要有一个变就触发:很多人以为数组形式的watch要所有监听源都变才会跑,其实不是——只要数组里的任意一个源变化,整个回调就会执行,所以最好像示例里那样,先对比每个源的新值旧值,只处理真的变了的部分,不然会有很多重复的接口请求或者逻辑处理。
写法2:单独写多个watch,但把重复逻辑抽成公共函数
刚才的数组形式虽然可以一次性处理,但如果监听源之间的逻辑关联度很低,或者每个监听源需要不同的配置项(比如有的要深层监听,有的要立即执行,有的不用),那单独写多个watch反而更清晰。
适用场景
监听源之间逻辑关联度极低(比如一个是查数据,一个是重置弹窗状态,一个是触发动画),或者每个监听源需要不同的配置项(immediate、deep的组合不一样)。
代码示例
还是刚才的三个props,但这次查数据、切换语言、重置头像UI的逻辑完全分开,而且查数据的要懒执行,切换语言和重置头像的要立即执行,切换语言还需要深层监听?(哦,切换语言是基本类型,不用深层监听,假设这次的userOptions里有个嵌套的对象比如displaySettings.fontSize,要深层监听这个嵌套对象的变化)
<script setup>
import { watch, ref } from 'vue'
const props = defineProps(['userId', 'lang', 'userOptions'])
const userInfo = ref(null)
const translatedBio = ref('')
const fontSize = ref('16px')
// 抽公共查询函数
const fetchUserById = (id) => {
console.log(`查询用户${id}`)
// 接口逻辑...
}
// 抽公共切换语言函数
const switchBioLang = (lang) => {
if (userInfo.value?.bioArr) {
translatedBio.value = userInfo.value.bioArr.find(b => b.lang === lang)?.content || ''
}
}
// 抽公共更新字号函数
const updateFontSize = (newFontSize) => {
fontSize.value = newFontSize
}
// 单独监听userId:懒执行,不需要旧值新值对比太多(因为只有一个源)
watch(
() => props.userId,
(newId) => {
fetchUserById(newId)
}
)
// 单独监听lang:立即执行
watch(
() => props.lang,
(newLang) => {
switchBioLang(newLang)
},
{ immediate: true }
)
// 单独监听userOptions的displaySettings.fontSize:深层监听整个displaySettings?或者直接监听嵌套属性的getter?
// 直接监听嵌套属性的getter更精准,只有这个属性变了才触发,不用整个displaySettings的其他属性变也触发
watch(
() => props.userOptions.displaySettings.fontSize,
(newFontSize) => {
updateFontSize(newFontSize)
},
{ immediate: true }
)
</script>
避坑指南
- 一定要抽公共逻辑:不然单独写多个watch就会出现大量重复代码,维护起来很麻烦。
- 根据需求选择精准的监听源:比如监听嵌套属性的时候,直接写嵌套属性的getter比监听整个对象再加deep更高效,因为deep监听会遍历整个对象的所有属性(包括嵌套的),性能开销比较大,尤其是对象比较大的时候。
写法3:用watchEffect自动追踪多个props
watchEffect的好处是不用显式列监听源,只要在回调里用到了,系统就会自动追踪,而且初始化就会跑一次,很适合“用什么数据就监听什么数据变化”的场景。
适用场景
监听源之间逻辑高度耦合(比如所有数据变化都要统一生成一个查询参数然后发请求),而且不需要旧值,可以接受立即执行。
代码示例
还是刚才的三个props,但这次父组件传的userOptions里除了isShowAvatar,还有pageSize、currentPage,子组件要在userId、lang、pageSize、currentPage任何一个变的时候,都发一个带这四个参数的接口请求(比如查某个用户的多语言评论列表,分页的)。
<script setup>
import { watchEffect, ref } from 'vue'
const props = defineProps(['userId', 'lang', 'userOptions'])
const commentList = ref([])
const loading = ref(false)
// 抽公共生成查询参数函数
const buildQueryParams = () => {
return {
userId: props.userId,
lang: props.lang,
pageSize: props.userOptions.pageSize,
currentPage: props.userOptions.currentPage
}
}
// 抽公共查询评论列表函数
const fetchCommentList = async (params) => {
loading.value = true
try {
console.log('查询评论,参数是', params)
// 接口逻辑...
// commentList.value = 接口返回的数据
} catch (err) {
console.error('查询评论失败', err)
} finally {
loading.value = false
}
}
watchEffect(() => {
// 直接在这里调用buildQueryParams和fetchCommentList,所有用到的props(包括嵌套的)都会被自动追踪
const params = buildQueryParams()
fetchCommentList(params)
})
</script>
避坑指南
- 没有旧值:如果需要对比变化前后的数据(比如从第一页变到第二页,要不要保留之前的评论列表做无限滚动?还是清空?从中文变到英文,要不要清空?),那watchEffect就不太合适了,必须用watch。
- 立即执行是强制的吗?不是:可以用
watchPostEffect(DOM更新后立即执行)或者watchSyncEffect(同步立即执行),但都没有懒执行的选项——如果一定要懒执行,只能用watch。 - 容易追踪到多余的依赖:比如在watchEffect的回调里不小心用到了某个本地的ref变量,那这个ref变量变化的时候也会触发回调,所以最好把回调里的逻辑写得纯粹一点,只用到需要监听的响应式数据。
- 嵌套对象/数组的追踪问题:和单个源的watch一样,直接在watchEffect里用props的对象/数组属性,系统会自动追踪到吗?比如上面的
props.userOptions.pageSize——是的,只要props的userOptions对象是响应式的(defineProps生成的默认就是响应式的),直接用嵌套属性也会被自动追踪,但如果是解构赋值后不用toRefs/toRef,就不会追踪到了。
写法4:用computed先把多个props合并成一个计算属性,再watch这个计算属性
这种写法稍微有点绕,但有时候很有用——比如要监听多个props的“组合变化”,比如只有当userId和lang同时变的时候才触发?不过其实组合变化用数组形式的watch加条件判断也能实现,但如果合并后的逻辑比较复杂,比如要把多个props转换成一个特定格式的对象,那用computed先合并,再watch计算属性,代码会更清晰。
适用场景
需要把多个props先转换成一个特定格式的响应式数据,再监听这个数据的变化,或者需要监听多个props的“特殊组合变化”(不过还是那句话,条件判断也能实现,但这种写法可读性可能更高,尤其是复杂转换的时候)。
代码示例
假设父组件传了userName: String、userAge: Number、userCity: String,子组件要在这三个props的任意一个变的时候,生成一个“用户卡片摘要”的响应式文本,然后根据这个文本的长度调整字体大小(比如超过20个字用14px,没超过用16px)。
<script setup>
import { watch, computed, ref, defineProps } from 'vue'
const props = defineProps(['userName', 'userAge', 'userCity'])
const cardSummaryFontSize = ref('16px')
// 先把多个props合并成一个计算属性
const userCardSummary = computed(() => {
return `我是${props.userName},今年${props.userAge}岁,来自${props.userCity}`
})
// 再watch这个计算属性
watch(
userCardSummary, // 注意:computed生成的是响应式的ref对象(如果返回的是基本类型)或者响应式的proxy对象(如果返回的是对象/数组),所以直接写变量名就行,不用getter函数
(newSummary) => {
console.log(`用户卡片摘要变成了:${newSummary}`)
// 根据摘要长度调整字体大小
cardSummaryFontSize.value = newSummary.length > 20 ? '14px' : '16px'
},
{ immediate: true } // 初始化页面也要根据默认的摘要长度调整字体大小
)
</script>
避坑指南
- computed必须返回响应式依赖的结果:就是说computed的回调里必须用到至少一个响应式数据(这里是三个props),不然computed不会是响应式的,watch它也没用。
- computed的缓存特性:computed有缓存,只有当它的依赖变化的时候才会重新计算,所以这种写法的性能其实和直接watch多个props差不多,但代码更清晰,尤其是转换逻辑复杂的时候。
怎么选择这4种写法?给你一张决策表
可能说了这么多,你还是有点纠结:到底什么时候用哪种?没关系,给你整理了一张简单的决策表,按照这个来选就不会错:
| 你的需求场景 | 推荐写法 |
|---|---|
| 需要所有监听源的新值+旧值,懒执行,逻辑关联度一般 | 写法1:数组形式的watch |
| 逻辑关联度极低,每个监听源需要不同的配置 | 写法2:单独多个watch+抽公共逻辑 |
| 逻辑高度耦合,不需要旧值,可以接受立即执行 | 写法3:watchEffect |
| 需要先把多个props转换成特定格式的响应式数据,再监听变化 | 写法4:computed合并后再watch |
再补充几个关于watch多props的高级技巧
技巧1:如何让数组形式的watch只在特定几个源同时变化的时候触发?
刚才说了,数组形式的watch只要有一个源变就触发,但有时候我们需要“同时变化”的情况,比如父组件是通过一个按钮同时修改userId和lang的,子组件只需要在这两个同时变的时候查一次接口,而不是先查userId变的,再查lang变的,这时候可以用条件判断:
watch(
[() => props.userId, () => props.lang],
([newUserId, newLang], [oldUserId, oldLang]) => {
// 只有当两个都变了的时候才触发
if (newUserId !== oldUserId && newLang !== oldLang) {
console.log('userId和lang同时变了,只查一次接口')
// 查接口...
}
}
)
不过这里有个问题:Vue的响应式更新是批量的,所以如果父组件是同步修改userId和lang的,子组件的watch只会触发一次;但如果父组件是异步修改的(比如先改userId,等100ms再改lang),那子组件的watch会触发两次,这时候条件判断就有用了。
技巧2:如何取消watch多props的监听?
有时候我们需要在组件销毁或者某个条件满足的时候取消监听,避免内存泄漏或者不必要的逻辑执行,组合式API里的watch和watchEffect都会返回一个“停止函数”,调用这个函数就能取消监听:
<script setup>
import { watch, onUnmounted } from 'vue'
const props = defineProps(['userId'])
// 保存停止函数
const stopWatch = watch(
() => props.userId,
(newId) => {
console.log('监听中...')
}
)
// 比如当用户点击某个按钮的时候取消监听
const handleCancel = () => {
stopWatch()
console.log('已取消监听')
}
// 或者在组件销毁的时候自动取消监听(其实Vue3组合式API里的watch/watchEffect默认会在组件销毁的时候自动取消,但如果是在组件之外或者动态创建的监听,最好手动取消)
onUnmounted(() => {
stopWatch()
})
</script>
技巧3:深层监听多个props的对象/数组,怎么优化性能?
刚才说了,deep监听的性能开销比较大,尤其是对象/数组比较大的时候,那怎么优化?
- 尽量用精准的嵌套属性getter:比如只监听
props.userOptions.isShowAvatar,而不是整个props.userOptions再加deep。 - 如果必须监听整个对象/数组的变化,但不需要知道具体哪个属性变了,可以用
JSON.parse(JSON.stringify())?不,这个太消耗性能了,尤其是大对象/数组的时候——更好的方法是用watch的回调里的新值旧值对比?不对,对象/数组的新值旧值是同一个引用的话(比如直接修改对象的属性,而不是替换整个对象),对比newVal === oldVal是没用的——这时候还是只能用精准的嵌套属性getter,或者用VueUse库里的watchDebounced(防抖监听,避免短时间内多次触发)、watchThrottled(节流监听,一定时间内只触发一次)。
Vue3监听多个props的核心4种写法都讲完了,还补充了几个高级技巧,其实没有最好的写法,只有最适合你当前场景的写法——按照决策表来选,再结合避坑指南和高级技巧,就能写出高效、清晰、易维护的代码了。
最后再提醒一句:不管用哪种写法,都要注意响应性问题(尤其是解构props的时候,一定要用toRefs/toRef),还有要避免不必要的逻辑执行(比如对比新值旧值、用精准的监听源、防抖节流)。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


