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

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客
发布于 2026-09-26 10:38 ·8 浏览 ·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轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-599.html
转载请注明出处,版权归原作者所有。

全部回复 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 + 一个布尔,没有第三个状态要维护。