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

Vue3中watch监听props传值后通过emit触发父组件事件容易出错吗?怎么正确用?

terry 1天前 阅读数 362 #Vue

在Vue3项目开发里,父子组件通信是每天都要碰的操作——子组件用props接父组件数据,用emit通知父组件改状态,中间常常用watch盯着props的变化,一旦符合条件就触发交互,但很多刚上手Vue3的开发者,甚至有些有Vue2基础的老手,在这里踩过不少坑:比如emit触发了但父组件没反应,或者watch重复触发导致emit刷屏,还有Composition API里setup语法糖下的写法混乱,这些问题其实都有明确的解法,只要理清这三个工具的底层逻辑和组合规则就行。

先搞懂这三个工具在Vue3里的核心变化

虽然很多人知道watch、props、emit的基本用法,但Vue3对比Vue2做的调整,恰恰是踩坑的主要原因,先梳理清楚底层逻辑,后面的坑点和最佳实践才好理解。

首先说watch的变化:Vue3里watch不仅保留了Vue2的监听能力,还拆分出了更轻量的watchEffect,而且watch的回调函数触发时机、依赖收集方式都有细节调整——Composition API里的watch默认是懒执行的,只有监听的源发生变化才会跑;而监听ref/reactive对象的整个值时,Vue2可以直接deep watch对象里的属性,Vue3同样可以,但 Composition API 里监听整个ref对象的话,是监听它的地址,监听整个reactive对象则是自动deep(当然也能手动关),这点很多人容易搞反。

然后是props的变化:Vue3里props还是只读的,不管是用defineProps声明的(setup语法糖)还是setup函数的第一个参数,直接修改都会有警告,这点没变;但最大的不同是,Vue3中如果父组件传的是基本类型(字符串、数字这些),props的响应式是自动挂的;如果传的是对象/数组的引用,父组件里改引用的属性,子组件不用watch也能看到变化,但改整个引用地址的话,还是得靠watch或者直接用响应式解构。

emit的变化:Vue2里emit可以随便触发,但Vue3里建议用defineEmits(setup语法糖)或者setup函数第二个参数context里的emit属性先声明事件名,虽然不声明也能用,但会有警告,而且对TypeScript支持不好,开发工具的提示也会弱很多;Vue3里emit的事件名建议用kebab-case(比如update:count),尤其是配合v-model的时候,这是Vue3官方要求的标准写法,避免兼容性问题。

先避最常见的三个坑

刚上手组合这三个工具的开发者,大概率会先踩这三个雷,先把它们排了,能解决80%的日常问题。

第一个坑:watch监听整个props对象,emit没反应,比如父组件传了一个{ count: 0 }的reactive对象给子组件的info prop,子组件用watch(info, () => emit('change', info.count)),然后父组件直接改info.count——哎,居然能触发?但如果父组件传的是用ref包裹的{ count: 0 },子组件直接watch(info)的话,除非父组件把整个info.value替换掉,否则count变了watch也不会跑,为什么?因为刚才说过,Composition API里watch监听ref对象的话,默认是监听它的.value的地址,而不是里面的属性;监听reactive对象的话才是自动deep的,所以这个坑的解法很明确:如果父组件传的是ref包裹的对象/数组,子组件要么watch(info.value, { deep: true }),要么直接把props里的ref对象.value解构出来用watch监听;如果父组件传的是reactive对象,直接watch就行,不想deep的话加个{ deep: false }。

第二个坑:watch重复触发导致emit刷屏,这个坑在Vue2里也常见,但Vue3里有新的触发条件,比如immediate选项加上deep选项,或者父组件频繁更新整个props对象的情况,举个例子:父组件有个input,v-model绑定的是info.name,然后info是个ref包裹的对象,每次input触发都会更新info.value.name——子组件如果watch(info, (newVal, oldVal) => { if (newVal.name !== oldVal.name) emit('update', newVal) }, { deep: true, immediate: true }),这里immediate首次执行没问题,但如果父组件里还有其他逻辑,比如每500ms自动给info加个timestamp属性,那子组件的watch就会跟着跑,emit也会触发,尽管我们只关心name的变化,怎么解决?有两个办法:第一个是只监听特定的属性,比如子组件用watch(() => props.info?.name, (newVal) => emit('update', newVal), { immediate: true }),这样不管info里加多少其他属性,只有name变了才会触发;第二个是加个防抖节流,比如用lodash的debounce或者throttle,或者自己写个简单的防抖函数,避免高频触发。

第三个坑:setup语法糖里defineProps和defineEmits的顺序搞混,或者emit里传了不该传的东西?顺序其实没问题,Vue3的编译器会处理,但习惯上还是先写defineProps再写defineEmits,逻辑更顺;不该传的东西是什么?比如把整个props对象或者reactive对象的引用直接传给emit,虽然父组件能收到,但这会违反“props只读,emit只传事件信息”的原则——如果父组件不小心改了这个引用,子组件的状态也会跟着变,很难排查问题,所以这个坑的解法是:emit里尽量只传基本类型的数据,或者传对象/数组的深拷贝(如果数据不大的话),再或者传触发事件需要的必要信息,不要传整个大对象。

