写业务组件的时候,最常卡住的一步不是把界面画出来,而是「这两个组件之间的数据怎么传」。父传子好办,子传父也好办,难的是隔了四层的祖孙、是两个八竿子打不着的兄弟、是那个已经挂在 body 上、根本不在组件树里的弹窗。Vue 给的通道其实不少,光常用的就有九种,但每种都有自己的适用范围,用错了要么写得别扭,要么埋下维护雷。这篇把这九种方式逐个过一遍,讲清各自的代价,最后用一个表单组件和一个弹窗组件把它们串起来,看看真实场景里是怎么组合使用的。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 组件化到底在解决什么问题
- 九种组件通信方式的写法、适用场景和代价,附一张选型对照表
- 一个把 props、自定义事件、事件总线、
$attrs、inject 全用上的完整示例 - 插槽的三种形态,匿名插槽、具名插槽、作用域插槽
- 实战一,用 provide/inject 加事件通知实现一套通用表单组件
- 实战二,用 JS 动态创建组件实例实现弹窗,以及 Vue 3 里对应的写法
# 一、组件化在解决什么问题
Vue 组件系统提供了一种抽象,让我们可以用独立可复用的组件来构建大型应用,任意类型的应用界面都可以抽象为一棵组件树。组件化能提高开发效率,方便重复使用,简化调试步骤,提升项目可维护性,也便于多人协同开发。

