聊Vue3的组件通信,说来说去绕不开父子这一对。我最早接触Vue2时,props和$emit就能解决大多数问题,到了Vue3组合式API全面铺开之后,通信方式变多了,可选的方案也变复杂了:有defineProps、defineEmits、v-model、defineModel、透传attribute、插槽、provide/inject、模板ref、Pinia……新手很容易看花眼,老手也偶尔会遇到“不知道该用哪一种”的选择困难。这篇文章就把Vue3父子组件通信的常用姿势从头到尾理一遍,不光讲怎么写,也讲为什么这么写、什么时候该选哪种方案。适合刚把Vue3语法捡起来的人,也适合已经在写项目但想梳理一下技术选型的人。
1. 先建立整体认知:父子通信究竟在解决什么问题
1.1 组件树中的“上下级”关系
一个Vue应用从根组件开始,可以长成一棵组件树。页面A里挂了头部组件Header,Header里又挂了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 与 emit
2.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 = defineProps<Props>() </script>父组件里使用子组件时:
<template> <Child :title="pageTitle" :count="totalCount" /> </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 @send="handleSend" @update="handleUpdate" /> </template> <script setup> function handleSend(data: any) { console.log('收到子组件数据', data) } function handleUpdate(val: string) { console.log('收到更新值', val) } </script>defineEmits的作用不只是声明一下,它还帮助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做静态校验。两套方案可以并存,运行时校验兜底,类型校验兜编译期。我做组件库的时候习惯两套都加:defineProps<Props>()用来让编辑器提示字段,withDefaults用来处理默认值,Vue3范围外再手动做一些条件判断:
<script setup> interface Props { size?: 'small' | 'medium' | 'large' disabled?: boolean } const props = withDefaults(defineProps<Props>(), { size: 'medium', disabled: false }) </script>这里要特别说明一下:withDefaults只负责默认值,如果父组件显式传了null,默认值不会生效。有些同学以为disabled: null会落到false上,结果Canvas渲染和样式全乱了,就是因为没理解这个问题。
emits的参数类型也一样,要避免使用any。组件边界处一旦放开类型,内部逻辑写得再安全,往外传的时候也可能把错误的数据结构带给上层,排查起来特别费劲。
2.4 实操中容易踩的坑
先说命名。Vue3对事件名没有强制的写法要求,但官方推荐使用camelCase定义、kebab-case监听。我在项目里见过两种写法混用,结果某些环境下监听失效,排错排了半小时。建议全组统一:子组件里emit('updateUser', data),父组件里@update-user="handle",或者全用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-model="username" />等价于:
<input :value="username" @input="username = $event.target.value" />对组件来说也一样:
<Child v-model="pageName" />等价于:
<Child :modelValue="pageName" @update:modelValue="pageName = $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 :value="modelValue" @input="onInput" /> </template>父组件:
<BaseInput v-model="searchText" />这样写出来的组件,在父组件里可以直接用v-model,双向绑定的链路是完整的。Vue3官方还支持多个v-model,可以写成:
<Child v-model:title="title" v-model:content="content" />子组件接收title和content两个props,对应发出update:title和update:content事件。这种多v-model写法在封装表单类高级组件时非常香,比如日期范围选择器、筛选器面板。
3.3 defineModel 的写法与演进
Vue3.4推出了defineModel宏,目的是把自定义组件的v-model从“双声明、双事件”的样板代码里解放出来:
<!-- BaseInput.vue --> <script setup> const modelValue = defineModel<string>({ default: '' }) </script> <template> <input v-model="modelValue" /> </template>是不是干净很多?defineModel返回一个ref,可以直接在模板里用v-model绑定,也可以使用modelValue.value来读写。底层还是会展开成modelValueprops和update:modelValue事件,但开发者不用手写那套通信代码了。
指定参数名也很方便:
<Child v-model:title="title" v-model:content="content" />子组件:
<script setup> const title = defineModel<string>('title') const content = defineModel<string>('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 = defineModel<number>('current') const pageSize = defineModel<number>('pageSize')代码量直接少了一半,可读性也提高了。建议新项目在Vue版本满足要求时优先使用defineModel,老项目如需升级API,再仔细测一遍边界情况。
4. 跨层级的帮手:插槽、attr 透传与 provide/inject
4.1 插槽:父组件往子组件里塞内容
严格意义上,插槽不是“传数据”,而是“传结构”。父组件把要显示的内容拼好,塞进子组件内部某个位置。组件封装里,弹窗、卡片、表格列渲染都重度依赖插槽。
默认插槽:
<!-- Card.vue --> <template> <div class="card"> <slot /> </div> </template>父组件:
<Card> <p>我是卡片内容</p> </Card>具名插槽解决多位置的问题:
<!-- LayoutCard.vue --> <template> <div class="card"> <header> <slot name="header" /> </header> <main> <slot /> </main> <footer> <slot name="footer" /> </footer> </div> </template>父组件:
<LayoutCard> <template #header>标题区</template> <p>默认区内容</p> <template #footer>底部</template> </LayoutCard>作用域插槽则把选择权交给父组件。子组件内部的数据通过插槽属性暴露给父组件,父组件决定UI长什么样:
<!-- ListItem.vue --> <template> <li> <slot name="item" :data="item" :index="index" /> </li> </template>父组件里:
<ListItem v-for="item in list" :key="item.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 class="app-btn"> <slot /> </button> </template>使用方:
<AppButton class="submit-btn" type="submit" :disabled="isSubmitting"> 提交 </AppButton>class和type、disabled自动落到按钮根元素上。如果希望它们落到某个内部元素而不是根元素,需要手动绑定$attrs:
<template> <div class="wrapper"> <button v-bind="$attrs"> <slot /> </button> </div> </template>inheritAttrs: 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') </script>inject可以带默认值,防止祖先没有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里先给子组件加ref:
<template> <Child ref="childRef" /> </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: Record<string, 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')定义store:
import { 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 的细节
父组件和子组件都可以直接读写store:
<script 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-case |
| v-model绑定的值不更新 | 子组件忘了emit update:modelValue | 检查emit声明和调用 |
| defineModel写入后报警告 | 父组件没绑定v-model | 父组件补充v-model绑定 |
| ref拿不到子组件方法 | 子组件没有defineExpose | 子组件显式defineExpose |
| provide/inject拿到undefied | 祖先没有对应provide | 给inject加默认值 |
| 插槽内容不更新 | 插槽作用域变更而外界没感知 | 检查父组件的响应式数据源 |
| attrs重复落到根元素 | 自动继承和手动绑定同时存在 | 设置inheritAttrs: false |
7.2 从报错信息反推通信类型
Vue3的报错信息一般比较友好,但有些提示还是容易让人懵。碰到这类问题,我有一套固定的排查路径:
第一步,定位报错组件。错误信息里通常带着组件名称,或者从堆栈找到最近一次渲染位置。先确定“哪个组件在解决哪个通信”。
第二步,看清方向。数据是向下传还是向上传?如果是向上传,看看子组件有没有emit;如果是向下传,看看父组件有没有绑定属性。绝大多数开发中的通信问题,都出在“你以为传了其实没传”或者“方向搞反了”。
第三步,怀疑响应式丢失。特别是把props解构成普通变量后,传递链就断了。用computed包一层,或者直接用props.xxx在模板里渲染。
第四步,怀疑生命周期。ref实例在setup阶段访问不到,onMounted里才安全。在v-if切换组件时,父组件里的旧ref不会自动同步到新实例,要手动重新获取。
7.3 我的组件通信设计习惯
写组件库和业务系统这么多年,我总结了几条习惯,分享给正在学习的人:
一是先定义“谁是数据的主人”。通信之前问一句:这个状态到底属于父组件还是子组件?如果数据是页面的,应该放父组件;如果只是子组件内部交互,别动不动提给父组件。
二是能靠越少机制解决问题越好。有一个半项目经历让我特别有体会:一开始用props+emit,后来为图方便改为provide/inject,再后来状态变多,又引入Pinia,结果排查问题时三个机制在重叠。只要不是跨页面共享,尽量把状态留在组件边界内。
三是组件接口设计要有“边界意识”。父组件暴露给子组件的东西要少而清晰;子组件暴露给父组件的也一样。defineExpose别一股脑全暴露,props别把所有字段都塞进去,要尽量把“外部关心的”和“内部私有的”分开。
四是多写注释,特别是“为什么”。通信链路一旦断开,重新接上的成本很高。在provide的注入名上写清“这个key给谁用、期望什么结构”,在emit的事件名上写清“触发时机和参数说明”,这些注释比大部分文档都值钱。
Vue3的父子组件通信本身不复杂,复杂的是面对一堆可选API时如何做出合适取舍。每个API设计的初衷都很明确,我要做的只是把“方向、边界、场景”理清楚。如果读完这篇文章,你能在下次写组件时多花半分钟想一下“这里用哪种方案最合理”,那这篇总结就没白写。我在实操中最常对同事说的一句话是:通信方式不是越高级越好,能让下一个维护者一眼看懂的方式,才是好方式。