三个高频场景的最佳实践

排了坑,接下来看日常开发中最常用的三个场景,每个场景都给具体的代码示例,方便直接套用。

第一个场景:子组件是个计数器,父组件传count,子组件点加减按钮触发父组件改count,同时watch count达到10的时候触发一个弹窗事件,这个场景很简单,用defineProps、defineEmits、watch就行,代码大概是这样的: 子组件用setup语法糖的话: 先声明props和emit:const props = defineProps(['count']); const emit = defineEmits(['update:count', 'show-alert']); 然后写加减的函数:const add = () => emit('update:count', props.count + 1); const sub = () => emit('update:count', props.count - 1); 然后watch:watch(() => props.count, (newVal) => { if (newVal === 10) emit('show-alert', '恭喜你数到10啦!') }, { immediate: true }); 父组件用v-model的话:<Child v-model:count="parentCount" @show-alert="handleAlert" />,这里v-model:count会自动监听update:count事件,更新parentCount。

第二个场景:子组件是个表单,父组件传一个formData对象(ref包裹的),子组件修改表单的时候,实时用watch监听formData的变化,然后emit给父组件,同时父组件有个重置按钮,重置整个formData,这个场景要注意的是,父组件重置的时候是替换整个formData.value,所以子组件的watch要监听整个formData,还要加deep,同时要区分是用户修改表单还是父组件重置——怎么区分?可以用一个isResetting的ref变量在子组件里,重置的时候通过props传一个布尔值,或者watch的时候对比newVal和oldVal的某些特定属性,比如如果oldVal是undefined或者和newVal完全不一样,就是重置,不触发emit?不对,重置的时候可能也需要父组件知道,但如果是实时修改表单才触发emit的话,可以对比oldVal和newVal的差异,或者用防抖函数,这里给一个对比差异的代码示例: 子组件的watch:watch(() => props.formData, (newVal, oldVal) => { // 先判断oldVal是不是存在,防止immediate触发的时候出错 if (!oldVal) return; // 用JSON.stringify对比差异,注意这个方法只能对比简单对象,不能对比有函数、Symbol、循环引用的对象 if (JSON.stringify(newVal) === JSON.stringify(oldVal)) return; // 这里判断是不是用户修改的,比如formData里有个isUserEdit的属性,默认false,用户修改表单的时候改成true if (!newVal.isUserEdit) return; emit('update:formData', { ...newVal, isUserEdit: false }); }, { deep: true, immediate: true }); 这里加isUserEdit属性的原因是,父组件重置的时候会把isUserEdit设为false,子组件修改表单的时候设为true,这样就不会把父组件的重置操作当成用户修改触发emit了。

第三个场景:子组件是个列表,父组件传一个list数组(reactive的),子组件要监听list的长度变化,当长度小于5的时候触发父组件加默认项的事件,同时子组件可以删除列表项,这个场景要注意的是,reactive数组的push/pop/splice等操作会自动触发watch,因为Vue3的响应式是基于Proxy的,比Vue2的Object.defineProperty更强大,能监听数组的索引变化和数组的方法调用,代码大概是这样的: 子组件用setup语法糖的话: 先声明props和emit:const props = defineProps({ list: { type: Array, required: true } }); const emit = defineEmits(['add-default', 'delete-item']); 然后写删除函数:const deleteItem = (index) => emit('delete-item', index); 然后watch:watch(() => props.list.length, (newVal) => { if (newVal < 5) emit('add-default') }, { immediate: true }); 这里直接监听list.length,不用加deep,因为length是一个基本类型的属性,而且每次数组变化(不管是增删改元素还是修改长度),length都会变吗?不对,修改元素不会变length,哦对,所以这个场景要监听的是数组的长度,所以直接() => props.list.length就行,如果是监听数组的元素变化,就加deep。

组合这三个工具的核心原则

把刚才的内容总结成几个核心原则,方便大家记忆:

  1. props永远只读,子组件不要直接改,要用emit通知父组件;
  2. watch要明确监听的源,能监听特定属性就不要监听整个对象,能不用deep就不用deep(deep会消耗性能);
  3. emit要先声明事件名,用kebab-case,尽量只传必要的基本类型数据或深拷贝的简单对象;
  4. 区分用户操作和父组件操作,避免重复触发emit;
  5. 用v-model简化父子组件的双向绑定,减少代码量。

其实Vue3里的watch和emit还有很多高级用法,比如watchEffect的清理副作用、watch的flush选项、emit的事件修饰符等等,但日常开发中只要掌握了刚才的坑点和最佳实践,就能解决大部分问题了,如果有时间的话,可以再去深入研究一下Vue3的响应式原理,理解了Proxy和ref/reactive的区别,这些工具的用法就会更得心应手。

版权声明

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

热门