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

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客
发布于 2026-09-26 10:38 ·3 浏览 ·7 回复
本文转载自 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 + 一个布尔,没有第三个状态要维护。