一旦拆成树,通信就成了必答题。树里的两个节点想说话,无非三种关系:直接相邻的父子、有共同祖先的跨层级、彻底不相关的任意两个。下面九种方式,说到底就是在这三种关系上做文章。
# 二、组件通信常用方式
# 1. props
父给子传值,最基础也最该优先用的一种。
// child
props: { msg: String }
// parent
<HelloWorld msg="Welcome to Your Vue.js App"/>
props 的好处是数据流向一眼可见,配上类型校验还能当接口文档用。它的约束是单向下行,子组件不能改,改了会被警告并且在父组件下次更新时被覆盖。这块的完整写法和单向数据流的原因我在 Vue3基础小结 里展开过。
# 2. 自定义事件
子给父传值,和 props 组成一对完整的父子通道。
// child this.$emit('add', good)
// parent
<Cart @add="cartAdd($event)"></Cart>
Vue 3 里建议用 emits 选项(或者 defineEmits)把组件会抛出的事件声明出来,这样组件的对外契约就完整了:往下是 props,往上是 emits。
# 3. 事件总线
任意两个组件之间传值常用事件总线,或者 vuex 的方式。
// Bus:事件派发、监听和回调管理
class Bus {
constructor() {
this.callbacks = {}
}
$on(name, fn) {
this.callbacks[name] = this.callbacks[name] || []
this.callbacks[name].push(fn)
}
$emit(name, args) {
if (this.callbacks[name]) {
this.callbacks[name].forEach(cb => cb(args))
}
}
}
// main.js
Vue.prototype.$bus = new Bus()
// child1
this.$bus.$on('foo', handle)
// child2
this.$bus.$emit('foo')
实践中通常用 Vue 实例代替这个 Bus 类,因为 Vue 已经实现了相应接口,new Vue() 出来的对象自带 $on / $emit。
这里有个必须提的变化:Vue 3 把实例上的 $on、$off、$once 移除了,所以「拿一个空 Vue 实例当事件总线」这个经典写法在 Vue 3 里直接失效。替代方案有两条,一是用 mitt 这类几百字节的第三方发布订阅库,二是就用上面这个手写的 Bus 类,它是纯 JavaScript,跟 Vue 版本没关系。挂载方式也要改,Vue 3 没有 Vue.prototype,全局属性走 app.config.globalProperties。
顺带说一句代价。事件总线是最自由的通信方式,也是最容易失控的。事件名是字符串,没有类型约束,谁发的谁收的全靠搜代码;组件销毁时忘了 $off,监听会一直挂着,下次进页面又加一个,回调越堆越多。这个我排查过一次,一个提交按钮点一下发了七次请求,就是反复进出页面攒下来的。所以真要用,一定在 beforeUnmount 里配对解绑。
# 4. vuex
创建唯一的全局数据管理者 store,通过它管理数据并通知组件状态变更。
跨组件共享的、有生命周期的、多处读写的状态,交给状态管理是最省心的。它比事件总线强在数据有明确归属,改动有迹可循,还有 devtools 可以回放。Vue 3 生态里官方推荐的已经是 Pinia,去掉了 mutation 这一层,对 TypeScript 也友好得多。Vuex 的实现原理和手写版本我写在 Vue Router、Vuex原理实践 里了。
# 5. $parent / $root
兄弟组件之间通信可通过共同祖辈搭桥,用 $parent 或 $root。
// brother1
this.$parent.$on('foo', handle)
// brother2
this.$parent.$emit('foo')
这招的思路挺巧:既然两个兄弟都能摸到同一个父组件,那就把父组件当临时的信号中心。但它同样依赖实例上的 $on / $emit,Vue 3 里用不了了。
更根本的问题是,这种写法让子组件对自己的父组件做了假设。组件一旦被挪到别处复用,this.$parent 指向的就不是原来那个了,代码静默失效。
# 6. $children
父组件可以通过 $children 访问子组件,实现父子通信。
// parent
this.$children[0].xx = 'xxx'
这个 API 有两个问题。第一,官方文档明确说它不保证顺序,你今天写 [0] 拿到的是 A,明天在模板里加个条件渲染,[0] 就成了 B。第二,Vue 3 直接把 $children 移除了,没有替代品,官方的建议是改用模板 ref。
# 7. $attrs / $listeners
$attrs 包含了父作用域中不作为 prop 被识别(且获取)的特性绑定,class 和 style 除外。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定,同样不含 class 和 style。它可以通过 v-bind="$attrs" 传入内部组件,在创建高级别的包装组件时非常有用。
// child:并未在props中声明foo
<p>{{$attrs.foo}}</p>
// parent
<HelloWorld foo="foo"/>
典型用途是写「透传」组件。比如你封装一个 MyInput 包住原生 input,用户传的 placeholder、maxlength、autofocus 你不可能一个个声明成 prop,直接 v-bind="$attrs" 全甩给内部的 input 就完事了。配合 inheritAttrs: false 可以阻止这些属性自动落在根元素上。
Vue 3 在这里做了合并:$listeners 被移除,事件监听器统一并进了 $attrs,class 和 style 现在也包含在 $attrs 里。所以 Vue 2 里那种 v-bind="$attrs" v-on="$listeners" 的双写法,在 Vue 3 里一个 v-bind="$attrs" 就够了。
# 8. refs
获取子节点引用。
// parent
<HelloWorld ref="hw"/>
mounted() {
this.$refs.hw.xx = 'xxx'
}
$refs 是这几种「越级」手段里最可控的一个,因为引用关系写在父组件自己的模板上,明明白白。它适合的是命令式操作,比如让子组件执行校验、让某个输入框获得焦点、调用一个播放器组件的 play()。用它来同步数据就不合适了,那还是 props 的活。
注意 $refs 要在 mounted 之后才有值,在 setup 或者 created 里访问是 undefined。Vue 3 用 script setup 时,子组件得用 defineExpose 主动暴露成员,否则父组件拿到的实例上什么都没有。
# 9. provide / inject
能够实现祖先和后代之间传值。
// ancestor
provide() {
return {foo: 'foo'}
}
// descendant
inject: ['foo']
这是解决「深层嵌套导致 props 层层透传」的正牌方案。祖先提供,任意深度的后代直接注入,中间组件完全不参与。
它的代价是隐式依赖,后代组件光看自己的代码不知道 foo 从哪来,一旦脱离那个祖先就跑不起来。所以它更适合组件库内部的父子协作,比如 Form 和 FormItem、Tabs 和 TabPane 这种成套出现的组件,而不适合当业务数据的通用管道。组合式 API 下的 provide / inject 写法和响应性处理,我在 Vue3之Composition API详解 里单独讲了。
# 怎么选
九种方式听着多,实际决策没那么复杂:
| 关系 | 首选 | 备选 | 不建议 |
|---|---|---|---|
| 父传子 | props | provide(组件库场景) | $children |
| 子传父 | 自定义事件 | provide 下发的方法 | $parent 直接改数据 |
| 父调子的方法 | 模板 ref | - | $children[0] |
| 跨多层祖先到后代 | provide / inject | 状态管理 | 逐层透传 props |
| 兄弟组件 | 状态管理 | 提升到共同父组件 | $parent 当中转 |
| 任意两个组件 | 状态管理 | 事件总线(配好解绑) | 全局变量 |
| 属性透传封装 | $attrs |
- | 手动声明一堆 prop |
一句话原则:能用 props 加事件解决的,别上更重的方案。
# 完整示例
把上面几种方式放进一个可运行的例子里。父组件用 props 给 Child1 传值、监听它抛上来的事件,同时借 $parent 当兄弟之间的信号中心: