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

Vue3子组件怎么用watch正确监听props变化?踩坑解决方案全有

terry 5小时前 阅读数 86 #Vue
文章标签 Vue3props监听

开篇先聊聊为什么子组件要专门监听props变化?

不少刚从Vue2转Vue3,或者刚学Vue3的同学,可能一开始会有这个疑惑:父组件传过来的props,直接用不就行了?什么时候才需要加watch呢? 其实这个需求太常见了,举几个我开发中常遇到的场景:比如子组件是个数据仪表盘,父组件每隔10秒会传新的图表数据过来,仪表盘需要实时更新;再比如子组件是个表单弹窗,父组件传递“编辑的行数据id”和“是否弹窗显示”两个props,当id变的时候,子组件要自动从后端拉取对应的详情;还有更复杂的,比如props是个嵌套很深的对象,只改了其中某个属性,子组件要触发特定的交互逻辑。 这些情况,直接用插值或者计算属性可能实现不了——计算属性是依赖props生成新值,不能做副作用(比如发请求、操作DOM、改本地存储);插值只能展示数据变化,做不了逻辑联动,这时候watch的作用就体现出来了。

第一个核心问题:Vue3的watch基础用法和Vue2有啥不一样?

在说props监听之前,得先把Vue3 watch的基础逻辑搞清楚,不然踩坑了都不知道怎么回事。 Vue2的watch大家应该都熟,要么是组件选项里的watch对象,要么是实例化后用$watch,但Vue3是组合式API主导(当然也保留了选项式,但主流开发用组合式),组合式里的watch是一个从vue库导入的函数,不是选项了。 而且组合式的watch更灵活:它可以监听多个源(比如同时监听两个props或者一个ref一个props),可以直接用箭头函数拿到最新值和旧值,深度监听的配置更直观(加个deep参数),立即执行的配置也不用像Vue2那样写handler和immediate分开的对象,直接加immediate:true就行。

第二个核心问题:监听基础类型的props,直接传变量名就够了吗?

这个问题看起来简单,但新手特别容易踩组合式API的第一个小坑——直接传props的变量名。 比如父组件传了一个count的数字(基础类型):

<!-- 父组件 -->
<template>
  <button @click="count++">父组件加加</button>
  <Child :num="count" />
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const count = ref(0)
</script>

很多同学在子组件会这么写:

<!-- 子组件错误示范 -->
<script setup>
import { watch } from 'vue'
const props = defineProps(['num'])
// ❌ 直接传props.num的话,传的是这个基本类型的当前值,相当于watch(0),根本不会监听变化!
watch(props.num, (newVal, oldVal) => {
  console.log('num变了', newVal, oldVal)
})
</script>

为什么不行?因为在JavaScript里,基础类型是按值传递的,defineProps返回的props对象虽然是响应式的,但你直接取props.num的话,拿到的是一个普通的数字字符串或者布尔值,不是响应式源(ref或者reactive的对象本身,或者计算属性,或者箭头函数返回响应式对象的某个属性)。 那正确的写法是什么?有两种,一种是用箭头函数包裹,一种是如果这个props在defineProps里是解构过的(注意解构时要加toRefs或者toRef,不然解构出来的变量会丢失响应式),直接传解构后的ref也可以。

正确写法1:箭头函数包裹props.xxx

这是监听单个props属性最稳妥、最常用的方式,不管是基础类型还是对象类型都能用(对象类型的话默认只监听引用变化,要监听属性变化再加deep)。

<!-- 子组件正确写法1 -->
<script setup>
import { watch } from 'vue'
const props = defineProps(['num'])
// ✅ 箭头函数返回props.num,每次父组件更新时,Vue会重新执行这个函数判断依赖是否变化
watch(() => props.num, (newVal, oldVal) => {
  console.log('num变了', newVal, oldVal)
})
</script>

正确写法2:解构props时加toRefs,直接传ref

很多同学喜欢解构props,这样用的时候不用每次写props.xxx,但如果不用toRefs/toRef,解构出来的基础类型变量会失去响应式。

