
聊Vue3的组件通信说来说去绕不开父子这一对。我最早接触Vue2时props和$emit就能解决大多数问题到了Vue3组合式API全面铺开之后通信方式变多了可选的方案也变复杂了有defineProps、defineEmits、v-model、defineModel、透传attribute、插槽、provide/inject、模板ref、Pinia……新手很容易看花眼老手也偶尔会遇到“不知道该用哪一种”的选择困难。这篇文章就把Vue3父子组件通信的常用姿势从头到尾理一遍不光讲怎么写也讲为什么这么写、什么时候该选哪种方案。适合刚把Vue3语法捡起来的人也适合已经在写项目但想梳理一下技术选型的人。1. 先建立整体认知父子通信究竟在解决什么问题1.1 组件树中的“上下级”关系一个Vue应用从根组件开始可以长成一棵组件树。页面A里挂了头部组件HeaderHeader里又挂了Logo、UserInfo、NavMenu这就构成了父子层级。实战里最常见的通信诉求就是父组件希望把状态传给子组件比如用户信息、列表配置项子组件希望把操作结果告诉父组件比如点击按钮、选择完成、表单校验失败某些场景下父组件还想直接调用子组件内部的方法比如打开弹窗、执行校验。这些诉求本身就决定了通信方案的门类数据向下走、事件向上走、方法显式暴露。Vue3把每种诉求都给出了对应的API但API之间边界有重合所以先想清楚“我是谁、我要干什么”比一上来就抄代码更重要。1.2 单向数据流Vue 骨子里的规矩要理解Vue3通信绕不开单向数据流。父组件的数据通过props传给子组件后子组件不应该直接修改props值。表面上看是Vue内部有警告提醒实际上的设计原因是数据源头如果太多状态一旦被改乱你根本不知道是哪个地方动了它。单向数据流让数据变更的方向清晰可追踪父组件改状态子组件接收新值子组件要改向上派发事件请求父组件来改。在Vue3里这套规则没有变化只是在写法上更加严格。比如你直接在子组件里写props.name 新名字控制台会立刻弹出警告。有人说这也太死板了传一个对象进来不就能改里面字段了吗确实可以改对象属性Vue不会拦你但这种写法破坏了单向数据流的语义项目一复杂就埋雷。所以我不建议拿“能跑”当“能用”。1.3 通信方式全景图在进入细节前可以先看一张全景表。这张表我写项目时经常拿来对照比背API好用通信方式方向典型场景使用频率props父 → 子传配置、传初始值、传数据源极高emits子 → 父通知事件、回传数据极高v-model双向表单类组件的双向绑定高defineModel双向Vue3.4 简化v-model绑定新项目推荐插槽父 → 子父组件控制子组件内部内容结构高attrs透传父 → 子class、style、原生属性、事件高provide/inject跨层级祖先组件向深层后代传值中模板ref命令式父组件调用子组件方法中Pinia全局跨组件跨页面共享状态中这张表里每一项后面都会展开讲。我个人的建议是第一优先级学透props、emits、v-model这几种能覆盖日常七成场景插槽和透传是组件封装的底气provide/inject和Pinia属于特定场景的救星ref属于兜底手段能不用就不用但必须会。2. 最基础也最常用props 与 emit2.1 props父组件向子组件传递数据props是父子通信的骨架。在Vue3的script setup写法里声明props的姿势非常简洁!-- Child.vue -- script setup const props defineProps({ title: { type: String, default: }, count: { type: Number, required: true } }) console.log(props.title) /script也推荐使用TypeScript语法来声明Vue3对类型推导的支持做得相当好script setup type Props { title?: string count: number } const props definePropsProps() /script父组件里使用子组件时template Child :titlepageTitle :counttotalCount / /template script setup import Child from ./Child.vue import { ref } from vue const pageTitle ref(首页标题) const totalCount ref(20) /script这里有个容易被忽略的细节:count传的是数字count传的是字符串。如果子组件需要数字类型父组件这边必须写冒号绑定否则后续做数值计算时会出现隐式转换的麻烦。这种细微区别写代码时不会报错但会留下“看起来正常、用起来不对”的隐患。props传引用类型时也要留意。数组、对象传进子组件后完成的是浅层响应式代理而不是深拷贝。这意味着子组件改对象内部字段父组件这边数据也会同步变化。有些同学会问这不是违背单向数据流吗严格说Vue的警告只针对直接重新赋值props对象字段的修改Vue无法拦截。可一旦两份代码同时维护同一个对象心智负担就会翻倍。我的经验是能只读就只读子组件需要加工数据时要么用computed生成派生值要么发事件让父组件改。2.2 emits子组件向父组件派发事件子组件想把数据往上递靠的是defineEmits!-- Child.vue -- script setup const emit defineEmits([send, update]) function handleSend() { emit(send, { id: 1, name: 张三 }) } function handleUpdate(val: string) { emit(update, val) } /script父组件监听template Child sendhandleSend updatehandleUpdate / /template script setup function handleSend(data: any) { console.log(收到子组件数据, data) } function handleUpdate(val: string) { console.log(收到更新值, val) } /scriptdefineEmits的作用不只是声明一下它还帮助Vue做事件校验并且让其他开发者一眼看清子组件向外部暴露了哪些能力。如果项目里没有这层声明父组件仍然可以靠send监听到事件但代码将变得非常难维护——你根本不知道子组件在哪个角落emit了什么。实际开发中还推荐给事件定义payload的类型script setup type SendPayload { id: number name: string } const emit defineEmits{ (e: send, payload: SendPayload): void (e: update, val: string): void }() /script这种写法非常有利于重构。哪天子组件的事件参数结构变了编译器会直接提示不用翻联调文档。2.3 props 校验和事件参数的类型细节props对比Vue2校验选项基本保留但Vue3更推荐用TypeScript做静态校验。两套方案可以并存运行时校验兜底类型校验兜编译期。我做组件库的时候习惯两套都加definePropsProps()用来让编辑器提示字段withDefaults用来处理默认值Vue3范围外再手动做一些条件判断script setup interface Props { size?: small | medium | large disabled?: boolean } const props withDefaults(definePropsProps(), { size: medium, disabled: false }) /script这里要特别说明一下withDefaults只负责默认值如果父组件显式传了null默认值不会生效。有些同学以为disabled: null会落到false上结果Canvas渲染和样式全乱了就是因为没理解这个问题。emits的参数类型也一样要避免使用any。组件边界处一旦放开类型内部逻辑写得再安全往外传的时候也可能把错误的数据结构带给上层排查起来特别费劲。2.4 实操中容易踩的坑先说命名。Vue3对事件名没有强制的写法要求但官方推荐使用camelCase定义、kebab-case监听。我在项目里见过两种写法混用结果某些环境下监听失效排错排了半小时。建议全组统一子组件里emit(updateUser, data)父组件里update-userhandle或者全用camelCase关键是不要随手混。第二是事件穿透。子组件根节点上如果同时绑定了原生事件和自定义事件Vue3的事件合并规则和Vue2不同。Vue2中父组件监听子组件根元素的原生事件靠的是native修饰符Vue3不再需要这个修饰符父组件监听的事件会默认当作自定义事件处理除非子组件根元素上也直接绑定了同名的原生事件。遇到过场景父组件给子组件加了click以为点子组件内部会触发结果没有。原因就是子组件内部没有向根元素传递click事件。解决办法是子组件内部用defineEmits([click])显式声明或者干脆用inheritAttrs配合事件穿透。第三点是props解构。在script setup里const { title } defineProps()虽然可以解构但解构出来的变量默认失去响应式。你用title去渲染模板是没问题的模板里会自动访问props.title可如果你在computed或者普通函数里使用解构后的title值就是快照。想拿响应式值推荐const title computed(() props.title)或者使用Vue3.5开始的usePropsDestructure方案新项目可以尝试。3. v-model 与 defineModel把双绑写进组件3.1 v-model 的本质v-model在Vue3里的本质是modelValue加update:modelValue的语法糖。模板里input v-modelusername /等价于input :valueusername inputusername $event.target.value /对组件来说也一样Child v-modelpageName /等价于Child :modelValuepageName update:modelValuepageName $event /很多人在自定义组件时被这块绕晕原因是对“v-model到底在编译成什么”不熟。只要我理解了它是“值 事件”的组合自定义组件的v-model就只剩下机械操作了。3.2 自定义组件支持 v-model自封装一个输入型组件最经典的写法是!-- BaseInput.vue -- script setup const props defineProps({ modelValue: { type: String, default: } }) const emit defineEmits([update:modelValue]) function onInput(event: Event) { const value (event.target as HTMLInputElement).value emit(update:modelValue, value) } /script template input :valuemodelValue inputonInput / /template父组件BaseInput v-modelsearchText /这样写出来的组件在父组件里可以直接用v-model双向绑定的链路是完整的。Vue3官方还支持多个v-model可以写成Child v-model:titletitle v-model:contentcontent /子组件接收title和content两个props对应发出update:title和update:content事件。这种多v-model写法在封装表单类高级组件时非常香比如日期范围选择器、筛选器面板。3.3 defineModel 的写法与演进Vue3.4推出了defineModel宏目的是把自定义组件的v-model从“双声明、双事件”的样板代码里解放出来!-- BaseInput.vue -- script setup const modelValue defineModelstring({ default: }) /script template input v-modelmodelValue / /template是不是干净很多defineModel返回一个ref可以直接在模板里用v-model绑定也可以使用modelValue.value来读写。底层还是会展开成modelValueprops和update:modelValue事件但开发者不用手写那套通信代码了。指定参数名也很方便Child v-model:titletitle v-model:contentcontent /子组件script setup const title defineModelstring(title) const content defineModelstring(content) /script这里要注意defineModel不是单纯的响应式变量它毕竟是props的语法糖所以不要抱着“改本地数据”的心态去更新它只要父组件这边没写v-model子组件写回值就会报警告。想做成内部状态还是要拆变量、加watch。3.4 v-model 多参数绑定遇到需要一次绑定多个值的场景props emits写法要声明两个参数defineModel写法清爽很多。我做分页组件时需要外面控制current和pageSize两个量早年的写法是defineProps({ current: Number, pageSize: Number }) defineEmits([update:current, update:pageSize])后来改成const current defineModelnumber(current) const pageSize defineModelnumber(pageSize)代码量直接少了一半可读性也提高了。建议新项目在Vue版本满足要求时优先使用defineModel老项目如需升级API再仔细测一遍边界情况。4. 跨层级的帮手插槽、attr 透传与 provide/inject4.1 插槽父组件往子组件里塞内容严格意义上插槽不是“传数据”而是“传结构”。父组件把要显示的内容拼好塞进子组件内部某个位置。组件封装里弹窗、卡片、表格列渲染都重度依赖插槽。默认插槽!-- Card.vue -- template div classcard slot / /div /template父组件Card p我是卡片内容/p /Card具名插槽解决多位置的问题!-- LayoutCard.vue -- template div classcard header slot nameheader / /header main slot / /main footer slot namefooter / /footer /div /template父组件LayoutCard template #header标题区/template p默认区内容/p template #footer底部/template /LayoutCard作用域插槽则把选择权交给父组件。子组件内部的数据通过插槽属性暴露给父组件父组件决定UI长什么样!-- ListItem.vue -- template li slot nameitem :dataitem :indexindex / /li /template父组件里ListItem v-foritem in list :keyitem.id template #item{ data, index } span{{ index }} - {{ data.name }}/span /template /ListItem这种双向往返的写法本质上是把“渲染逻辑的控制权”交给了父组件但又让子组件保留了数据供给。组件库里的表格列自定义、下拉选项自定义模板底层都是这个原理。插槽写好了“插槽内容何时更新”也要心里有数。插槽内容是在父组件作用域里编译的所以父组件状态一变插槽区域会自动更新。子组件在里面对slot做v-if或过滤不要试图把插槽里的数据当作子组件内部状态那样思维就拧了。4.2 attrs 透传自动继承没有声明的属性Vue3里组件根节点会自动继承组件标签上未被props和emits声明的attribute。这个机制在做基础组件时特别实用。比如你封装了一个AppButton希望使用方可以直接传class、style、disabled、原生type等属性不需要每个都在props里登记!-- AppButton.vue -- template button classapp-btn slot / /button /template使用方AppButton classsubmit-btn typesubmit :disabledisSubmitting 提交 /AppButtonclass和type、disabled自动落到按钮根元素上。如果希望它们落到某个内部元素而不是根元素需要手动绑定$attrstemplate div classwrapper button v-bind$attrs slot / /button /div /templateinheritAttrs: false可以关闭自动继承避免事件和属性重复落下。用useAttrs()可以把它当作普通对象操作script setup import { useAttrs } from vue const attrs useAttrs() /script注意useAttrs()本身不是响应式对象如果你根据某个attribute做条件渲染模板里直接使用attrs会有更新问题。真要在逻辑里读取并响应推荐computed(() attrs)或者直接把它映射成普通props。4.3 provide/inject跨越中间层直接通信provide/inject解决的是“祖孙通信”问题。父组件provide一个值任意深层子组件都能inject到不需要逐层传props。项目里最常见的用法是主题配置、用户信息、全局筛选条件。父组件script setup import { provide, ref } from vue const theme ref(light) const user { name: 张三 } provide(theme, theme) provide(user, user) /script孙组件script setup import { inject } from vue const theme inject(theme) const user inject(user) /scriptinject可以带默认值防止祖先没有provide时报错const theme inject(theme, ref(light))这里有一个经常被问到的点provide传的对象是响应式的吗如果provide的是ref那inject方拿到的是ref对象天然带响应式两边共享同一个引用。如果provide的是普通对象Vue不会把它变成响应式。项目里要共享配置推荐直接用reactive或者ref包一层。另一个注意点是provide/inject是单向的祖先提供数据后代消费数据。后代直接修改inject到的ref虽然能改但违背了设计初衷一旦多个组件都去改排查成本很高。真要改让祖先提供修改方法后代调用。4.4 三种方式的适用边界插槽、attrs透传、provide/inject三者的边界经常混。我建议从“数据往哪走”来判断插槽适合父组件控制子组件的内容结构数据还是父组件这边的attrs透传适合把原生属性、样式类名、原生事件交给不知名后代去处理provide/inject适合跨越好几层组件快速共享配置和上下文。如果只是父子两层不要一上来就用provide/inject。多绕一层在代码可读性上不算灾难但对“这个值是从哪来的”这个问题来说逐层查询的成本会明显增加。能靠props讲清楚的通信不急着升级成magic。5. ref 与 defineExpose命令式操作子组件5.1 模板 ref 拿到组件实例props和事件都是数据层面的通信父组件调用子组件方法则是命令式通信。Vue3里先给子组件加reftemplate Child refchildRef / /template script setup import Child from ./Child.vue import { ref, onMounted } from vue const childRef ref() onMounted(() { childRef.value?.focus() }) /script子组件内部如果想把方法暴露给父组件需要defineExpose。Vue3的script setup默认不暴露任何成员给外部必须在子组件里显式声明!-- Child.vue -- script setup import { ref } from vue const innerData ref(0) function refresh() { innerData.value } defineExpose({ refresh, innerData }) /script父组件就能拿到innerData和refresh了。5.2 defineExpose 精确暴露方法很多初学者疑惑为什么我在子组件里定义了一个const函数父组件通过ref拿不到就是因为script setup默认是“闭门”状态。defineExpose就是那把钥匙。但暴露什么暴露多少要克制。B站看视频用到的弹窗组件我一般只暴露open、close、updateData这种动作型方法不把组件内部的reactive对象全盘交出去。父组件一旦能直接改子组件的内部状态边界就没了。哪天内部数据结构调整所有外部调用方都可能跟着崩。更稳妥的做法是封装成动作接口比如defineExpose({ open(payload: Recordstring, any) { state.visible true state.payload payload }, close() { state.visible false } })用方只关心open和close不关心内部怎么存数据。5.3 什么时候适合用命令式调用命令式通信适合“一次性动作”打开弹窗、聚焦输入框、滚动到指定位置、手动触发校验。不适合“持续运行的状态流”。我把这类调用当作例外而不是默认方案。如果能用props事件把状态传清楚就别用ref调用方法。因为命令式调用一旦多了父组件和子组件的生命周期就要耦合。比如父组件onMounted里调子组件方法子组件还没挂载完拿到的实例可能没有对应方法再比如v-if切换子组件旧实例已经卸载新实例还没建立。这类时序问题排查起来非常消耗精力。要是实在绕不开命令式调用建议封装上一层“防抖”或“状态判断”在父组件侧判断childRef.value是否存在、目标方法是否存在再执行调用function safeCall(methodName: string, ...args: any[]) { const instance childRef.value if (instance typeof instance[methodName] function) { instance[methodName](...args) } }6. 全局状态管理Pinia 是父子通信的下限兜底6.1 Pinia 解决的问题Pinia和组件通信有什么关系当一个状态被多个方向的组件共享时比如用户信息、购物车、权限标识再用props一层一层传会累死人。Pinia把共享状态抽出来放到store里任何组件都能读、能写不再关心组件层级。安装和基本使用npm install piniamain.js里注册import { createApp } from vue import { createPinia } from pinia import App from ./App.vue const app createApp(App) app.use(createPinia()) app.mount(#app)定义storeimport { defineStore } from pinia import { ref } from vue export const useUserStore defineStore(user, () { const name ref(张三) const role ref(admin) function setRole(newRole: string) { role.value newRole } return { name, role, setRole } })6.2 父子组件里用 Pinia 的细节父组件和子组件都可以直接读写storescript setup import { useUserStore } from /stores/user const userStore useUserStore() /script这算不算破坏了父子通信我说是的它会绕过props和emits。但这不意味着它是坏设计而是应该克制使用。我见过一个项目登录用户的头像地址放在store里页面组件A改一下store里的昵称组件B通过store拿到新值。跨层级效果确实好可是当store变得越来越大组件之间的关系就越来越模糊。Pinia适合“多个组件需要同一份数据源”的场景不适合“父组件随便塞一个临时变量给子组件”的场景。临时变量的通信就该留在组件边界内别什么都往store里丢。6.3 与 props/emit 如何搭配实际项目里最健康的做法是分层组件内部临时状态组件自己ref/reactive父子组件交互状态props emit跨组件、跨页面共享状态Pinia比如一个弹窗组件父组件控制它显不显示用v-model:visible或者props传入就好这不算“全局状态”。但登录状态、主题色这种东西父传子子传孙传三层就傻了直接丢store合理。还可以在store里使用$patch批量更新或者借助storeToRefs保持响应式script setup import { storeToRefs } from pinia import { useUserStore } from /stores/user const userStore useUserStore() const { name, role } storeToRefs(userStore) /script这样name和role解构出来后仍然带响应式模板里直接用没问题。7. 常见问题与排查心得7.1 常见问题速查表问题现象可能原因解决方向子组件修改props报警告props只读改用emit请求父组件修改父组件监听不到子组件事件事件名大小写不一致统一camelCase或kebab-casev-model绑定的值不更新子组件忘了emit update:modelValue检查emit声明和调用defineModel写入后报警告父组件没绑定v-model父组件补充v-model绑定ref拿不到子组件方法子组件没有defineExpose子组件显式defineExposeprovide/inject拿到undefied祖先没有对应provide给inject加默认值插槽内容不更新插槽作用域变更而外界没感知检查父组件的响应式数据源attrs重复落到根元素自动继承和手动绑定同时存在设置inheritAttrs: false7.2 从报错信息反推通信类型Vue3的报错信息一般比较友好但有些提示还是容易让人懵。碰到这类问题我有一套固定的排查路径第一步定位报错组件。错误信息里通常带着组件名称或者从堆栈找到最近一次渲染位置。先确定“哪个组件在解决哪个通信”。第二步看清方向。数据是向下传还是向上传如果是向上传看看子组件有没有emit如果是向下传看看父组件有没有绑定属性。绝大多数开发中的通信问题都出在“你以为传了其实没传”或者“方向搞反了”。第三步怀疑响应式丢失。特别是把props解构成普通变量后传递链就断了。用computed包一层或者直接用props.xxx在模板里渲染。第四步怀疑生命周期。ref实例在setup阶段访问不到onMounted里才安全。在v-if切换组件时父组件里的旧ref不会自动同步到新实例要手动重新获取。7.3 我的组件通信设计习惯写组件库和业务系统这么多年我总结了几条习惯分享给正在学习的人一是先定义“谁是数据的主人”。通信之前问一句这个状态到底属于父组件还是子组件如果数据是页面的应该放父组件如果只是子组件内部交互别动不动提给父组件。二是能靠越少机制解决问题越好。有一个半项目经历让我特别有体会一开始用propsemit后来为图方便改为provide/inject再后来状态变多又引入Pinia结果排查问题时三个机制在重叠。只要不是跨页面共享尽量把状态留在组件边界内。三是组件接口设计要有“边界意识”。父组件暴露给子组件的东西要少而清晰子组件暴露给父组件的也一样。defineExpose别一股脑全暴露props别把所有字段都塞进去要尽量把“外部关心的”和“内部私有的”分开。四是多写注释特别是“为什么”。通信链路一旦断开重新接上的成本很高。在provide的注入名上写清“这个key给谁用、期望什么结构”在emit的事件名上写清“触发时机和参数说明”这些注释比大部分文档都值钱。Vue3的父子组件通信本身不复杂复杂的是面对一堆可选API时如何做出合适取舍。每个API设计的初衷都很明确我要做的只是把“方向、边界、场景”理清楚。如果读完这篇文章你能在下次写组件时多花半分钟想一下“这里用哪种方案最合理”那这篇总结就没白写。我在实操中最常对同事说的一句话是通信方式不是越高级越好能让下一个维护者一眼看懂的方式才是好方式。