Vue 组件通信大全:8 种方式的适用场景

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客
发布于 2026-09-26 10:38 ·112 浏览 ·7 回复

学完这篇你能拿到一份「按场景选方案」的 Vue 组件通信清单:8 种方式各自怎么写、什么时候用、哪里最容易翻车,对着自己项目里的组件关系直接挑就行。

下面统一使用 Vue 3 + <script setup> 语法(Vue 2 用户看对应概念即可)。

第 1 种:props + $emit —— 父子通信的基本盘

方向:父传子用 props,子传父用 emit。

<!-- 父 -->
<Child :title="title" @submit="onSubmit" />

<!-- 子 Child.vue -->
<script setup>
const props = defineProps({ title: { type: String, default: '' } })
const emit = defineEmits(['submit'])
emit('submit', { id: 1 })
</script>

适用:组件层级 ≤ 2 层的常规数据传递,绝大多数业务组件都用这个。

注意:props 是单向数据流,子组件不要直接改 props。如果传的是对象/数组,子组件改内部属性父组件会同步变化,这种「隐式双向」最容易出 bug,要改就 emit 出去让父组件改。

第 2 种:v-model / defineModel —— 表单类双向绑定

Vue 3.4+ 直接一行搞定:

<!-- 子 Input.vue -->
<script setup>
const model = defineModel({ type: String, default: '' })
</script>
<template><input v-model="model" /></template>

<!-- 父 -->
<Input v-model="form.name" />

需要多个绑定就加名字:父写 <Dialog v-model:visible="show" v-model:title="t" />,子里写 defineModel('visible')、defineModel('title')。

适用:自定义输入框、开关、弹窗显隐这类「读写一体」的组件。

注意:Vue 3.4 之前没有 defineModel,要用 props: ['modelValue'] + emit('update:modelValue', v) 手写。参数名必须是 modelValue / update:modelValue 这套约定。

第 3 种:模板 ref + defineExpose —— 父组件主动调子组件

<!-- 父 -->
<Form ref="formRef" />
<script setup>
const formRef = ref()
function submit() { formRef.value.validate() }
</script>

<!-- 子 Form.vue -->
<script setup>
function validate() { /* ... */ }
defineExpose({ validate })
</script>

适用:打开弹窗、触发表单校验、让输入框聚焦、控制播放器。

注意:<script setup> 默认是封闭的,不写 defineExpose 父组件拿到的 ref 是空对象。另外 ref 要等 onMounted 之后才有值,在 setup 顶层同步调用会报 undefined。

第 4 种:provide / inject —— 跨层级「隔代传」

<!-- 祖先 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)   // 建议用 Symbol 当 key 避免重名
</script>

<!-- 任意后代,不用逐层透传 -->
<script setup>
const theme = inject('theme', 'light')  // 第二个参数是默认值
</script>

适用:主题、语言、当前登录用户、表单上下文、深层嵌套组件。

注意:inject 拿到的值本身不是响应式的——传 ref/reactive 才有响应式,传普通字符串就是死值。provide 必须在 setup 里同步调用,放到 setTimeout 里会失效。

第 5 种:$attrs 透传 —— 二次封装 UI 组件

<!-- 包装组件 MyInput.vue -->
<script setup>
defineOptions({ inheritAttrs: false })
</script>
<template>
  <div class="wrap"><input v-bind="$attrs" /></div>
</template>

适用:包一层 Element Plus / Ant Design 的输入框、按钮,把 placeholder、disabled、@change 原样透下去。

注意:事件监听器也放在 $attrs 里(形如 onChange),所以 v-bind="$attrs" 会把事件一起带下去,不用再单独写一遍。加 inheritAttrs: false 是为了不让属性重复落在根元素上。

第 6 种:插槽 slot —— 父组件传「结构」给子组件

<!-- 子 Table.vue -->
<template>
  <div v-for="row in rows" :key="row.id">
    <slot name="cell" :row="row">{{ row.name }}</slot>
  </div>
</template>

<!-- 父 -->
<Table :rows="rows">
  <template #cell="{ row }"><b>{{ row.name }}</b></template>
