ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3组件通信全解:props、v-model、provide/inject与Pinia的取舍

Vue3组件通信全解:props、v-model、provide/inject与Pinia的取舍 Vue3的组件通信说简单也简单说复杂是真的复杂。以前带着新人做后台管理系统时我见过太多人把props和emit背得滚瓜烂熟结果一遇到跨层级传值、兄弟组件同步这种场景就原地懵圈。也有人学了Pinia之后恨不得所有数据都往store里塞最后调试的时候打开Vue Devtools一看一片汪洋根本分不清哪个状态是哪个页面在用。这节Vue3组件通信的内容我结合自己实际项目的踩坑经验把这几种通信方式的原理、适用边界、常见误区和实用技巧一次性理清楚。不管你是刚学完Vue3基础、在面试前突击组件通信的开发者还是在真实项目中越写越乱、想找个靠谱方案的同行这篇文章都值得你花时间读完。我不打算只给你背代码而是把为什么这个场景要用这种方式讲透因为这才是组件通信真正的分水岭。1. 通信方案取舍Vue3真正考验人的是该用哪个我以前在公司经常问来面试的人一个问题你们项目里组件通信都用什么十有八九会回答props、emit、vuex。这个答案不能算错但它暴露了一个很典型的问题很多开发者只是机械地记住了API脑子里根本没有场景-方案的映射关系。Vue3的组件通信方式比Vue2多了不少再加上script setup语法糖的普及很多老写法已经变了。我根据实际项目经验先给出一张总览表把主流的通信方式按适用场景分个类后面再逐个展开讲。通信场景推荐方案备选方案不推荐父组件传数据给子组件propsprovide/inject直接修改子组件内部状态子组件通知父组件emitsv-model、defineExpose直接反向修改props父子组件双向同步v-model手动props emits在子组件内直接改props父组件主动调用子组件方法ref defineExpose全局事件不推荐通过props传函数勉强跨多级深层组件传递provide/injectPinia如果状态确实全局共享props一层层透传兄弟组件或非树形关系通信Piniamitt事件总线通过父组件中转层级太深会很痛苦看到这张表你可能会说这不就是把官方文档的推荐抄了一遍吗别急真正的干货在后面的原理分析和踩坑记录里。比如props那一节我会讲一个我线上项目遇到过的响应式丢失问题provide/inject那一节会有一个让很多新手百思不得其解的数据变成死值的经典案例mitt那节我会聊聊为什么官方都推荐了Pinia我还在某些场景坚持用事件总线。方案本身没有绝对的好坏只有适不适合当前的组件结构和数据流。接下来我按从最近到最远的通信距离来展开父子关系先讲跨层级和全局通信放后面。2. 父传子props的正确姿势与那条不能碰的红线2.1 从defineProps类型声明说起在script setup里父传子最标准的写法是defineProps。很多新手会看到一个很困惑的点为什么有时候defineProps()要接收一个数组或对象有时候却只调用一个空函数我直接给出现阶段最推荐的写法——类型声明。!-- Child.vue -- script setup langts interface UserInfo { name: string age: number tags?: string[] } const props defineProps{ title: string userInfo: UserInfo count?: number }() /script template div h2{{ props.title }}/h2 p{{ props.userInfo.name }}/p /div /template这里有个很多刚上手TS的人容易踩的坑在模板里直接用title没问题但在script setup内部用必须写成props.title因为defineProps的返回值才是那个响应式props对象。如果直接在script里写console.log(title)会直接报未定义。2.2 解构props会丢失响应性这是我在真实项目里犯过的错也是面试时高频出现的考点。在Vue3里像下面这样直接解构props在Vue 3.5之前的版本中解构出来的变量会丢失响应性。script setup langts const { title, count } defineProps{ title: string count: number }() // 在子组件中对count进行watch当父组件更新count时 // 这个watch可能不会触发Vue 3.5之前 watch(count, (newVal) { console.log(count updated:, newVal) }) /script在Vue 3.5之前正确的做法是使用toRefs或者直接用props.xx。Vue 3.5之后官方支持了props解构的响应式转换但为了兼容性和可读性我在项目中依然倾向于保持props.xxx的写法只在模板里直接用短变量名。这个习惯让代码在版本升级时少了很多麻烦。2.3 单向数据流的红线为什么碰不得props是只读的子组件不能直接修改props的值。这条规则几乎所有教程都会讲但很少有人说清楚为什么。我用一个真实的例子来说明父组件持有一个userList数据传给子组件去做列表展示。如果子组件直接props.userList.push(newItem)看起来挺好父组件的userList也会同步变化因为引用是同一个。过几天需求变了列表页面要展示两种筛选条件。父组件在另外一处逻辑里基于旧的userList做了个统计结果因为子组件偷偷改了数组统计数据莫名其妙地变了。这种任何组件都能随手改共享数据的写法短期看着方便长期就是噩梦。数据流在哪个环节被改了你根本不知道排查问题时只能逐个组件去找。所以正确的姿势永远是子组件通过emit发事件让父组件自己去改数据源始终只有一个Owner。!-- 正确做法子组件通知父组件去修改 -- const emit defineEmits{ (e: update:userList, newList: UserInfo[]): void }() function handleAddTag(tag: string) { const newList props.userList.map(item ({ ...item, tags: [...(item.tags || []), tag] })) emit(update:userList, newList) }也许你会问那我用对象引用传递改的是内部的某个属性props的引用没变这算不算违反单向数据流严格来说这依然会带来数据流混乱的问题。如果你确实需要父子组件共享一个可变对象优先考虑这个对象是否应该提升到父组件或者直接考虑Pinia。等到代码里出现三四个层级都在改同一个对象时你就知道这个原则有多救命了。3. 子传父emits的命名陷阱与类型推导3.1 defineEmits让事件名称和载荷都有约束子组件向父组件通信最标准的方式就是defineEmits。在Vue3 TS的组合下事件名称和载荷类型都可以被严格约束。我推荐所有项目都采用类型声明的方式!-- Child.vue -- script setup langts const emit defineEmits{ (e: submit, payload: { name: string; age: number }): void (e: cancel): void }() function handleSubmit() { emit(submit, { name: Tom, age: 18 }) } /script这样写的好处是显而易见的在你的组件里调用emit时Volar会提示你事件名称只能从submit | cancel里选载荷类型也会校验。团队成员用错事件名编译期直接报错不用等运行时玄学报错。3.2 kebab-case和camelCase那点破事Vue3官方文档建议事件名用kebab-case但实际开发中我在TS类型里更推荐camelCase因为类型字符串里写camelCase更自然。模板里监听可以用submit也可以写成on-submit两种都可以工作但要注意名字的一致性。更关键的是事件命名需要避开原生DOM事件名。我有一个线上项目曾经出过这样的问题子组件里定义了一个emit(click, data)父组件写clickhandleChildClick。看起来运行正常但当这个子组件在某个父页面里和其他原生click混在一起时就非常容易造成混淆——事件到底是从子组件发射的还是原生DOM的click后来我强制团队规范自定义事件统一用业务前缀比如user-click、item-remove。这个规范看起来死板但排查跨组件事件时能省下大量时间。3.3 紧记$event到底是什么很多新手会在父组件监听时对$event产生疑惑。$event到底是事件对象还是emit出来的数据我直接给结论如果是原生DOM事件监听比如click$event$event是原生事件对象。如果是子组件的自定义事件比如子组件emit(submit, payload)父组件submithandleSubmit那么handleSubmit的第一个参数就是payload跟DOM的Event对象没有任何关系。如果有两个自定义事件的载荷都要传入同一个处理函数那就别用内联$event了直接写箭头函数template Child submit(payload) handleSubmit(from-child, payload) / /template这个基础和细节很多人不注意但面试官很喜欢拿它出题。4. v-model双绑也可以一个组件玩出花4.1 v-model的本质是语法糖很多Vue开发者都用过v-model做表单双向绑定但少有人意识到Vue3的v-model其实是:modelValue加上update:modelValue的语法糖。!-- 父组件写法 -- Child v-modelsearchText / !-- 等价于 -- Child :modelValuesearchText update:modelValuesearchText $event /子组件内部的实现也很简单!-- Child.vue -- script setup langts const props defineProps{ modelValue: string }() const emit defineEmits{ (e: update:modelValue, value: string): void }() function handleInput(event: Event) { const value (event.target as HTMLInputElement).value emit(update:modelValue, value) } /script理解了这一点你就能明白为什么组件里自定义v-model的时候约定俗成要用modelValue这个字段名。4.2 多个v-model和参数化v-modelVue3支持在一个组件上使用多个v-model还能给它们各自命名这让很多老Vue2开发者很不适应但用熟了之后发现是真的香。比如一个筛选组件需要同时管理关键词、分类、排序三个状态按老写法可能要把组件拆成三个或者传一个复杂对象但现在可以这样!-- 父组件 -- FilterPanel v-model:keywordkeyword v-model:categorycategory v-model:sortsortOrder /这比v-model加一坨props和emits清爽得多。子组件里的定义方式就是props和emits各写一组script setup langts defineProps{ keyword: string category: string sort: asc | desc }() const emit defineEmits{ (e: update:keyword, value: string): void (e: update:category, value: string): void (e: update:sort, value: asc | desc): void }() /script4.3 v-model搭配computed实现局部改写全局同步有时候子组件拿到的v-model数据需要做一次加工再展示但还是要写回父组件。比如一个带单位的价格输入框用户输入123子组件要显示123元但v-model绑定的值应该是纯数字123这个场景可以用computed加setter优雅实现script setup langts const props defineProps{ modelValue: number }() const emit defineEmits{ (e: update:modelValue, value: number): void }() const displayValue computed({ get() { return ${props.modelValue}元 }, set(val: string) { const num parseFloat(val.replace(元, )) if (!isNaN(num)) { emit(update:modelValue, num) } } }) /script这个模式的精髓在于子组件内部可以自由地加工输入输出的数据而对外仍然保持标准的v-model接口调用方完全感知不到内部实现的变化。这种封装能力在表格组件、表单控件类组件开发中特别常用。5. ref加defineExpose什么时候该把子组件内部直接掏出来5.1 获取子组件实例的正确姿势v-model解决了数据同步但有些场景你需要直接调用子组件内部的方法。最典型的就是表单提交校验父组件点提交需要让子表单组件先做一次校验如果通过才提交。在Vue3的script setup中子组件默认是闭合的外部拿不到内部定义的方法和属性。这时候必须用defineExpose显式暴露!-- FormChild.vue -- script setup langts function validate(): boolean { // 校验逻辑 return true } defineExpose({ validate }) /script父组件里就用模板ref去拿子组件实例template FormChild refformRef / /template script setup langts import { ref } from vue const formRef refInstanceTypetypeof FormChild | null(null) function handleSubmit() { if (formRef.value?.validate()) { // 提交 } } /script5.2 一个关于onMounted的经典坑很多新手拿到ref后在父组件的onMounted里立刻去调用子组件的方法结果发现拿不到。原因是父组件的onMounted触发在子组件的onMounted之后但这不代表子组件的实例和方法就一定可用。如果你在父组件的setup同步代码中直接访问formRef.value那必然是null因为子组件还没挂载。正确的方式是在异步时机里访问比如onMounted、事件回调、nextTick之后import { onMounted, nextTick, ref } from vue onMounted(async () { await nextTick() formRef.value?.validate() })5.3 defineExpose的使用边界别把子组件变成无底洞这个方案的便利性也不该被滥用。如果一个父组件经常需要直接操作子组件的内部方法那你要反思你们的组件边界是否划分合理组件应该是自治的外部通过事件或props来协作而不是像操作一个对象一样从外面随意调用它的内部逻辑。我自己的项目中defineExpose主要用于两类场景表单类组件的校验或重置方法需要精确控制DOM行为的组件比如图表组件暴露resize方法、滚动容器暴露scrollToTop如果代码里出现了大量的xxxRef.value.xxx()我的第一反应是检查组件设计是不是出了问题而不是继续往这个洞里加方法。尽量减少这种穿透式调用会让组件的独立性高很多重构时也能少花时间。6. provide和inject跨层级通信的响应式大坑6.1 provide/inject的定位自上而下的共享通道遇到祖先组件需要给深层孙组件传数据你可能会本能地想props一层层传呗。但层级一旦超过三层这种透传就是灾难中间层组件明明不需要这个数据却得在props里声明一遍只为了把它往下递。这时provide/inject的好处就出来了。// App.vue祖先组件 script setup langts import { provide, ref } from vue const theme reflight | dark(light) provide(theme, theme) /script!-- 任意深层子组件 -- script setup langts import { inject } from vue const theme injectReflight | dark(theme) /script6.2 非响应式的inject最经典的死值问题我见过太多项目里的这个坑开发者在祖先组件里provide(userInfo, userInfo)结果userInfo是一个普通对象。等用户信息更新后孙组件里怎么都不刷新。原因很简单provide只能保证传入的值能拿到但它不会自动帮你把值变成响应式。如果传入的是普通对象它就只是一个静态快照。正确做法是import { provide, ref } from vue const userInfo ref({ name: Tom, age: 18 }) provide(userInfo, userInfo)孙组件里要么用inject配合computed去派生要么严格遵守通过方法修改的约定// 同层或祖先组件中提供修改函数 function updateUserName(name: string) { userInfo.value.name name } provide(updateUserName, updateUserName)6.3 关于修改权限inject进来的数据别直接改我在代码审查里经常看到一些人直接在孙组件里写userInfo.value.name Jerry然后还振振有词反正inject拿到的是ref改一下就能同步到祖先了。这种写法有个很严重的问题数据的变更来源会变得不可追踪。如果十个组件都能修改userInfo改到后面你根本不知道是哪个组件改的、什么时候改的。我的实践经验是provide时响应式数据和修改方法成对提供。数据由提供者统一管理消费者只能通过方法去修改。如果需要也可以用readonly包裹一层防止被直接改写import { provide, readonly, ref } from vue const userInfo ref({ name: Tom }) function setUserName(name: string) { userInfo.value.name name } provide(userInfo, readonly(userInfo)) provide(setUserName, setUserName)这样既保证了跨层级传值的便利又守住了数据流的统一出口。站在维护者的角度看十个组件里只有一个能改数据调试成本会从大海捞针降级到顺藤摸瓜。6.4 别把provide/inject当成免费的全局状态管理provide/inject虽然好用但它不是为跨路由、跨页面的大范围共享设计的。它最合适的场景是一个组件子树内部的服务供给某个页面容器给它的多个子组件提供用户信息某个布局组件给内部的导航、侧边栏提供菜单展开状态提供全局的主题、语言配置如果你发现provide的数据需要跨路由、跨多个完全不相关的页面共享那它就不是provide的职责了。直接上Pinia吧别硬撑。7. mitt事件总线被误解但偶尔真香的方案7.1 Vue3里为什么事件总线过气了Vue2时代有一个很流行的通信方式$on、$off、$emit做全局事件总线。到了Vue3官方移除了实例上的$on和$off社区推荐的替代方案是 mitt ——一个只有200多字节的小库。Pinia出现之后事件总线被很多人鄙视说它会导致状态混乱。这个批评有道理但要分场景。事件总线最大的问题是没有状态的概念谁发射了事件、谁监听了事件都是隐式的。如果项目里大量使用事件总线传数据简直是在给自己埋雷。7.2 我还在用mitt的两个真实场景话虽如此我维护的一个中后台项目里依然在使用mitt而且效果很不错。场景是这样的第一个场景是表头和表格内容组件之间的联动。我们的表格组件拆成了头部包含排序、筛选和内容区两部分它们不是父子关系但需要非常频繁地通信点击表头排序内容区要触发重排内容区滚动到特定位置表头要同步高亮。用Pinia来管理这种高度时序性的UI协作就很别扭因为根本不需要持久化状态只要当下触发一次就好。mitt一发一收干净利落。第二个场景是权限更新后通知全页面刷新用户菜单。用户重新登录或切换角色后整个页面布局都要刷新菜单。如果用Pinia我得改状态、等watch触发绕一大圈。用mitt直接触发一个refresh-menu事件布局组件监听后重新拉取菜单简单粗暴。// mitt.ts import mitt from mitt type Events { refresh-menu: void sort-change: { key: string; direction: asc | desc } } const emitter mittEvents() export default emitter7.3 用mitt一定要记得off用事件总线最怕的就是内存泄漏和重复监听。组件卸载的时候如果不off掉监听回调会残留在总线里。特别是SPA应用组件反复挂载和卸载监听器越积越多事件触发时多个已销毁组件还会各自执行一遍逻辑这是很糟糕的体验。我的习惯是在组件中这样写import { onMounted, onUnmounted } from vue import emitter from /utils/mitt function handleMenuRefresh() { // 处理逻辑 } onMounted(() { emitter.on(refresh-menu, handleMenuRefresh) }) onUnmounted(() { emitter.off(refresh-menu, handleMenuRefresh) })再给个更稳的建议在mitt封装里统一管理事件名避免字符串魔法值散落在代码里。我们上面用了type Events定义这样IDE能自动补全事件名传参也有类型校验算是弥补了事件总线最大的短板。7.4 事件总线 vs provide/inject vs Pinia的抉择有一些经验可以分享需要跨层级但只在一棵组件子树内共享状态优先provide/inject。需要跨路由、跨页面共享持久状态直接Pinia。临时性的UI交互提醒、非状态类的业务信号可以考虑mitt。两者都行但拿不准时优先Pinia因为它有DevTools的time-travel调试排障优势太明显了。8. Pinia兜底共享状态的最终归宿不是无脑塞8.1 storeToRefs解决响应式丢失Pinia现在已经是Vue3官方推荐的全局状态管理库。但很多从Vue2的Vuex转过来的人会写出一堆低级错误。最典型的就是解构store之后发现状态不响应了import { useUserStore } from /stores/user const userStore useUserStore() // 这样解构出来的userInfo不具备响应性 const { userInfo } userStore正确姿势是用storeToRefsimport { storeToRefs } from pinia const userStore useUserStore() const { userInfo } storeToRefs(userStore)可以这样理解Pinia的store本身是用reactive包装的它解构之后和普通响应式对象解构遇到的问题是一模一样的。storeToRefs就是专门为它打造的响应式解构工具。8.2 别把store当成万能储物间比起这种API层面的小坑我更想重点说说一个架构层面的大坑把不该放进store的数据全塞进去。我见过不少项目的store里全是各种组件内部临时状态比如弹窗的显隐、某个表格的当前筛选条件、某个表单的草稿。这些状态只属于特定的组件子树放到store里除了让store越变越大、越来越难维护之外没有任何好处。每次打开DevTools看到store里几百个字段查一个问题得翻半天。我的建议是只在多个不相关组件尤其是跨路由、跨页面需要共享时才放store。单个组件内部的临时状态保持在组件内部就好。某个组件子树内部的共享状态优先用provide/inject。粒度上一个业务模块建一个store不要搞一个巨大的root store包罗万象。8.3 Pinia的性能考量还有一点可能被忽略store的state是全局响应式的任何组件访问它都会建立依赖追踪。如果某个store特别大、字段特别多而组件频繁地修改其中的值会连带触发大量组件的重新渲染。在业务中我会把store拆分成多个更小的store并且避免让一个组件同时依赖太多store。比如用户信息、权限、购物车、消息通知各自独立。这样数据流清晰修改一个store时颗粒度也更小渲染性能也更能把控。把store的边界画清楚比花时间去调什么响应式性能优化有意义得多。对于大多数中后台系统来说组件的更新范围控制住了性能问题就解决了一大半。9. 实际项目中我是怎么选的最后分享一个我在带团队时惯用的决策流程也算是我这几年写Vue3组件通信的一个经验沉淀。拿到一个通信需求我通常会连续问自己四个问题这两个组件是父子关系吗是优先props emits。需要双向同步吗是把v-model用起来别手动绑一坨props和事件。数据需要跨很多层级传递吗是看看是否限定在一个页面/模块内部是就provide/inject涉及跨路由就上Pinia。只是临时通知一下不需要持久状态吗是这时候才考虑mitt。这套流程看着简单但坚持下来之后项目的代码结构会变得非常干净。数据流的修改源头永远清晰可查组件边界也稳。多说一句关于面试的。组件通信几乎是Vue3面试必考题但面试官真正想听到的绝不是你会背几个API。他更想确认的是你知不知道props是只读的、为什么你知不知道v-model的本质是语法糖遇到跨层级通信时你的权衡思路是什么。一个能把为什么讲清楚的人才真的算把Vue3熟练掌握了。我自己刚学Vue3时也被这些通信方式搞昏头过后来带的几个新人更是踩了一轮又一轮的坑。希望这一节的内容能让你少走这些弯路。Vue3魔法手册的组件通信篇就到这里下一节我们继续聊别的玩法。
返回列表