Code前端首页关于Code前端联系我们

Vue3 watch监听props怎么写才对?新手容易踩的7个坑+实战优化技巧

terry 2小时前 阅读数 23 #Vue

很多刚从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回调里的newUseroldUser同一个对象!因为引用没变,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的所有核心点,现在再总结一下:

  1. 什么时候用watch监听props:需要执行副作用操作、需要派生复杂的异步状态、需要对比新旧值。
  2. 3种基础写法:监听单个普通值用getter函数、监听单个对象/数组加deep: true、用watchEffect自动追踪依赖。
  3. 7个必踩坑:不用getter包装、深度监听以为新旧值不同、直接修改props、忘记immediate、用多个watch监听多个props、异步请求没有清理、滥用watch不用computed。
  4. 3个高阶优化:用watchPostEffect/watchSyncEffect控制时机、用节流/防抖优化频繁触发、用provide/inject/pinia避免props drilling。

只要掌握了这些点,你就能轻松搞定Vue3 watch监听props的所有问题,再也不会踩坑了,如果还有什么疑问,欢迎在评论区留言讨论!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门