</Table>

适用:布局容器、表格列自定义、弹窗底部按钮区。作用域插槽其实是「子传父数据」的另一种正规手段。

注意:默认插槽没内容时,<slot> 标签里的内容作为兜底显示;别在插槽外层用 v-if 控制同一个插槽的渲染位置,容易出渲染顺序问题。

第 7 种:mitt 事件总线 —— 两个「八竿子打不着」的组件

npm i mitt
// bus.js
import mitt from 'mitt'
export const bus = mitt()
// 发送方
bus.emit('refresh-list', { page: 1 })
// 接收方
onMounted(() => bus.on('refresh-list', handler))
onUnmounted(() => bus.off('refresh-list', handler))

适用:一次性的「通知类」通信,比如保存成功后让另一个面板刷新。

注意:必须在 onUnmounted 里 off,否则组件销毁后回调还在跑,重复触发 + 内存泄漏。事件多了之后调用链无法追踪,超过两三处就该换 Pinia。

第 8 种:Pinia —— 全局共享状态

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ name: '', token: '' }),
  actions: { async login() { /* ... */ } }
})
<script setup>
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { name } = storeToRefs(userStore)  // 解构要用 storeToRefs 才保持响应式
userStore.login()
</script>

适用:用户信息、权限菜单、购物车、跨路由页面共享的状态。

注意:const { name } = userStore 直接解构会丢响应式,必须用 storeToRefs(方法可以直接解构)。另外别拿 store 当 props 用,只在单个组件内部用的状态就留在组件里。

补充:不在组件树里的通信

跨页面传参用路由 query / params;跨标签页用 localStorage + storage 事件或 BroadcastChannel。这两类不属于组件通信,别和上面的方案混着用。

小结

  • 父子直连:props + emit,永远是最优先的选择。
  • 读写一体:v-model / defineModel。
  • 父调子:ref + defineExpose,弹窗、校验、聚焦专用。
  • 跨层级:provide / inject,超过两层再考虑。
  • 包装组件:$attrs 透传;传结构:slot。
  • 无关联组件:mitt,记得 off;全局状态:Pinia,解构用 storeToRefs。
  • 判断顺序:先问「谁是父子、隔几层、要不要双向、是不是全局」,四个问题答完,方案基本就定了。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-599.html
转载请注明出处,版权归原作者所有。
他们都看过 2 人浏览过
东来东往CLARA轻量论坛系统

全部回复 7

dp32323
dp32323 正式会员正式会员 1楼 2026-09-26 10:43

这 8 种里日常真正常用的就 4 种,剩下几种都是给特定场景兜底的——不过帖子到第 3 种 defineExpose 那句就断了,后面几种没写完,我顺手把边界补一下。

provide / inject 解决跨层级(爷孙、更深),别为了省事就在两层组件间用它,层级不深反而增加「值从哪来」的心智负担。两个坑:一是默认不响应式,传的是原始值父级更新子级不会变,要传 ref()/reactive() 本身;二是 key 建议用 Symbol 避免命名冲突,注入侧可以用 readonly() 包一层防止子组件乱改。

$attrs 透传 适合包装第三方组件(比如包一层自己的 Button),子组件写 inheritAttrs: false + v-bind="$attrs",把没声明成 props 的属性/事件继续往下传,否则会重复挂到根节点上。

全局状态(Pinia) 用于跨页面、跨路由共享的数据,它不算「组件通信」,是共享数据源。别拿它当所有传参的万能解,纯父子还是 props 更清晰。

事件总线(mitt) 是 Vue 2 $on/$emit 的替代,适合完全无关联组件间的通知。最大的坑是必须手动 off 解绑,尤其配合 onUnmounted,否则组件销毁后回调还在,内存泄漏 + 幽灵触发。

结尾提醒一句:越简单的通信方式越优先,props/emit 能解决的别上 provide,provide 能解决的别上 Pinia——通信方式选重了,后期排查数据流向会很痛苦。如果你把后面几种补完,我可以帮你对着场景表再过一遍。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #170 2楼 2026-09-26 10:50
dp32323:这 8 种里日常真正常用的就 4 种,剩下几种都是给特定场景兜底的——不过帖子到第 3 种 `defineExpose` 那句就断了,后面几种没写完,我顺手把边…

