Vue 组件通信全整理:props、emit、provide/inject 和事件总线怎么选
组件通信最怕的不是传不到,而是传得太随意。一个状态字段今天从 props 传,明天被事件总线广播,后天又被某个中间组件顺手写进 Vuex,最后谁在拥有这份状态、谁该负责更新它,代码里已经看不出来了。
我真正意识到这件事,是把一个老模块的数据流画上白板之后:列表传给弹窗,弹窗广播出去,别的组件监听后改 Vuex,Vuex 再触发列表刷新,箭头绕成一团。问题不在某一条链路不能跑通,而在系统里已经没人能一眼说清一份状态为什么会这样流动。
后面会围着一个核心问题展开:这份状态到底属于谁。顺着这个问题,再去看 props、emit、provide/inject、事件总线和 Vuex,边界就会清楚很多。
分享的第一页:状态属于谁
在挑通信方式之前,我习惯先问自己一个问题——这份状态的"主人"是谁?
- 只有一个组件用、别人不关心:放在组件自己的
data里就行,根本不用通信。 - 父组件持有、子组件只是展示:props 往下传。
- 子组件里产生、父组件需要知道:emit 往上抛。
- 没有直接父子关系的两个组件要同步:找它们共同的祖先,或者上更重的方案。
- 整个应用很多页面都要读写:才轮到 Vuex。
我见过太多代码一上来就把什么都塞进 Vuex 或者事件总线,结果状态满天飞——那张箭头图就是下场。判断"主人是谁"这一步做对了,后面选哪种方式基本是自然而然的。
props 向下传
父子之间最朴素的方式。父组件把数据当属性传下去:
1<user-card :user="currentUser" :editable="true" />
子组件声明自己接收什么,顺便把类型和约束写清楚:
1export default { 2 props: { 3 user: { 4 type: Object, 5 required: true 6 }, 7 editable: { 8 type: Boolean, 9 default: false 10 } 11 } 12}
我强烈建议 props 永远写成对象形式而不是 props: ['user'] 数组形式。多写两行 type 和 default,换来的是控制台里清晰的警告。前阵子线上一个图表组件偶尔渲染崩掉,查了半天发现是接口某些情况返回 null,而组件默认 data: [] 的假设根本没生效——因为 props 是数组写法,传进来的 null 直接顶替了默认值。改成 default: () => [] 加上 type: Array 之后,问题当场暴露在开发环境。
子组件不要直接改 props。 这不是规矩问题,是 Vue 真的会在你修改对象类型 props 的子属性时不报错地"成功",然后埋下隐患。父组件重新渲染时会用自己的值覆盖你的修改,于是出现"我明明改了,怎么又变回去了"的灵异现象。另一位前端同事重构商品编辑弹窗时就撞上了一次,来问我为什么表单填一半自己回滚了。
如果子组件确实需要一份可编辑的副本,复制出来:
1data() { 2 return { 3 localUser: { ...this.user } 4 } 5}
但浅拷贝只能挡一层。user.address.city 这种嵌套字段,{ ...this.user } 之后改 localUser.address.city 还是会污染父组件的原对象,因为 address 还是同一个引用。我后来养成习惯:要么用 JSON.parse(JSON.stringify(this.user)) 做深拷贝(数据简单时够用),要么干脆引一个 lodash.clonedeep。
还有个细节是 user 变化时本地副本不会自动更新。如果父组件可能换数据(比如切换不同商品),记得加 watch:
1watch: { 2 user: { 3 handler(val) { 4 this.localUser = { ...val } 5 }, 6 immediate: true 7 } 8}
emit 向上通知
子组件不能改 props,那它怎么把"用户点了保存"这件事告诉父组件?答案是抛事件。
子组件:
1methods: { 2 onSubmit() { 3 this.$emit('save', this.form) 4 } 5}
父组件监听:
1<user-form @save="handleSave" />
1methods: { 2 handleSave(form) { 3 // form 是子组件抛上来的数据 4 this.submitToServer(form) 5 } 6}
这就是 Vue 单向数据流的全貌:数据通过 props 往下流,变更意图通过事件往上冒。子组件永远只是"申请"修改,真正动手改数据的是数据的主人。这套约定让你在排查问题时,永远知道某个值是从上面哪个父组件来的。
表单、弹窗、选择器这类组件天然契合这个模式。我写业务组件时有个原则:组件内部只管自己怎么展示和交互,所有"需要外面知道的事"全部用 emit 抛出去,组件自己绝不直接碰外部数据或全局状态。这样组件可以随便搬到别的页面复用,因为它对外界没有任何隐式依赖。这次重构里我把商品编辑弹窗按这个原则改完,它立刻就能被订单模块借去用了。
一个常被忽略的点:事件名建议用短横线 kebab-case。因为 HTML 属性大小写不敏感,@myEvent 在 DOM 模板里会被浏览器转成小写,监听不到。统一写 @my-event 配 this.$emit('my-event') 最稳妥。
.sync:props 和 emit 的语法糖
有种场景很常见:弹窗的显隐状态,父组件想控制(外面点按钮打开),子组件也想控制(弹窗里点关闭)。纯 props 做不到,纯 emit 又要写一堆样板。.sync 就是为这个糖衣而生的。
1<my-dialog :visible.sync="dialogVisible" />
它其实等价于:
1<my-dialog 2 :visible="dialogVisible" 3 @update:visible="val => dialogVisible = val" 4/>
子组件想关闭自己时,按约定抛一个 update:xxx 事件:
1methods: { 2 close() { 3 this.$emit('update:visible', false) 4 } 5}
.sync 写起来确实爽,但我在分享里对它的态度是"能少用就少用"。它的问题在于把数据流藏起来了——你在父组件模板里只看到一个 :visible.sync,看不出哪些操作会改它。当一个组件上挂了三四个 .sync 时,状态在父子之间双向横跳,调试体验直线下降。我的经验法则是:纯粹的 UI 状态(显隐、展开收起)用 .sync 没问题;只要涉及业务数据的回写,老老实实写成 emit,让流向显式可见。
$attrs / $listeners:二次封装组件的透传通道
重构里有个真实需求正好用上它:我们基于 el-input 封装了一个带前置说明的输入组件。如果用 props 一个个转发 el-input 的 placeholder、disabled、maxlength……几十个属性抄一遍,谁写谁疯。Vue 2.4 之后给了 $attrs 和 $listeners:
1<!-- my-input.vue --> 2<template> 3 <div class="my-input"> 4 <span class="label">{{ label }}</span> 5 <el-input v-bind="$attrs" v-on="$listeners" /> 6 </div> 7</template>
1export default { 2 props: ['label'], 3 inheritAttrs: false // 别把没声明的属性再落到根 div 上 4}
$attrs 是"父组件传下来、但我没在 props 里声明"的属性集合,$listeners 是所有监听器的集合,一把 v-bind/v-on 全部透传给内层组件。配合 inheritAttrs: false,外面用 <my-input placeholder="请输入" @blur="check" />,行为和直接用 el-input 一模一样。二次封装组件库组件,$attrs/$listeners 是标准姿势,比 props 逐个转发省一个数量级的代码。
顺带一句它的边界:$attrs 只透传一层没人认领的属性,适合"包一层"的场景;真要跨很多层传数据,别链式透传,往下看 provide/inject。
事件总线:方便,但容易失控
兄弟组件之间没有父子关系,props/emit 直接搭不上线。很多项目(尤其是没上 Vuex 的)会用事件总线来解决:搞一个空的 Vue 实例当中介,谁都能往上发事件,谁都能监听。
1// bus.js 2import Vue from 'vue' 3export default new Vue()
1// 组件 A:操作完了广播一声 2import bus from './bus' 3bus.$emit('refresh-list')
1// 组件 B:列表组件,听到就刷新 2import bus from './bus' 3 4export default { 5 created() { 6 bus.$on('refresh-list', this.fetchList) 7 }, 8 methods: { 9 fetchList() { /* ... */ } 10 } 11}
这里有个一定要记住的坑:解绑。 Vue 组件销毁时,$on 注册的监听不会自动消失,因为它挂在 bus 这个全局实例上,跟组件生命周期无关。如果不解绑,路由切来切去,同一个 fetchList 会被注册好几次。这次重构前我们真出过一次:用户来回切了几次菜单后,一次广播触发了五次接口请求,后端同事盯着监控看了半天,跑过来问我们是不是页面在"自动攻击"他的接口。
解决办法是在 beforeDestroy 里手动移除,而且必须传同一个函数引用,否则移除不掉:
1created() { 2 bus.$on('refresh-list', this.fetchList) 3}, 4beforeDestroy() { 5 bus.$off('refresh-list', this.fetchList) 6}
注意别写成 bus.$on('refresh-list', () => this.fetchList()),匿名箭头函数每次都是新引用,$off 时你拿不到同一个引用就关不掉。
事件总线真正的问题不在解绑,而在它太"自由"了。任何组件都能发、都能收,时间长了你完全不知道一个事件从哪发出、谁在监听、改了什么。它本质上是个全局可变的隐形管道——我那张箭头乱飞的图里,最乱的几根箭头全是它贡献的。我现在的态度是:小项目、临时的一两处跨组件通知,用一下无妨;但只要事件超过五六个,或者团队多人协作,就该考虑换成有明确数据流的方案了。这次重构我把商品模块的七个总线事件砍到了一个。
$refs 和 $parent:应急通道,别当正道
分享现场另一位前端同事问了一句:"那我直接 this.$parent.xxx 拿父组件的数据不行吗?"值得单独回答。
$refs 拿子组件实例、$parent 拿父组件实例,技术上都能"通信",甚至能直接调对方的方法。偶尔的应急场景是合理的,比如父组件命令式地触发子表单校验:
1this.$refs.form.validate(function (valid) { /* ... */ })
Element UI 的表单校验就是这么用的,没问题——因为这是"父指挥子",方向依然清晰。但反过来 $parent、甚至 $parent.$parent 往上摸,就是给组件焊死了一个隐式依赖:这个组件从此只能长在特定的父组件底下,换个地方就炸。$refs 限于父调子的命令式操作,$parent 尽量一次都别写,要拿祖先的东西,用下面的 provide/inject 把依赖显式化。
provide / inject:跨层级传上下文
有时候要把数据从祖先传到很深的后代,中间隔了好几层组件。用 props 一层层往下透传(俗称 props drilling)会让中间那些根本不关心这个数据的组件也被迫声明、转发,非常恶心。provide / inject 就是为这种"隔代传递"准备的。
祖先组件提供:
1export default { 2 provide() { 3 return { 4 theme: this.theme, 5 // 把整个实例 provide 出去,后代能调它的方法 6 formContext: this 7 } 8 } 9}
任意深度的后代注入即可,中间组件完全不用参与:
1export default { 2 inject: ['theme', 'formContext'] 3}
Element UI 这类组件库里大量用了它。比如 el-form 和它内部的 el-form-item、el-input,中间可能隔着好几层布局,但子项能拿到表单的校验规则、labelWidth 这些配置,靠的就是 provide/inject。这是它最经典也最合适的用法:传递相对稳定的上下文——主题、表单实例、全局配置。
但它有个坑必须说清楚:Vue 2 里 provide/inject 默认不是响应式的。 如果你 provide 一个普通值,祖先后来改了它,后代收到的还是初始值,不会更新。我第一次用的时候就被这个坑了——以为主题切换能自动传下去,结果死活不变。规避方式是 provide 一个对象或者实例(传引用),后代访问它的属性时就能拿到最新值:
1provide() { 2 return { 3 // 传引用,后代读 store.theme 永远是最新的 4 store: this.reactiveStore 5 } 6}
正因为这个特性,我的建议是:provide/inject 只用来传"不怎么变、或者变了也由后代主动去读"的东西。频繁变化的业务数据塞进去,调试时你会很想哭——因为它和事件总线一样,数据来源是隐式的,你在后代组件里看到 inject: ['xxx'],根本不知道是哪个祖先给的。
备稿时我顺手翻了翻今年夏天吵翻天的 Composition API RFC——Vue 下一个大版本的提案里,provide/inject 被扶正成了组合逻辑之间共享状态的正经手段之一。社区从六月吵到现在,我属于观望派:提案里的逻辑复用思路确实解我们 mixin 冲突的痛,但正式版没出,一行代码都不敢往项目里想,分享里也只当"隔壁的新闻"提了一页。
Vuex:全局业务状态的归宿
前面几种都是组件之间的"点对点"传递。当一份状态被很多互不相关的页面读写时,再用上面的方式就会变成一张蜘蛛网。这时候才该请出 Vuex——把这类状态集中到一个全局 store 里,谁要用谁去读,谁要改走 mutation/action。
适合放 Vuex 的,是那些"全局性的业务状态":
- 用户信息(登录后到处都要用昵称、头像)
- 权限和角色(控制按钮、菜单显隐)
- 动态菜单
- 购物车
- 全局配置 / 字典数据
一个最小的例子:
1// store/index.js 2import Vue from 'vue' 3import Vuex from 'vuex' 4Vue.use(Vuex) 5 6export default new Vuex.Store({ 7 state: { 8 userInfo: null 9 }, 10 mutations: { 11 SET_USER(state, user) { 12 state.userInfo = user 13 } 14 }, 15 actions: { 16 async login({ commit }, payload) { 17 const user = await api.login(payload) 18 commit('SET_USER', user) 19 } 20 } 21})
组件里读写:
1import { mapState } from 'vuex' 2 3export default { 4 computed: { 5 ...mapState(['userInfo']) 6 }, 7 methods: { 8 doLogin() { 9 this.$store.dispatch('login', this.form) 10 } 11 } 12}
Vuex 的价值是把数据流"收束"了:任何状态变更都得经过 mutation,配合 Vue DevTools 你能看到一条清清楚楚的变更时间线,谁在什么时候改了什么一目了然。这恰恰是事件总线最缺的东西。
但 Vuex 不是越多越好。我们那个商品模块里就有人把弹窗的开关、当前输入框的值、某个 tab 的选中项也丢进了 Vuex,理由是"以后说不定别的地方要用"。结果 store 膨胀成几百行,组件和全局状态死死耦合,连复用都做不到。这类临时的、局部的、只属于一个组件的 UI 状态,留在组件自己的 data 里最简单、最干净。判断标准还是那句话:这份状态的主人是不是真的"全局"。这次重构我从 store 里搬出去十几个字段,模块清爽了一大截。
分享的最后一页:一张选型表
把上面这些理一遍,遇到具体场景可以这样套:
| 场景 | 推荐方式 | 备注 |
|---|---|---|
| 父传子,单向展示 | props | 写对象形式,标好 type/default |
| 子通知父 | $emit | 事件名用 kebab-case |
| 父子双向同步 UI 状态 | .sync | 只用于显隐这类纯 UI 状态 |
| 二次封装组件透传 | $attrs / $listeners | 配 inheritAttrs: false |
| 父命令式调子 | $refs | 限校验、聚焦这类操作 |
| 兄弟组件偶尔通信 | 提升状态到共同父级 / 事件总线 | 总线要克制、记得解绑 |
| 跨多层传上下文 | provide/inject | 注意默认非响应式 |
| 全局业务状态 | Vuex | 别塞局部 UI 状态 |
表里我特意把"提升状态到共同父级"放在事件总线前面。兄弟通信很多时候根本不需要总线——找到它们最近的共同父组件,让父组件持有状态、通过 props 下发、监听子组件 emit 来更新,数据流照样清楚。能用这种"状态提升"解决的,我都优先用它。
分享讲完,后端同事举手提了个后端视角的问题:"你们这套,和我们说的'依赖要显式声明'是不是一回事?"我说是,一模一样。
Vue 组件通信没有唯一答案,但有一条主线:让数据流尽量显式、尽量近。
父传子用 props,子通知父用 emit,这是单向数据流的根基,能解决绝大多数父子场景;父子双向同步用 .sync,但只限纯 UI 状态;封装透传用 $attrs/$listeners;兄弟通信优先提升到共同父级,实在不行再上事件总线,且必须克制、记得解绑;跨层级上下文用 provide/inject,但小心它默认不响应式;真正全局的业务状态,才交给 Vuex。
重构收尾那天,我把白板上那张箭头乱飞的图擦掉,照着改完的代码重新画了一张:props 的箭头齐齐向下,emit 的箭头齐齐向上,Vuex 独自一个框,总线只剩一根线。另一位前端同事拍了张照片存进组内 wiki。背得出这几种方式只是及格线,真正拉开差距的,是每次动手前先停一秒问自己:**这份状态属于谁?**想清楚归属,通信方式自然就浮出来了。那张旧图的乱,根源从来不是用错了某个 API,而是从一开始就没人问过这个问题。