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

Vue3 props watch 没触发怎么办?

terry 1小时前 阅读数 17 #Vue
文章标签 Vue3Props Watch失效

开发Vue3项目时,你是不是遇到过明明传了新的props值,页面上的计算属性或者data能跟着变,但专门写的watch函数纹丝不动?别慌,这是Vue3里新手到进阶开发者都常踩的小坑群,今天咱们把所有可能的原因拆得明明白白,连Vue3官方文档藏在边边角角的小细节也挖出来,看完下次肯定能10秒定位问题、5分钟搞定。

第一类坑:props引用数据类型没看“新”对象

Vue的响应式系统一直有个核心逻辑:监听基本数据类型看值,监听引用数据类型看地址(也就是指针有没有变),这个逻辑Vue2和Vue3核心没变,但Vue3的Proxy代理虽然把对象里的属性监听变得更彻底,但对整个对象/数组的替换监听逻辑还是没变的——这也是新手最容易掉的第一个大陷阱。

子场景1:父组件直接修改引用类型的属性,watch没开deep

举个新手最常写的例子:

<!-- 父组件 -->
<template>
  <div>
    <button @click="changeUser.name">改名字</button>
    <Child :user="user" />
  </div>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const user = ref({ name: '张三', age: 18 })
</script>
<!-- 子组件 -->
<script setup>
const props = defineProps(['user'])
watch(props.user, (newVal, oldVal) => {
  console.log('用户信息变了!', newVal, oldVal) // 点击按钮不会打印哦!
})
</script>

为啥呢?因为父组件的user是ref包裹的引用类型,点击按钮只改了user.value.name这个属性的值,user.value的地址指针没换——就像你住在张三的房子里,只是换了墙上的画,没换房子钥匙,Vue的watch默认只看钥匙有没有换,根本不管画的事。

这时候解决办法很简单,要么给watch加个deep: true的配置项,让它“钻”进对象/数组里监听每一个属性变化;要么如果只需要监听特定的某个属性,别直接监听整个对象,监听具体的路径会更高效(这点Vue3官方文档也推荐,因为deep监听会有性能损耗,对象层级越深、数据量越大越明显)。

推荐的两种修复写法

第一种:加deep(适合要监听整个引用数据全量变化的场景)

<script setup>
const props = defineProps(['user'])
watch(props.user, (newVal, oldVal) => {
  console.log('用户全量或部分信息变了!', newVal, oldVal)
}, { deep: true })
</script>

第二种:监听具体的属性路径(推荐,性能更好)

<script setup>
const props = defineProps(['user'])
watch(() => props.user.name, (newVal, oldVal) => { // 注意这里用了箭头函数!
  console.log('只改了名字!', newVal, oldVal) // 箭头函数的作用是每次依赖收集都能拿到最新的属性值
})
</script>

这里插一句关键的:监听引用类型的具体属性时,必须把属性放在箭头函数里作为返回值!新手如果直接写watch(props.user.name, ...),监听的是props.user.name的初始值(比如第一次传的是‘张三’这个字符串,基本数据类型),后续父组件改了name,子组件拿到的props.user.name虽然是新字符串,但Vue的watch初始化时已经绑定到第一次的字符串了,肯定不会触发——这点是很多入门教程没讲透的,但却是天天会遇到的小细节。

子场景2:父组件以为换了引用类型,其实是原地修改

这个坑老手偶尔也会踩,比如父组件想给数组加个元素,或者给对象加个属性,但是用了Vue2时代不太友好的原地修改方法?不对,Vue3的Proxy已经解决了数组下标修改、对象新增属性的响应式问题了啊,怎么还会有原地修改导致watch不触发的情况?