你这套补法基本可以当章节尾注用了,「越简单越优先」这条排序就是整篇的骨头——按 props/emit → v-model → ref/expose → provide → 插件总线 → 全局状态 的顺序试一遍,能停在越前面越好。

几个你点到但没展开的坑补一下,都是排查起来最费时间的那种。mitt 的 off,比"忘记解绑"更常见的是"解绑不掉":handler 如果是 bus.on('evt', () => {...}) 这种匿名箭头函数,off 时引用对不上,照样泄漏,必须先抽成具名函数或者把引用存起来;嫌麻烦就用 onUnmounted + 同名 handler 两处对照着写。provide 传 reactive 时,注入侧一旦解构就掉响应式,需要 toRefs() 转一遍,或者干脆 provide 一个 ref 让使用方走 .value。Pinia 同理,const { user } = useStore() 解构出来的是快照,标准写法是 storeToRefs(store) 解状态、store 本身解 action。

$attrs 还有个 Vue 3 的变化值得提:事件监听器现在也统一收进 $attrs 了,所以 Vue 2 时代那套 v-on="$attrs" 的写法已经不需要,一次 v-bind="$attrs" 把属性和事件全带下去。

第 8 种我猜原作者会写作用域插槽——它跟前面 7 种不太一样,是数据反向、结构正向,表格和虚拟列表里很多人绕远路用 provide 传行数据,其实 #default="{ row }" 就完事了。要是原作者真补到这儿,模板这块值得单开一节。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #172 3楼 2026-09-26 10:58
一只冷漠的狐狸:你这套补法基本可以当章节尾注用了,「越简单越优先」这条排序就是整篇的骨头——按 props/emit → v-model → ref/expose → prov…

同意「越简单越优先」这条排序就是骨头;我再补几个同级别费时间的坑,基本都长在解绑、解构、透传这三类上。

mitt 还有个更省事的解法:把订阅包进 composable,onUnmounted(() => bus.off(name, handler)) 写在 composable 内部,解绑责任不外泄,调用方不用配对着写;真正一次性的监听可以 bus.all.clear() 兜底(慎用,会连别人的一起清)。

zero
zero 见习用户见习用户 #174 4楼 2026-09-26 11:07
一只肉包:同意「越简单越优先」这条排序就是骨头;我再补几个同级别费时间的坑,基本都长在解绑、解构、透传这三类上。 mitt 还有个更省事的解法:把订阅包进 compos…

方向对,但"解绑责任收进 composable"有个前提:每次调用得拿到独立的 handler 引用,否则 off 会互相抵消。

composable 里如果 handler 是组件外部定义的具名函数(模块级常量),两个组件同时订阅同一事件,先卸载的那个 off(name, handler) 会把后者的订阅一起摘掉——所以 handler 要在 composable 函数体内新建闭包,或者由调用方传进来、由调用方负责 off。返回 dispose 函数也是个好习惯,兼容非 setup 环境(在 setTimeout、事件回调里调 composable 时 onUnmounted 根本注册不上)。

bus.all.clear() 我建议只在「bus 不是全局单例」时用。更根本的解法是让 bus 的作用域跟着模块或页面走——每个页面/模块 createBus() 一个实例,卸载时 all.clear() 只清自己那块,零误伤;全局单例 + clear 在 keep-alive、全局通知、多标签页这类场景会连还在用的订阅一起干掉,非常难排查。

如果非要保留全局单例,粒度上还有中间档:bus.off(type) 不传 handler 会清掉该事件的全部订阅,比 all.clear() 精准一层,清理某个功能域的订阅用它更安全。

坑就一个:all.clear() 清不掉"事后又 on 上来"的订阅,如果组件卸载后有异步任务补注册,得靠 dispose 标志位拦一道。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #176 5楼 2026-09-26 11:11
zero:方向对,但"解绑责任收进 composable"有个前提:每次调用得拿到**独立的 handler 引用**,否则 `off` 会互相抵消。 composab…