<!-- 子组件正确写法2 -->
<script setup>
import { watch, toRefs } from 'vue'
const props = defineProps(['num', 'name'])
// ✅ 用toRefs把整个props对象的属性都转成ref,保持响应式
const { num, name } = toRefs(props)
// 直接传num这个ref就可以,不用箭头函数
watch(num, (newVal, oldVal) => {
  console.log('num变了', newVal, oldVal)
})
</script>

这里补充一个小细节:如果只需要解构其中一个或几个可能不需要响应式的属性,可以不用toRefs,但需要监听的那个一定要单独用toRef转,

<script setup>
import { watch, toRef } from 'vue'
const props = defineProps(['num', 'staticText'])
// staticText是父组件传的不会变的文案,直接解构
const { staticText } = props
// 单独把num转成ref
const numRef = toRef(props, 'num')
watch(numRef, (newVal, oldVal) => {
  console.log('num变了', newVal, oldVal)
})
</script>

第三个核心问题:监听引用类型的props(对象、数组),怎么才不会踩引用不变但内容变的坑?

引用类型的坑才是Vue3(其实Vue2也有)监听props的重灾区! 什么是引用类型?JavaScript里的对象、数组、函数都是,它们在内存里存的是地址,不是值,也就是说,如果你父组件只改了数组的元素,或者只改了对象的某个属性,那这个引用类型的地址是不变的——也就是props的引用没变化,默认情况下的watch是不会触发的! 比如父组件传了一个嵌套的user对象:

<!-- 父组件 -->
<template>
  <button @click="user.name = '张三'">只改name</button>
  <button @click="user = { name: '李四', age: 20 }">整个替换user</button>
  <Child :user="user" />
</template>
<script setup>
import { reactive } from 'vue'
import Child from './Child.vue'
const user = reactive({
  name: '王五',
  age: 18,
  address: {
    city: '北京',
    district: '朝阳区'
  }
})
</script>

子组件如果用默认的箭头函数包裹:

<!-- 子组件引用类型默认监听示范 -->
<script setup>
import { watch } from 'vue'
const props = defineProps(['user'])
watch(() => props.user, (newVal, oldVal) => {
  console.log('user变了', newVal, oldVal)
})
</script>

这时候你点击“整个替换user”的按钮,控制台会打印;但点击“只改name”的按钮,控制台一点反应都没有! 那怎么解决?

解决方案1:加deep:true深度监听

最直接的方法就是在watch的第三个参数里加个配置对象,设置deep:true,这样不管你改的是引用类型的第几层属性,watch都会触发。

<!-- 子组件正确深度监听 -->
<script setup>
import { watch } from 'vue'
const props = defineProps(['user'])
watch(
  () => props.user,
  (newVal, oldVal) => {
    console.log('user变了', newVal, oldVal)
    // 这里还有个小问题:深度监听的时候,newVal和oldVal是同一个引用!
    // 因为只是内容变了,地址没换,所以newVal === oldVal是true,如果你需要对比旧值,这个时候是对比不了的
  },
  { deep: true } // 第三个参数是配置项
)
</script>

那如果我需要对比旧值怎么办?比如只在user的age从18变到其他数的时候才发请求? 这时候可以用watchEffect,或者用computed先处理一下要对比的属性,再单独监听computed——computed缓存了值,每次变化都会生成新的结果(如果是基础类型的话)或者返回新的响应式对象(不过这种情况少),这样对比的时候就能拿到准确的oldVal了。

解决方案2:单独监听引用类型的某个属性(推荐按需使用)

如果你只需要监听user的name或者age,或者只监听address的city,那完全没必要加deep:true——深度监听的性能损耗其实比你想象的大,尤其是当你的props是个超大的嵌套对象或者超长的数组时,Vue会递归遍历所有的属性,每次变化都要检查一遍。 这时候直接用箭头函数返回具体的属性就行,不管它是第几层的:

<!-- 子组件按需监听属性 -->
<script setup>
import { watch } from 'vue'
const props = defineProps(['user'])
// 单独监听user.name
watch(
  () => props.user.name,
  (newVal, oldVal) => {
    console.log('name变了', newVal, oldVal) // 这里newVal和oldVal是基础类型,能准确对比
  }
)
// 单独监听user.address.city
watch(
  () => props.user.address.city,
  (newVal, oldVal) => {
    console.log('city变了', newVal, oldVal)
  }
)
</script>