哦,对哦,Vue3的Proxy解决的是Vue自己的响应式数据(ref、reactive包裹的)的原地修改问题,如果父组件传的props不是响应式数据,而是普通的JS对象/数组,或者虽然是响应式的,但你在父组件用了Object.assign()或者Array.prototype.concat()map()这些没有返回新地址的方法?不,不对,concat()map()filter()这些数组方法是返回新数组的啊,只有push()pop()splice()shift()unshift()sort()reverse()这些是原地修改的——但刚才说了,Vue3的Proxy下,响应式数据的原地修改是能触发子组件更新的,那为啥还会关联到watch的问题?

哦,重点来了:你子组件watch的如果是整个响应式数组/对象的话,原地修改只会触发deep: true的watch,不会触发不加deep的默认watch!这点是新手和刚转Vue3的Vue2老手最容易搞混的——Vue2里,如果你用Vue.set()或者this.$set()给响应式数组加元素、给对象加属性,或者用那7个数组变异方法原地修改,不加deep的watch是不会触发整个引用数据的回调的,Vue3虽然把Vue.set()废了,但默认不加deep的watch整个引用数据的逻辑还是和Vue2一样的:只看地址变没变。

举个转Vue3的老手容易犯的例子,父组件想用reactive定义一个对象,然后给对象加个新属性:

<!-- 父组件 -->
<template>
  <div>
    <button @click="addUserProperty">加性别属性</button>
    <Child :user="user" />
  </div>
</template>
<script setup>
import { reactive } from 'vue'
import Child from './Child.vue'
const user = reactive({ name: '张三', age: 18 })
const addUserProperty = () => {
  user.gender = '男' // Proxy能让这个新增属性在页面上显示,但不加deep的watch整个user不会触发!
}
</script>
<!-- 子组件 -->
<script setup>
const props = defineProps(['user'])
watch(props.user, (newVal, oldVal) => {
  console.log('性别变了?', newVal, oldVal) // 肯定不会打印!
})
</script>

这时候怎么办?要么像刚才一样加deep: true,要么如果你一定要触发不加deep的watch整个引用数据的回调,就手动给它换个地址——比如给对象用Object.assign({}, user, { gender: '男' }),给数组用[...user, newItem]这种展开语法,返回一个全新的对象/数组,这样地址指针变了,不加deep的watch肯定会触发。

第二类坑:props解构的时候丢了响应式

Vue3的<script setup>语法糖里,defineProps返回的props对象是只读的响应式对象——这点和Vue2的this.$props是类似的,不能直接修改,修改了也不会生效,而且是响应式的。

但很多新手为了写代码方便,会直接把defineProps的返回值解构赋值给普通变量,

<script setup>
// 踩坑写法!
const { count } = defineProps(['count'])
watch(count, (newVal, oldVal) => {
  console.log('count变了!', newVal, oldVal) // 绝对不会打印!
})
</script>

为啥呢?因为你解构出来的count只是一个普通的基本数据类型(如果count是基本数据的话)或者普通的引用数据类型(如果count是引用数据的话),它已经脱离了Vue的响应式系统的追踪——就像你把张三的房子钥匙从一个带追踪功能的钥匙链上摘下来,挂到了普通钥匙链上,追踪器自然就追踪不到它了。

那如果我一定要解构怎么办?别慌,Vue3提供了toRefs这个API,专门用来把响应式对象的所有属性都转换成响应式的ref对象,这样解构出来的变量就还是响应式的了。

正确的props解构写法

<script setup>
import { toRefs, watch } from 'vue'
const props = defineProps(['count', 'user'])
const { count: countRef, user: userRef } = toRefs(props) // 这里的冒号是重命名,避免和变量名冲突
watch(countRef, (newVal, oldVal) => {
  console.log('count真的变了!', newVal, oldVal) // 现在会打印了!
})
watch(userRef, (newVal, oldVal) => {
  console.log('user地址变了!', newVal, oldVal) // 如果要监听user的属性,还是要加deep或者箭头函数哦
}, { deep: true })
</script>

