Vue3 watch监听props怎么写才对?新手容易踩的7个坑+实战优化技巧
很多刚从Vue2转过来的开发者,或者第一次用Vue3的小伙伴,上手watch监听props的时候总会遇到奇奇怪怪的问题:明明子组件props变了,watch怎么没触发?明明传的是一个普通值,子组件为什么不小心改了?是不是Vue3的watch跟Vue2的比,变得“复杂难搞”了?别慌,今天这篇文章就把所有关于Vue3 watch on props的核心点讲透,从基础写法、踩坑避坑到高阶实战优化,全给你整明白,看完绝对能解决你99%的相关问题。
什么情况下必须用watch监听props?
在讲具体写法之前,先得搞清楚这个前提——不是所有的props变化都需要watch来处理,滥用watch反而会让代码变得冗余、难以维护,甚至出现不必要的性能问题,那什么时候用才合适呢?
props变化时需要执行副作用操作
“副作用操作”是个编程术语,说人话就是除了修改组件自身状态或者渲染视图之外的操作,比如向服务器发请求获取新数据、调用第三方插件的API、改变浏览器的URL hash/query、打印日志(不过生产环境一般不用打印)、触发动画回调这些,举个例子,父组件传了一个“文章ID”的props给子组件,子组件每次拿到新ID都要去后台拉对应的文章内容,这时候就必须用watch监听这个ID了。
props变化时需要派生新的复杂状态
派生状态指的是从props或者组件自身state计算出来的值,简单的派生状态用computed就能搞定,比如把props传的价格乘以税率算含税价,但如果派生新状态需要异步逻辑或者复杂的条件判断(比如要根据多个props的组合情况,并且还要查本地缓存才能确定新状态),这时候watch就派上用场了,再举个例子,父组件传了“用户ID”和“搜索关键词”两个props给子组件,子组件需要先等搜索关键词输入停顿500ms,再结合用户ID去后台查对应的搜索建议,这个就得用watch + 防抖来做,computed可处理不了异步和延迟。
需要对比props变化前后的具体值
虽然computed可以拿到props的最新值,但它没法直接拿到变化前的旧值(除非你手动维护一个ref存旧值),如果你的需求是:只有当props的变化量超过某个阈值的时候才执行操作,比如父组件传的“温度值”,子组件只有温度上升或下降超过3度才触发温度预警提示,这时候就必须用watch了——watch会自动给你传新旧两个值,直接对比就行。
除了这三种情况,其他的尽量用computed或者v-if/v-show这类模板指令来处理,不然代码会越来越乱。
Vue3 watch监听props的3种基础写法,你用对了吗?
Vue3的watch主要有两种API:一种是watch()(非即时、非深度的默认监听,但是可以配置),一种是watchEffect()(即时执行、自动追踪依赖的监听),这两种API结合props的类型(普通值、对象/数组),可以衍生出3种最常用的监听写法,下面一一讲。
监听单个普通值props
普通值props指的是string、number、boolean、null、undefined这些基本数据类型的props,监听它们最简单,先看Vue3的<script setup>写法(现在官方推荐用setup语法糖,大部分场景都不用写setup函数了):
<!-- 父组件 Parent.vue -->
<template>
<div class="parent">
<h2>父组件:当前文章ID是 {{ articleId }}</h2>
<button @click="changeArticleId">切换下一篇文章</button>
<Child :article-id="articleId" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const articleId = ref(1)
const changeArticleId = () => {
articleId.value++
}
</script>
<!-- 子组件 Child.vue -->
<template>
<div class="child">
<h3>子组件:正在加载第 {{ currentArticleId }} 篇文章...</h3>
<div v-if="articleContent" class="article-content">{{ articleContent }}</div>
<div v-else-if="loading" class="loading">加载中...</div>
<div v-else class="error">加载失败</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
// 先声明props,注意setup语法糖里可以用defineProps直接声明,不需要引入
const props = defineProps({
articleId: {
type: Number,
required: true
}
})
const currentArticleId = ref(props.articleId)
const articleContent = ref('')
const loading = ref(false)
const error = ref(false)
// watch的第一个参数是监听源,第二个是回调函数,第三个是配置对象(可选)
// 监听普通值props,直接传props.articleId作为源就行
watch(
() => props.articleId, // 这里为什么要加箭头函数?后面踩坑会讲!
async (newId, oldId) => {
console.log(`文章ID从 ${oldId} 变成了 ${newId}`)
// 执行副作用操作:拉文章内容
loading.value = true
error.value = false
try {
// 模拟请求
await new Promise(resolve => setTimeout(resolve, 1000))
articleContent.value = `这是第 ${newId} 篇文章的内容,包含Vue3 watch on props的详细教程,`
} catch (e) {
error.value = true
} finally {
loading.value = false
}
}
)
</script>
这里有个非常重要的细节——监听普通值props的时候,第一个监听源参数最好加箭头函数包装成getter函数,虽然有时候不加(比如直接传props.articleId)也能触发,但Vue官方文档里明确推荐用getter函数,为什么?后面踩坑环节会重点说这个“隐形坑”。
监听单个对象/数组类型的props
对象和数组属于引用数据类型,直接传props.obj作为监听源的话,默认只能监听到obj这个引用本身的变化(比如父组件给obj重新赋值了一个新对象),而监听不到obj内部属性的变化(比如父组件只改了obj.name或者往arr里push了一个元素),这时候就需要用到watch的第三个配置对象里的deep: true属性了。
先看错误的默认监听例子:
<!-- 父组件 Parent.vue -->
<template>
<div class="parent">
<h2>父组件:当前用户信息</h2>
<p>姓名:{{ userInfo.name }}</p>
<p>年龄:{{ userInfo.age }}</p>
<button @click="changeUserName">只改姓名</button>
<button @click="resetUserInfo">重置整个用户</button>
<Child :user-info="userInfo" />
</div>
</template>
<script setup>
import { reactive } from 'vue'
import Child from './Child.vue'
const userInfo = reactive({
name: '张三',
age: 25
})
const changeUserName = () => {
userInfo.name = '李四' // 只改内部属性
}
const resetUserInfo = () => {
// 这里注意,如果用reactive定义的,直接整个赋值会破坏响应式,要改用Object.assign或者单独改属性
// 不过为了演示引用变化,我们这里把userInfo改成ref吧
// 哦对,刚才的示例用了reactive,演示引用变化的话换ref定义更方便
// 重新写一下父组件的script setup
}
</script>
<!-- 重新修改父组件的script setup,用ref定义userInfo -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const userInfo = ref({
name: '张三',
age: 25
})
const changeUserName = () => {
userInfo.value.name = '李四' // 只改内部属性,引用不变
}
const resetUserInfo = () => {
userInfo.value = { // 重新赋值,引用变了
name: '王五',
age: 30
}
}
</script>
<!-- 子组件 Child.vue -->
<template>
<div class="child">
<h3>子组件:监听到的用户信息变化次数:{{ count }}</h3>
</div>
</template>
<script setup>
import { ref, watch, defineProps } from 'vue'
const props = defineProps({
userInfo: {
type: Object,
required: true
}
})
const count = ref(0)
// 错误写法:默认只能监听引用变化
watch(props.userInfo, () => {
count.value++
console.log('监听到用户信息变化')
})
</script>
这时候你在父组件点击“只改姓名”,子组件的count不会变,console也不会打印;只有点击“重置整个用户”,才会触发。
那正确的深度监听写法呢?很简单,加个deep: true就行:
<!-- 子组件 Child.vue 修改后的watch -->
watch(
() => props.userInfo, // 同样,这里用getter函数包装更稳妥
(newUser, oldUser) => {
count.value++
console.log('监听到用户信息变化', newUser, oldUser)
},
{
deep: true // 开启深度监听
}
)
开启深度监听之后,不管是父组件改userInfo的内部属性,还是往userInfo里加新属性,或者重新赋值整个对象,都会触发watch,不过这里有个小问题——深度监听引用数据类型的时候,watch回调里的newUser和oldUser是同一个对象!因为引用没变,Vue只是深度遍历了对象内部的变化,但新旧值还是指向同一个内存地址,所以对比的时候没用,要是需要对比新旧值的具体属性,得自己手动深拷贝一份旧值存起来。
用watchEffect()自动监听props依赖
如果你不想手动指定监听源,想让Vue自动追踪回调函数里用到的所有响应式数据(包括props、组件自身的ref/reactive),并且组件初始化的时候就会自动执行一次回调,那可以用watchEffect()。
还是刚才拉文章内容的例子,用watchEffect()改写一下子组件:
<!-- 子组件 Child.vue -->
<script setup>
import { ref, watchEffect, defineProps } from 'vue'
const props = defineProps({
articleId: {
type: Number,
required: true
}
})
const articleContent = ref('')
const loading = ref(false)
const error = ref(false)
// watchEffect只有一个回调函数,没有配置对象(不过可以手动取消监听)
// 回调函数里用到的props.articleId会被自动追踪
watchEffect(async (onCleanup) => {
console.log(`watchEffect自动触发,当前文章ID:${props.articleId}`)
// 这里的onCleanup是清理函数,用来清除上一次副作用的影响,很重要!后面实战优化会讲
onCleanup(() => {
console.log('清理上一次的副作用')
// 比如可以取消上一次未完成的请求
})
loading.value = true
error.value = false
try {
await new Promise(resolve => setTimeout(resolve, 1000))
articleContent.value = `这是第 ${props.articleId} 篇文章的内容,用watchEffect自动追踪的,`
} catch (e) {
error.value = true
} finally {
loading.value = false
}
})
</script>
这时候你会发现,组件刚挂载的时候,watchEffect就会自动执行一次(因为初始化的时候会读取props.articleId),不需要像watch那样加immediate: true配置;而且每次props.articleId变化,它也会自动触发,很方便,但watchEffect也有缺点:它没法直接拿到变化前的旧值,而且回调函数里只要用到的任何响应式数据变了,都会触发,有时候可能会导致不必要的执行,所以要根据需求选择。
新手必踩的7个Vue3 watch on props坑,你中了几个?
讲完基础写法,接下来是最重要的踩坑环节,这些坑我身边很多新手都中过,甚至有些用了Vue3半年的开发者也没注意到,赶紧记下来。
坑1:监听普通值props时不用getter函数包装
刚才在基础写法一里提过,虽然有时候直接传props.articleId也能触发,但官方推荐用getter函数,为什么?因为如果直接传props.articleId,当props的类型是“解构赋值之后丢失响应式”的情况(不过setup语法糖里用defineProps解构的话,只要用toRefs或者toRef处理就不会,但如果不用的话就会),或者当父组件传的是一个“静态普通值加动态变化的普通值混合绑定”的时候?不对,更准确的原因是——Vue的watch监听源如果是一个响应式对象的属性,直接传属性值的话,监听的其实是属性值的原始值,而不是响应式连接;但如果用getter函数包装成() => props.articleId,监听的就是这个getter函数返回值的变化,Vue会自动追踪getter里用到的响应式数据,不管props的属性是怎么定义的,都不会出问题。
举个极端的例子(虽然平时不太会这么写,但能说明问题):
<!-- 父组件 Parent.vue -->
<template>
<Child :count="count" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import Child from './Child.vue'
const count = ref(0)
onMounted(() => {
// 延迟1秒再改count
setTimeout(() => {
count.value = 1
}, 1000)
})
</script>
<!-- 子组件 Child.vue -->
<script setup>
import { watch, defineProps, toRefs } from 'vue'
const props = defineProps(['count'])
const { count: destructuredCount } = props // 不用toRefs解构,普通值会丢失响应式
// 错误写法1:直接传props.count?其实这个不会丢,因为直接访问的是props对象的响应式属性
// 但错误写法2:直接传解构后的destructuredCount,肯定会丢
watch(destructuredCount, () => {
console.log('直接传解构后的count,触发了吗?') // 不会触发!
})
// 正确写法1:用getter函数包装props.count(不管解不解构,只要getter里用props就行)
watch(() => props.count, () => {
console.log('用getter包装props.count,触发了!') // 会触发
})
// 正确写法2:用toRefs解构后传ref
const { count: countRef } = toRefs(props)
watch(countRef, () => {
console.log('用toRefs解构后传ref,触发了!') // 会触发
})
</script>
为了保险起见,不管监听什么类型的props,都尽量用getter函数包装成监听源;如果是对象/数组,还可以用toRef或者toRefs把props的属性转成ref后再传,效果是一样的。
坑2:深度监听对象/数组props时以为newVal和oldVal不一样
刚才在基础写法二里也提过,深度监听引用数据类型的时候,newVal和oldVal是同一个对象,因为引用没变,只是内部属性变了,很多新手会在这里踩坑,比如想对比用户姓名有没有变,直接写if (newUser.name!== oldUser.name),结果发现不管怎么改,这个条件永远是false,因为newUser和oldUser是同一个东西,name肯定一样。
那怎么解决?很简单,手动深拷贝一份旧值存起来就行,Vue3没有内置的深拷贝函数,但我们可以用第三方库比如Lodash的cloneDeep,或者用JSON.parse(JSON.stringify())(不过这个有局限性,不能拷贝函数、正则、Date、循环引用的对象等),或者自己写一个简单的深拷贝函数。
用Lodash cloneDeep的例子:
<!-- 子组件 Child.vue -->
<script setup>
import { ref, watch, defineProps } from 'vue'
import { cloneDeep } from 'lodash-es' // 注意用es模块版本,不然setup语法糖里可能有问题
const props = defineProps({
userInfo: {
type: Object,
required: true
}
})
// 手动存一份旧值
let oldUserInfo = cloneDeep(props.userInfo)
watch(
() => props.userInfo,
(newUser) => {
// 对比新旧值的姓名
if (newUser.name!== oldUserInfo.name) {
console.log(`用户姓名从 ${oldUserInfo.name} 变成了 ${newUser.name}`)
}
// 对比完之后,更新旧值
oldUserInfo = cloneDeep(newUser)
},
{
deep: true
}
)
</script>
坑3:在子组件里直接修改props
这个是Vue2里就有的老坑,但很多新手在Vue3里还是会犯,Vue是单向数据流,父组件传props给子组件,子组件只能读取,不能直接修改,不然会报错(虽然开发环境才会报错,生产环境不会,但还是会破坏单向数据流,导致代码难以维护)。
比如下面的错误写法:
<!-- 子组件 Child.vue -->
<template>
<div class="child">
<p>姓名:{{ props.userInfo.name }}</p>
<button @click="props.userInfo.name = '赵六'">子组件直接改姓名</button>
<!-- 普通值props直接改的话,开发环境会直接报错 -->
<!-- <button @click="props.articleId = 10">子组件直接改文章ID</button> -->
</div>
</template>
这里要注意:如果props是引用数据类型(比如对象/数组),直接修改内部属性开发环境不会报错!但这也是违反单向数据流的,因为父组件的userInfo也会跟着变,万一父组件把同一个userInfo传给了多个子组件,所有子组件的状态都会乱。
那正确的做法是什么?如果子组件需要修改props,应该通过emit事件通知父组件,让父组件自己修改。
比如修改姓名的正确写法:
<!-- 子组件 Child.vue -->
<template>
<div class="child">
<p>姓名:{{ props.userInfo.name }}</p>
<button @click="changeName">子组件请求改姓名</button>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
userInfo: {
type: Object,
required: true
}
})
// 声明emit事件
const emit = defineEmits(['update-user-name'])
const changeName = () => {
// emit事件通知父组件,传新的姓名
emit('update-user-name', '赵六')
}
</script>
<!-- 父组件 Parent.vue -->
<template>
<div class="parent">
<h2>父组件:当前用户信息</h2>
<p>姓名:{{ userInfo.name }}</p>
<Child :user-info="userInfo" @update-user-name="handleUpdateUserName" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const userInfo = ref({
name: '张三',
age: 25
})
const handleUpdateUserName = (newName) => {
// 父组件自己修改
userInfo.value.name = newName
}
</script>
如果是普通值props,或者需要修改整个引用数据类型props,还可以用Vue3的v-model:propName语法糖,更简洁:
<!-- 用v-model:userInfo的例子,父组件 -->
<template>
<Child v-model:user-info="userInfo" />
</template>
<!-- 子组件 -->
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps(['userInfo'])
const emit = defineEmits(['update:userInfo'])
const changeUserInfo = () => {
emit('update:userInfo', { name: '赵六', age: 35 })
}
</script>
坑4:用watch监听props时忘记加immediate: true,导致初始化时不执行
这个也是常见的坑,比如刚才拉文章内容的例子,如果你用的是普通的watch,不加immediate: true的话,组件刚挂载的时候不会拉文章内容,只有当父组件第一次切换文章ID的时候才会拉,用户刚进来看到的就是空白的加载完成状态(除非你手动在onMounted里再调用一次拉数据的函数)。
比如错误的写法:
<!-- 子组件 Child.vue -->
<script setup>
import { ref, watch, defineProps, onMounted } from 'vue'
const props = defineProps(['articleId'])
const articleContent = ref('')
const loading = ref(false)
const fetchArticle = async () => {
loading.value = true
await new Promise(resolve => setTimeout(resolve, 1000))
articleContent.value = `第 ${props.articleId} 篇文章`
loading.value = false
}
// 错误写法:不加immediate,初始化不执行
watch(() => props.articleId, fetchArticle)
// 为了解决初始化不执行的问题,新手可能会在onMounted里再调用一次
onMounted(fetchArticle)
</script>
这样写虽然能解决问题,但代码冗余了,而且如果fetchArticle有清理逻辑的话,会更麻烦,正确的做法是加immediate: true配置:
<!-- 正确写法 -->
watch(
() => props.articleId,
fetchArticle,
{
immediate: true // 组件初始化时立即执行一次
}
)
</script>
如果用watchEffect的话,就不需要加这个配置,因为它默认就是即时执行的。
坑5:监听多个props时用多个watch,导致代码重复
有时候我们需要监听多个props的变化,比如父组件传了“用户ID”和“搜索关键词”两个props,只要其中一个变了,子组件就要重新拉搜索建议,很多新手会写两个watch,然后在两个回调里都调用拉搜索建议的函数,这样代码重复了。
正确的做法是把多个监听源放在一个数组里,传给watch的第一个参数:
<!-- 子组件 Child.vue -->
<script setup>
import { ref, watch, defineProps } from 'vue'
const props = defineProps({
userId: {
type: Number,
required: true
},
keyword: {
type: String,
default: ''
}
})
const suggestions = ref([])
const fetchSuggestions = async () => {
// 模拟请求
await new Promise(resolve => setTimeout(resolve, 500))
suggestions.value = [`${props.keyword}相关的用户${props.userId}的建议1`, `${props.keyword}相关的建议2`]
}
// 正确写法:监听多个props放在数组里
watch(
[() => props.userId, () => props.keyword],
// 回调函数的第一个参数也是数组,对应新旧值的数组
([newUserId, newKeyword], [oldUserId, oldKeyword]) => {
console.log(`userId从 ${oldUserId} 变成 ${newUserId},keyword从 ${oldKeyword} 变成 ${newKeyword}`)
fetchSuggestions()
},
{
immediate: true
}
)
</script>
如果这多个props里有对象/数组,还可以分别配置deep吗?不可以,数组监听源的话,deep配置是全局生效的,要么所有源都深度监听,要么都不深度监听,如果需要分别配置,那只能分开写watch,或者用更灵活的方式处理。
坑6:watchEffect里的异步请求没有清理,导致竞态条件
这个坑比较隐蔽,但很容易出问题,特别是在拉取数据的时候,什么是“竞态条件”?比如父组件传的文章ID从1快速变成2再变成3,子组件用watchEffect监听,会依次发起请求1、请求2、请求3,但如果网络不稳定,请求1比请求3晚回来,那子组件最后显示的就是文章1的内容,而不是最新的文章3的内容,这就叫竞态条件。
怎么解决?用watchEffect回调函数里的onCleanup清理函数,或者用AbortController取消未完成的请求。
用AbortController的例子:
<!-- 子组件 Child.vue -->
<script setup>
import { ref, watchEffect, defineProps } from 'vue'
const props = defineProps({
articleId: {
type: Number,
required: true
}
})
const articleContent = ref('')
const loading = ref(false)
const error = ref(false)
watchEffect(async (onCleanup) => {
console.log(`开始请求文章${props.articleId}`)
const controller = new AbortController()
const signal = controller.signal
// 清理函数:当watchEffect重新执行或者组件卸载时,会先执行这个
onCleanup(() => {
console.log(`取消文章${props.articleId}的请求`)
controller.abort()
})
loading.value = true
error.value = false
try {
// 模拟不稳定的网络,请求ID越小,延迟越长
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, 2000 - props.articleId * 500)
// 监听abort事件,取消定时器并reject
signal.addEventListener('abort', () => {
clearTimeout(timer)
reject(new Error('请求被取消'))
})
})
articleContent.value = `这是最新的文章${props.articleId}的内容!`
} catch (e) {
if (e.name!== 'AbortError') { // 只有不是取消请求的错误才显示
error.value = true
console.error('请求失败', e)
}
} finally {
loading.value = false
}
})
</script>
这时候你快速点击父组件的切换按钮,比如从1到2到3,会看到控制台依次打印“开始请求1→取消1→开始请求2→取消2→开始请求3”,最后只显示文章3的内容,完美解决竞态条件。
如果用的是普通的watch,也可以手动维护一个AbortController,在回调函数开始的时候先取消上一次的请求,效果一样,但watchEffect的onCleanup更方便,因为它会自动在重新执行或者组件卸载时调用。
坑7:滥用watch监听props,而不用computed
这个开头就提过,但还是要再强调一遍——简单的派生状态一定要用computed,不要用watch,比如父组件传了price和taxRate两个props,子组件要算含税价,用computed比用watch好太多了:
<!-- 错误写法:用watch -->
<script setup>
import { ref, watch, defineProps } from 'vue'
const props = defineProps(['price', 'taxRate'])
const totalPrice = ref(0)
watch(
[() => props.price, () => props.taxRate],
([newPrice, newTaxRate]) => {
totalPrice.value = newPrice * (1 + newTaxRate)
},
{
immediate: true
}
)
</script>
<!-- 正确写法:用computed -->
<script setup>
import { computed, defineProps } from 'vue'
const props = defineProps(['price', 'taxRate'])
const totalPrice = computed(() => props.price * (1 + props.taxRate))
</script>
用computed的好处:第一,代码更简洁;第二,computed有缓存,只有当依赖的price或taxRate变化时才会重新计算,而watch每次变化都会执行回调(虽然这里效果一样,但复杂的计算场景下,computed的缓存能提升性能);第三,computed是只读的(除非写getter/setter),能防止不小心修改派生状态。
Vue3 watch on props的3个高阶实战优化技巧,让你的代码更优雅
讲完基础和踩坑,接下来是高阶实战优化,能让你的代码更优雅、性能更好。
技巧1:用watchPostEffect和watchSyncEffect控制监听的执行时机
Vue3除了默认的watchEffect(其实它的别名是watchPreEffect),还有watchPostEffect和watchSyncEffect,用来控制监听回调的执行时机。
- watchPreEffect(默认的watchEffect):在组件更新之前执行,也就是在DOM更新之前执行。
- watchPostEffect:在组件更新之后执行,也就是在DOM更新之后执行,相当于Vue2的
$nextTick里执行。 - watchSyncEffect:同步执行,也就是一旦依赖的响应式数据变化,立即执行回调,不等待任何生命周期。
什么时候用watchPostEffect?比如你需要在props变化导致DOM更新之后,操作DOM元素的属性或者调用第三方插件的API(比如需要获取元素的宽高),这时候用watchPostEffect就不用再手动包一层nextTick了。
举个例子:父组件传了一个“标题文字”的props给子组件,子组件需要在标题DOM更新之后,获取标题的宽度,然后根据宽度调整字体大小。
<!-- 子组件 Child.vue -->
<template>
<div class="child">
<h3 ref="titleRef" class="title">{{ props.title }}</h3>
</div>
</template>
<script setup>
import { ref, watchPostEffect, defineProps } from 'vue'
const props = defineProps({ {
type: String,
required: true
}
})
const titleRef = ref(null)
watchPostEffect(() => {
if (!titleRef.value) return
// DOM已经更新了,直接获取宽度
const titleWidth = titleRef.value.offsetWidth
console.log(`标题宽度:${titleWidth}`)
// 调整字体大小
if (titleWidth > 300) {Ref.value.style.fontSize = '16px'
} else {Ref.value.style.fontSize = '24px'
}
})
</script>
<style scoped>{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
watchSyncEffect一般用得比较少,因为它可能会导致性能问题(比如频繁同步执行回调),只有在极少数需要立即响应的场景下才用,比如监听某个props来同步修改另一个内部ref的初始值(不过这个用computed或者immediate: true的watch也能搞定)。
技巧2:用节流/防抖优化频繁触发的props监听
比如父组件传了一个“搜索关键词”的props给子组件,用户在父组件的输入框里快速输入,子组件每次关键词变化都要发请求,这样会给服务器造成很大的压力,而且用户体验也不好(请求太多,加载状态跳来跳去),这时候就需要用防抖(debounce)或者节流(throttle)来优化。
防抖的意思是:当事件触发后,延迟一段时间再执行回调,如果在这段时间内事件又触发了,就重新计时,比如输入搜索关键词,延迟500ms再发请求,用户快速输入的时候不会发请求,只有停顿500ms之后才会发,适合搜索场景。
节流的意思是:当事件触发后,执行一次回调,然后在一段时间内不管事件触发多少次,都不执行回调,直到这段时间过去,比如滚动加载更多,适合用节流,不管滚动多快,每隔500ms才检查一次是否到底部。
Vue3没有内置的节流/防抖函数,但可以用第三方库比如Lodash的debounce和throttle,或者自己写一个简单的。
用Lodash debounce优化搜索的例子:
<!-- 父组件 Parent.vue -->
<template>
<div class="parent">
<input v-model="keyword" placeholder="输入搜索关键词" />
<Child :keyword="keyword" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const keyword = ref('')
</script>
<!-- 子组件 Child.vue -->
<script setup>
import { ref, watch, defineProps } from 'vue'
import { debounce } from 'lodash-es'
const props = defineProps({
keyword: {
type: String,
default: ''
}
})
const suggestions = ref([])
const loading = ref(false)
// 定义防抖的搜索函数
const fetchSuggestionsDebounced = debounce(async (currentKeyword) => {
if (!currentKeyword.trim()) {
suggestions.value = []
return
}
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 500))
suggestions.value = [`${currentKeyword}建议1`, `${currentKeyword}建议2`, `${currentKeyword}建议3`]
} catch (e) {
console.error('搜索失败', e)
} finally {
loading.value = false
}
}, 500)
// 监听keyword变化,调用防抖函数
watch(
() => props.keyword,
(newKeyword) => {
fetchSuggestionsDebounced(newKeyword)
},
{
immediate: true // 初始化时也执行一次
}
)
// 重要!组件卸载时要取消防抖函数的定时器,不然可能会有内存泄漏
import { onUnmounted } from 'vue'
onUnmounted(() => {
fetchSuggestionsDebounced.cancel()
})
</script>
这里有个重要的点:组件卸载时一定要取消防抖/节流函数的定时器,不然可能会有内存泄漏,或者在组件卸载后还执行回调,导致报错。
技巧3:用provide/inject + pinia/vuex管理全局状态,避免深层组件的props层层传递和watch
如果你有一个深层嵌套的组件树,比如Parent→Child1→Child2→Child3,Child3需要用到Parent的某个props,这时候就需要层层传递props(俗称“props drilling”),如果Child3还要监听这个props的变化,每个中间组件都要传props,代码会变得非常冗余、难以维护。
这时候就可以用provide/inject来传递数据,或者用pinia/vuex来管理全局状态,这样Child3可以直接获取数据,不需要中间组件传递,监听也更方便。
用pinia的例子(pinia是Vue3官方推荐的状态管理库,比vuex4更简单):
首先安装pinia:npm install pinia
然后在main.js里引入:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
然后创建一个store:
// stores/article.js
import { defineStore } from 'pinia'
export const useArticleStore = defineStore('article', {
state: () => ({
articleId: 1,
articleContent: '',
loading: false,
error: false
}),
actions: {
async fetchArticle() {
this.loading = true
this.error = false
try {
await new Promise(resolve => setTimeout(resolve, 1000))
this.articleContent = `这是第 ${this.articleId} 篇文章的内容,用pinia管理的,`
} catch (e) {
this.error = true
} finally {
this.loading = false
}
},
setArticleId(newId) {
this.articleId = newId
}
}
})
然后在Parent组件里修改store的状态:
<!-- 父组件 Parent.vue -->
<template>
<div class="parent">
<h2>父组件:当前文章ID是 {{ articleStore.articleId }}</h2>
<button @click="changeArticleId">切换下一篇文章</button>
<Child1 />
</div>
</template>
<script setup>
import Child1 from './Child1.vue'
import { useArticleStore } from '../stores/article'
// 获取store
const articleStore = useArticleStore()
const changeArticleId = () => {
articleStore.setArticleId(articleStore.articleId + 1)
}
</script>
然后在Child3组件里直接使用store的状态和监听:
<!-- 子组件 Child3.vue -->
<template>
<div class="child3">
<h3>Child3:正在加载第 {{ articleStore.articleId }} 篇文章...</h3>
<div v-if="articleStore.articleContent" class="article-content">{{ articleStore.articleContent }}</div>
<div v-else-if="articleStore.loading" class="loading">加载中...</div>
<div v-else class="error">加载失败</div>
</div>
</template>
<script setup>
import { useArticleStore } from '../stores/article'
import { watch } from 'vue'
const articleStore = useArticleStore()
// 监听store的articleId变化,调用fetchArticle
watch(
() => articleStore.articleId,
() => {
articleStore.fetchArticle()
},
{
immediate: true
}
)
</script>
这样就完全避免了props drilling,代码更清晰、维护性更好,如果你的应用比较大,强烈推荐用pinia来管理状态。
今天这篇文章从前提条件、基础写法、踩坑避坑到高阶实战优化,全面讲解了Vue3 watch on props的所有核心点,现在再总结一下:
- 什么时候用watch监听props:需要执行副作用操作、需要派生复杂的异步状态、需要对比新旧值。
- 3种基础写法:监听单个普通值用getter函数、监听单个对象/数组加deep: true、用watchEffect自动追踪依赖。
- 7个必踩坑:不用getter包装、深度监听以为新旧值不同、直接修改props、忘记immediate、用多个watch监听多个props、异步请求没有清理、滥用watch不用computed。
- 3个高阶优化:用watchPostEffect/watchSyncEffect控制时机、用节流/防抖优化频繁触发、用provide/inject/pinia避免props drilling。
只要掌握了这些点,你就能轻松搞定Vue3 watch监听props的所有问题,再也不会踩坑了,如果还有什么疑问,欢迎在评论区留言讨论!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