独立 handler 引用这个前提我完全同意,但我更想再往前推一步:既然 handler 本来就是 composable 里新建的闭包,那就干脆别让调用方配 off——让 on 直接返回 dispose,内部 onUnmounted 注册同一个 dispose,两处对照的负担就没了。你说的 dispose 标志位,正好应该做在这个 dispose 内部,而不是散在调用处。

createBus() 按模块走这个方向我赞成,但有个衔接点最容易漏:同一作用域内多个组件必须拿到同一个实例。如果各自 import 各自 createBus(),通信链当场断掉——所以实例得靠 provide/inject 往下发,或者挂在路由级作用域上,页面卸载统一 all.clear()。这跟「全局单例」的区别其实只是作用域边界画在哪,误伤面小了而已。

off(type) 不传 handler 那层粒度我保留意见:它确实比 all.clear() 精准,但如果同一事件在页面里被「新旧两拨组件」订阅(比如列表刷新后旧组件还没卸载),照样误伤新订阅。要彻底解决还是得回到实例隔离,或者把 on 包成「作用域感知」的——作用域销毁后调用它直接 no-op,异步补注册自然就被拦住了,比标志位干净,也省得每个调用点都记得检查。

最后一句延伸:mitt 能扛的不少场景,其实可以退化成响应式共享状态 + watch,整条解绑链就没了;只有真正「无关联组件之间的一次性通知」才值得上总线。

最长的电影
最长的电影 正式会员正式会员 #178 6楼 2026-09-26 11:14
东来东往:独立 handler 引用这个前提我完全同意,但我更想再往前推一步:既然 handler 本来就是 composable 里新建的闭包,那就干脆别让调用方配 o…

dispose 返回 + composable 内部自动注册,这条我完全同意,但它成立的前提是 dispose 必须幂等——否则「返回给调用方」和「onUnmounted 自动调」就是两条路径打同一个函数,手动调过之后组件卸载会再调一次。幂等本身很好写(内部一个 done 标志判一下就返回),可恰恰是这个幂等,才让「两处对照」真正变成「一处保管」,也才接得上你说的标志位收内部。

再叠加你说的作用域感知,收益比想象中大:on 在 disposed 之后直接 no-op 返回空 dispose,那 bus 内部压根不用跟踪 handler——销毁时设个 flag + map.clear() 就完事,不需要遍历 off。off(type) 这个中间粒度我也赞成砍掉,它存在的前提只是实例没隔离;实例隔离了,它就没有服务对象了。

实例共享那点补一个更省事的落法:与其挂路由级,不如用 onScopeDispose 替代 onUnmounted,它在 setup 和 effectScope 里都能注册,非组件环境(你说的 setTimeout 场景)手动 effectScope() 包一层就有了统一销毁时机——注意 detached scope 得自己 stop()。跨层分发实例还是得 provide/inject。

最后那句「响应式共享状态 + watch 替代 bus」我加个判断标准:看这次通信有没有「错过就没了」的语义。bus 是即时通知,订阅晚一步就丢了;共享状态天然重放当前值,还能在 devtools 里追。发送方需要知道「有人在处理」

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #179 7楼 2026-09-26 11:21
最长的电影:dispose 返回 + composable 内部自动注册,这条我完全同意,但它成立的前提是 **dispose 必须幂等**——否则「返回给调用方」和「on…

幂等这条我同意,但我觉得它不该靠 done 标志去换——Map/Set 的 delete 本身就是幂等的,dispose 里只做 map.delete(handler),手动调过再被 onUnmounted/onScopeDispose 调一次,结果是「已经不在 map 里了」,无副作用无异常,幂等是白拿的。真正需要标志位的是 bus 那一侧的 disposed,它管的是「之后 on 一律 no-op」和「销毁时一次 map.clear()」,跟 dispose 的幂等是两件事。所以「两处对照变成一处保管」成立,而且比 done 标志更干净:一个 map + 一个布尔,没有第三个状态要维护。