这里再补充一个小细节:如果defineProps里有默认值,比如defineProps({ count: { type: Number, default: 0 } }),当父组件没有传count的时候,用toRefs解构出来的countRef会是一个有默认值的ref对象,不会是undefined——这点很好用,不用担心默认值丢失的问题。

那有没有更简单的办法?如果你只需要解构几个属性,不想每次都写toRefs重命名,可以用Vue3.2+新增的defineProps解构语法糖——也就是直接在defineProps前面加个符号?不对,等一下,Vue3官方提供的是<script setup>中的解构赋值如果结合defineProps的默认值语法,其实是可以自动保留响应式的?哦,不对不对,刚才差点说错,官方后来纠正过:直接解构defineProps返回的对象,不管有没有默认值,都会丢失响应式,除非你用Vue3.3+新增的props destructuring with reactivity——也就是在tsconfig.json里开启vueCompilerOptions.experimentalModelPropDestructure(不过这个还是实验性的),或者更稳妥的办法,还是用toRefs

哦,对了,还有一种新手可能犯的错误:<script setup>的顶层用await(也就是异步<script setup>),然后把props解构赋值放在了await之后——虽然这个错误和丢响应式的直接原因不太一样,但后果也是一样的:因为defineProps必须在<script setup>同步顶层调用,不能放在await后面,不能放在if/else里,不能放在函数里,否则Vue的编译器没法正确地解析props,更别说追踪响应式了。

第三类坑:watch的监听时机不对,或者watchEffect没注意依赖

除了最常见的引用类型和响应式解构的坑,还有一些比较隐蔽的坑,和watch的配置或者watchEffect的使用有关。

子场景1:用了immediate: true但发现oldVal是undefined

这个其实不算“bug”,是Vue3的正常设计,但很多新手以为是watch没工作——比如你写了这样的代码:

<script setup>
const props = defineProps(['count'])
watch(props.count, (newVal, oldVal) => {
  console.log('count:', newVal, oldVal)
}, { immediate: true })
</script>

父组件第一次传count=0的时候,控制台会打印count:0 undefined——新手可能会说:“我传了0啊,oldVal怎么是undefined?是不是watch没拿到第一次的旧值?”

其实不是的,immediate: true的作用是让watch在组件初始化完成之后立即执行一次回调,这时候组件还没有接收到任何“后续”的props变化,第一次接收到的props值就是新值,旧值自然就是undefined——这个是官方明确说明的,完全正常,不用紧张。

那如果我第一次执行回调的时候也想要旧值怎么办?可以用watchEffect吗?或者有没有别的办法?其实watchEffect第一次执行的时候也没有旧值的概念,因为它是“副作用式”的监听,只看依赖有没有变化,然后执行回调;如果一定要第一次也拿到旧值(比如第一次的旧值想用默认值代替),可以在回调里手动判断一下oldVal是不是undefined,如果是的话就用自己设定的默认值。

子场景2:watchEffect里的依赖没有正确被收集

很多新手觉得watchEffect比watch好用,因为不用写监听的数据源,它会自动收集依赖——但自动收集依赖也有坑,就是如果你的依赖写在if/else或者try/catch的分支里,或者写在定时器里,第一次执行watchEffect的时候没有触发到那个分支,Vue就不会收集到那个依赖,后续依赖变化了,watchEffect也不会触发。

举个例子:

<script setup>
const props = defineProps(['isShow', 'count'])
const showInfo = ref(false)
watchEffect(() => {
  if (showInfo.value) {
    console.log('isShow:', props.isShow, 'count:', props.count)
  }
})
const toggleShow = () => {
  showInfo.value = true
}
</script>

当组件初始化的时候,showInfo.value是false,if分支没有执行,所以Vue只收集到了showInfo.value的依赖,没有收集到props.isShowprops.count的依赖——这时候你先点击toggleShow按钮,让showInfo.value变成true,watchEffect第一次执行if分支,收集到了props.isShowprops.count的依赖,后续再改这两个props,watchEffect会触发;但如果你先改了props.isShowprops.count,再点击toggleShow,那之前的props变化就不会被watchEffect记录到了。