这个方法是性能最优的,强烈建议大家平时监听引用类型props的时候,先想想自己是不是真的需要监听整个对象/数组的所有变化,如果不是,就只监听具体的属性。

解决方案3:替换整个引用(从父组件层面解决)

如果你不想在子组件加deep,也不想单独监听一堆属性,那可以要求父组件每次修改引用类型内容的时候,都替换整个引用。 怎么替换?数组的话可以用展开运算符、slice()、concat()这些会返回新数组的方法;对象的话可以用展开运算符、Object.assign()这些会返回新对象的方法。 比如父组件刚才的代码可以改成这样:

<!-- 父组件替换整个引用 -->
<script setup>
import { reactive } from 'vue'
import Child from './Child.vue'
const user = reactive({
  name: '王五',
  age: 18,
  address: {
    city: '北京',
    district: '朝阳区'
  }
})
// 只改name的时候也替换整个user对象
const changeName = () => {
  user.value = { ...user.value, name: '张三' } // 注意这里如果是ref的话要加.value,如果是reactive直接改属性不能触发,但展开赋值可以
  // 哦不对,reactive的对象不能直接整体赋值,会丢失响应式!刚才的演示父组件用的是reactive,那如果要整体替换的话,应该改成ref包裹对象:
  // 修正一下父组件的script
}
</script>
<!-- 修正后的父组件script -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
// 用ref包裹引用类型,这样整体赋值不会丢失响应式
const user = ref({
  name: '王五',
  age: 18,
  address: {
    city: '北京',
    district: '朝阳区'
  }
})
// 只改name的时候展开赋值
const changeName = () => {
  user.value = { ...user.value, name: '张三' }
}
// 只改address.city的时候也要展开两层
const changeCity = () => {
  user.value = {
    ...user.value,
    address: { ...user.value.address, city: '上海' }
  }
}
// 整个替换的话更简单
const replaceUser = () => {
  user.value = { name: '李四', age: 20, address: { city: '广州', district: '天河区' } }
}
</script>

这样父组件不管怎么改,user的引用都会变,子组件默认的watch就能触发了,而且newVal和oldVal还能准确对比!不过这个方法需要父组件配合,如果你是封装通用组件的话,可能没法要求所有使用你组件的人都这么做,所以还是按需用方案1或2比较好。

第四个核心问题:什么时候需要用watchEffect代替watch监听props?

Vue3除了watch,还有个watchEffect,很多同学容易搞混这两个。 简单说一下它们的区别:watch是懒执行的,只有当监听的源变化时才会触发第一次回调(除非你加immediate:true),而且可以拿到oldVal;watchEffect是立即执行的,组件挂载的时候就会跑一次回调,然后自动追踪回调里用到的所有响应式依赖(不管是props、ref、reactive还是computed),只要其中任何一个依赖变了,就会重新执行回调,但它拿不到oldVal。 那什么时候监听props适合用watchEffect?我觉得主要有两种场景:

场景1:回调里用到了多个props,而且不需要对比旧值

比如子组件有两个props:page(页码)和pageSize(每页条数),当这两个任何一个变的时候,都要发请求拉取列表数据,而且不需要对比之前的page和pageSize是什么,直接拿最新的就行,这时候用watchEffect比用watch监听两个源要简洁:

<!-- 子组件用watchEffect监听多个props拉取列表 -->
<script setup>
import { watchEffect, ref } from 'vue'
const props = defineProps(['page', 'pageSize'])
const list = ref([])
// 立即执行,自动追踪page和pageSize
watchEffect(async () => {
  console.log('拉取列表,当前页码', props.page, '每页条数', props.pageSize)
  // 模拟发请求
  const res = await fetchList(props.page, props.pageSize)
  list.value = res.data
})
</script>

如果用watch的话,要写成这样:

<script setup>
import { watch, ref } from 'vue'
const props = defineProps(['page', 'pageSize'])
const list = ref([])
// 监听两个源,加immediate:true立即执行
watch(
  [() => props.page, () => props.pageSize],
  async ([newPage, newPageSize]) => {
    console.log('拉取列表,当前页码', newPage, '每页条数', newPageSize)
    const res = await fetchList(newPage, newPageSize)
    list.value = res.data
  },
  { immediate: true }
)
</script>

看起来差不多,但watchEffect更短一点,而且不用手动写监听源,更符合组合式API“自动追踪依赖”的理念。

场景2:回调里用到的响应式依赖不只是props,还有其他ref/reactive

比如刚才的列表例子,子组件还有个searchKey的ref,当searchKey变的时候也要拉取列表,这时候用watchEffect就更方便了,不用把searchKey也加到watch的监听源数组里:

<!-- 子组件用watchEffect监听props和本地ref -->
<script setup>
import { watchEffect, ref } from 'vue'
const props = defineProps(['page', 'pageSize'])
const searchKey = ref('')
const list = ref([])
// 自动追踪page、pageSize、searchKey
watchEffect(async () => {
  console.log('拉取列表,当前页码', props.page, '每页条数', props.pageSize, '搜索关键词', searchKey.value)
  const res = await fetchList(props.page, props.pageSize, searchKey.value)
  list.value = res.data
})
</script>

第五个核心问题:还有没有其他监听props变化的方法?

有,但不是主流,而且有局限性,这里简单提一下,大家了解就行:

方法1:选项式API的watch(当然可以,但现在主流用组合式)

如果你还在写Vue3的选项式API,或者项目里有旧的选项式代码,那监听props的方法和Vue2完全一样:

<!-- 子组件选项式API监听props -->
<script>
export default {
  props: ['num', 'user'],
  watch: {
    // 监听基础类型
    num(newVal, oldVal) {
      console.log('num变了', newVal, oldVal)
    },
    // 监听引用类型
    user: {
      handler(newVal, oldVal) {
        console.log('user变了', newVal, oldVal)
      },
      deep: true,
      immediate: true
    }
  }
}
</script>

方法2:计算属性+computed的watch

其实这个和直接用watch监听computed差不多,没什么特别的优势,反而多了一层:

<script setup>
import { watch, computed } from 'vue'
const props = defineProps(['num'])
const doubleNum = computed(() => props.num * 2)
watch(doubleNum, (newVal, oldVal) => {
  console.log('doubleNum变了', newVal, oldVal)
})
</script>

方法3:onUpdated生命周期钩子

onUpdated会在组件的DOM更新之后触发,而DOM更新通常是因为props、本地ref/reactive等响应式数据变化了,但onUpdated有个很大的问题:它不知道是哪个响应式数据变了!所以如果你只是想在某个特定props变化的时候做事情,千万不要用onUpdated,不然每次任何数据变化它都会触发,性能差不说,还容易出bug。

最后给大家总结一下Vue3 watch监听props变化的最佳实践

  1. 优先考虑是否真的需要watch:如果只是展示数据或者生成新值,用插值或computed;只有需要做副作用(发请求、操作DOM、改本地存储、触发父组件事件等)的时候才用watch。
  2. 监听基础类型props:用箭头函数包裹props.xxx,或者解构时加toRefs/toRef直接传ref。
  3. 监听引用类型props
    • 只需要监听引用变化:直接箭头函数包裹props.xxx,不用加deep。
    • 需要监听所有层级变化:加deep:true,但注意性能损耗和newVal/oldVal引用相同的问题。
    • 只需要监听某个/某几个具体属性:用箭头函数返回具体的属性,性能最优。
    • 如果可以控制父组件:让父组件每次修改内容时替换整个引用(用展开运算符等)。
  4. 多个响应式依赖(包括props)+ 不需要对比旧值:优先用watchEffect,自动追踪依赖,代码更简洁。
  5. 避免用onUpdated监听特定props变化:它不知道是哪个数据变的,容易出问题。

希望这篇文章能帮大家彻底搞懂Vue3 watch监听props变化的所有问题,以后开发的时候再也不会踩坑了!

版权声明

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

热门