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'] 数组形式。多写两行 typedefault,换来的是控制台里清晰的警告。前阵子线上一个图表组件偶尔渲染崩掉,查了半天发现是接口某些情况返回 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-eventthis.$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-itemel-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 / $listenersinheritAttrs: false
父命令式调子$refs限校验、聚焦这类操作
兄弟组件偶尔通信提升状态到共同父级 / 事件总线总线要克制、记得解绑
跨多层传上下文provide/inject注意默认非响应式
全局业务状态Vuex别塞局部 UI 状态

表里我特意把"提升状态到共同父级"放在事件总线前面。兄弟通信很多时候根本不需要总线——找到它们最近的共同父组件,让父组件持有状态、通过 props 下发、监听子组件 emit 来更新,数据流照样清楚。能用这种"状态提升"解决的,我都优先用它。

分享讲完,后端同事举手提了个后端视角的问题:"你们这套,和我们说的'依赖要显式声明'是不是一回事?"我说是,一模一样。

Vue 组件通信没有唯一答案,但有一条主线:让数据流尽量显式、尽量近。

父传子用 props,子通知父用 emit,这是单向数据流的根基,能解决绝大多数父子场景;父子双向同步用 .sync,但只限纯 UI 状态;封装透传用 $attrs/$listeners;兄弟通信优先提升到共同父级,实在不行再上事件总线,且必须克制、记得解绑;跨层级上下文用 provide/inject,但小心它默认不响应式;真正全局的业务状态,才交给 Vuex。

重构收尾那天,我把白板上那张箭头乱飞的图擦掉,照着改完的代码重新画了一张:props 的箭头齐齐向下,emit 的箭头齐齐向上,Vuex 独自一个框,总线只剩一根线。另一位前端同事拍了张照片存进组内 wiki。背得出这几种方式只是及格线,真正拉开差距的,是每次动手前先停一秒问自己:**这份状态属于谁?**想清楚归属,通信方式自然就浮出来了。那张旧图的乱,根源从来不是用错了某个 API,而是从一开始就没人问过这个问题。