那怎么解决这个问题?要么把需要监听的依赖尽量放在if/else分支的外面,让第一次执行watchEffect的时候就能收集到;要么干脆不用watchEffect,改用watch,明确写清楚要监听的数据源——虽然watch写起来多了几行代码,但更可控,不容易出这种自动收集依赖的坑。

子场景3:父组件第一次传的props值和第二次传的完全一样

这个坑虽然很傻,但真的会有人踩——比如父组件写了个定时器,每秒给子组件传一个相同的字符串或者数字,这时候watch当然不会触发,因为Vue的响应式系统会做新旧值的浅对比,如果新旧值一样(基本数据类型值一样,引用数据类型地址一样),就不会触发回调。

举个例子:

<!-- 父组件 -->
<template>
  <Child :message="message" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import Child from './Child.vue'
const message = ref('Hello Vue3')
onMounted(() => {
  setInterval(() => {
    message.value = 'Hello Vue3' // 每次传的都是一样的!
  }, 1000)
})
</script>
<!-- 子组件 -->
<script setup>
const props = defineProps(['message'])
watch(props.message, (newVal, oldVal) => {
  console.log('message变了?', newVal, oldVal) // 永远不会打印!
})
</script>

这时候解决办法当然是父组件传不一样的值了——或者如果你一定要每次传一样的值都触发watch,可以给watch加个flush: 'sync'?不对,flush: 'sync'只是改变watch回调的执行时机(改成同步执行,不是在DOM更新之后的微任务里执行),不会改变新旧值的对比逻辑;哦,对了,可以给watch加个deep: true?不对,基本数据类型加deep也没用;哦,有了,可以用watchEffect吗?或者给父组件传一个引用数据类型,比如每次传一个新的数组或者对象,哪怕里面的内容一样,地址不一样也会触发watch——但这个太浪费性能了,没事别这么干。

第四类坑:props没有正确传递,或者defineProps定义的类型不对

这个坑也是新手常犯的,比如父组件传的props名和子组件defineProps里定义的props名不一样,或者父组件传的是一个字符串,但子组件defineProps里定义的是Number类型,而且没有默认值——这时候子组件根本接不到正确的props,watch当然不会触发了。

子场景1:props名大小写不匹配

Vue的模板是大小写不敏感的,但<script setup>里的变量名是大小写敏感的——这点和Vue2是一样的,但很多新手还是会搞混。

举个例子:

<!-- 父组件:模板里传的是user-name(kebab-case) -->
<template>
  <Child :user-name="userName" />
</template>
<!-- 子组件:defineProps里定义的是userName(camelCase)?不,不对,defineProps里定义camelCase是可以的,模板里传kebab-case会自动转换成camelCase -->
<script setup>
const props = defineProps(['userName']) // 这个没问题!
watch(props.userName, ...) // 会触发!
</script>

哦,刚才差点举错反例,正确的反例应该是:

<!-- 父组件:模板里传的是username(全小写,kebab-case或者camelCase?不,全小写在模板里就是一个字符串,defineProps里如果定义的是userName,就接不到!) -->
<template>
  <Child :username="userName" />
</template>
<!-- 子组件:defineProps里定义的是userName(camelCase) -->
<script setup>
const props = defineProps(['userName']) // 接不到!props.userName是undefined!
watch(props.userName, ...) // 当然不会触发!
</script>

所以父组件传props的时候,要么在模板里用kebab-case(推荐,符合HTML规范),要么在模板里用camelCase(但HTML规范里标签和属性名应该全小写或者kebab-case),子组件defineProps里统一用camelCase就可以了,别搞成全小写或者乱加下划线。

子场景2:defineProps定义的类型是必填的,但父组件没传

比如子组件defineProps里定义的是{ count: { type: Number, required: true } },但父组件忘了传count,或者传的是nullundefined——这时候Vue会在控制台报警告(开发环境下),而且子组件的props.count是undefined,watch监听undefined当然不会触发(除非你给父组件传了一个新的undefined,但刚才说了,新旧值一样也不会触发)。

所以开发的时候一定要注意看控制台的警告,Vue的警告信息其实很友好,很多时候直接告诉你问题出在哪了。

第五类坑:用了shallowRef或者shallowReactive包裹的props父数据

这个坑进阶开发者偶尔会踩,比如父组件为了优化性能,给一个很大的对象/数组用了shallowRef或者shallowReactive包裹——shallowRef只监听.value的地址变化,不监听内部属性;shallowReactive只监听第一层属性的变化,不监听深层属性的变化。

举个例子:

<!-- 父组件:用shallowRef包裹了一个很大的user对象 -->
<template>
  <div>
    <button @click="changeUserName">改名字</button>
    <button @click="changeUserAddress">改地址</button>
    <Child :user="user" />
  </div>
</template>
<script setup>
import { shallowRef } from 'vue'
import Child from './Child.vue'
const user = shallowRef({ 
  name: '张三', 
  age: 18, 
  address: { city: '北京', district: '朝阳区' } 
})
const changeUserName = () => {
  user.value.name = '李四' // shallowRef不监听内部属性的变化!
}
const changeUserAddress = () => {
  user.value.address.city = '上海' // shallowRef也不监听!
}
</script>
<!-- 子组件:不管加不加deep,都不会触发?不对,要看父组件的数据有没有更新 -->
<script setup>
const props = defineProps(['user'])
// 子组件拿到的props.user是shallowRef.value的代理吗?不,props是只读的响应式对象,props.user是父组件shallowRef.value的引用,也就是一个普通的JS对象(因为shallowRef只包裹.value的地址,内部对象是普通的)
watch(() => props.user.name, (newVal, oldVal) => {
  console.log('名字变了?', newVal, oldVal) // 不会触发!因为父组件的user.value.name虽然改了,但父组件的shallowRef没有触发更新,子组件的props.user.name也不会更新!
})
</script>

哦,对哦,shallowRefshallowReactive包裹的数据,内部属性变化的时候,整个响应式数据不会触发更新,所以子组件根本接不到新的props值,页面也不会更新,更别说watch了。

这时候解决办法要么是不用shallowRef/shallowReactive,要么是手动给shallowRef.value换个地址,

const changeUserName = () => {
  user.value = { ...user.value, name: '李四' } // 手动换地址,触发shallowRef的更新
}

Vue3 props watch不工作的排查清单

下次再遇到Vue3 props watch没触发的问题,别慌,按照下面的清单一步步排查,10秒就能定位问题:

  1. 先看props有没有正确传递:父组件传的props名和子组件defineProps里的一致吗?父组件传的是必填的吗?有没有看控制台的警告?
  2. 再看是不是引用数据类型的问题:如果是基本数据类型,是不是新旧值一样?如果是引用数据类型,是不是直接修改了内部属性没加deep?是不是原地修改了整个引用数据没加deep或者换地址?
  3. 然后看响应式有没有丢失:是不是直接解构了defineProps的返回值?是不是把defineProps放在了await后面、if/else里或者函数里?
  4. 接着看watch的配置和时机:是不是用了immediate: true但纠结oldVal是undefined?是不是watchEffect的依赖没有正确收集?是不是用了shallowRef/shallowReactive?
  5. 最后实在不行,用watchEffect代替试试:但要注意依赖收集的问题,或者用Vue DevTools看看props有没有变化,看看watch有没有被正确注册。

好啦,今天的Vue3 props watch踩坑指南就到这里,你有没有踩过上面的某一个坑?欢迎在评论区留言分享你的经历~

版权声明

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

热门