Vue 的组件通信方式
开篇先俗套一下,在 Vue 中,组件间的通信有以下几种方式:
prop
作用: 父组件向子组件传值,单向数据流$emit $on
作用: 子组件发布事件,父组件订阅事件vuex
作用: 集中数据管理,数据共享Event Bus
作用: 作为全局事件池,发布订阅事件ref
作用: 通过ref获取子组件的引用(实例),不是响应式的$attrs $listeners
作用: 获取父作用域中除了 props 中声明的属性( class 和 style 也不包含在内) 和 事件( .native 除外)$parent $children
作用: 获取当前组件的父实例和所有子实例(非响应式)$root
作用: 获取当前组件数的根实例,实际上和 vuex 有相似之处provide inject
作用: 依赖注入,将当前组件的方法和数据注入的子组件中,可以跨层级,非响应式
封装组件的方式与目的
进入正题,通常情况下在设计一个组件的时候,我们会习惯性的将组件中的部分状态设计成 props 。以一个简单的 button 组件为例:
const ButtonBase = { |
常用的事件则会通过 $emit
派发事件,例如 $emit('click', $event)
。如果还有更多不重要的属性和事件时,或者所写的组件只是对另一个更底层的组件进行包装(高阶组件),就可以直接使用 v-bind="$attrs"
和 v-on="$listeners"
来进行事件和数据的绑定。
以上的这种思路,可以 cover 大部分的场景,但当涉及到双向数据流或非父子组件传值的时候,大家的方式就开始奔放起来了,常见的方式大概是以下几种:
- ref
- veux
- Event Bus
借助这些方式几乎所有的场景都可以解决了,那我们再进入具体的场景分析一下。
首先想一下为什么需要封装组件?
为了复用
我猜你已经抢答了,但事实上除了组件库和项目中的基础组件,我们所写的业务组件几乎都不会有复用的场景,那我们在写业务代码的时候,为什么还要封装呢?
为了代码的可维护性和可读性
机智的你又回答对了,确实如此,作为一个平淡无奇的打工人,在封装代码的时候,一开始都是从可复用的角度出发,最后发现根本没有太多可复用的场景,甚至只用到一次,那我们封装的作用剩下了一个: 可维护性。
业务组件的拆分
站在 可维护性 这个角度再去思考组件该如何封装,以下面的原型图和需求为例,你会如何拆分组件?
- 上半部分为表单,用于写处方
- 下半部分为表格,用于展示历史处方
- 写处方和获取历史处方都需要一个 patientId ,从路由中获取
- 处方提交完成之后,下方历史处方需要同步更新
- 可以将下方表格中的一条处方复制到上方表单中,方便快速开方
先简单分析一下,上半部分的表单和下半部分的表格功能虽然有耦合,但还是相对独立的,可以拆分为三个组件
- Prescription.vue 根组件
- PrescriptionAdd.vue 写处方
- PrescriptionHistory.vue 历史处方
根组件
用于接收路由参数,因为需求中提到写处方和历史处方有一些联动,所以它可能还会承担两个兄弟组件间的通信作用。
写处方组件
有一部分独立的功能,那就是提交处方表单。需要联动的功能则是提交完成后通知历史处方组件
更新数据。
历史处方组件
的独立功能是拉取展示历史数据,需要联动的功能是将一行数据复制发送给写处方组件
。
每个组件的独立功能都比较简单,具体代码不予赘述。我们直接探讨需要联动的部分如何去实现。
第一个要探讨的点是路由中的参数 patientId 如何传递。最简单暴力的方式就是随用随取了: this.$route.params.patientId
。但是很明显,作为一个稍微有一点点追求的人,都不会用这种方式,因为耦合度太高了,所以我们选择通过路由组件传参的方式将 patientId 以 prop 的方式传递给根组件
。然后再继续通过 prop 的方式将其传递给写处方
和历史处方
组件。
我们再考虑的长远一些,随着业务的不断增长需求也不断变化,有可能写处方
和历史处方
两个组件也会变成类似根组件
一样的容器组件,这样可能又会抽一些组件出去,又要继续将 patientId 向更深层的子组件传递。再极端一点,可能有时候我们为了传递一个 prop 中间隔了很多层组件,而这个 prop 在这些组件却不会被使用到。
那有没有其他方法呢?这里就要引出本文所介绍的 provide
/inject
。两者配合可以跨层级传递数据和方法,ElementUI 的表单组件就大量运用了这个功能,但它有一个明显的缺陷, provide
和 inject
绑定不是响应式的,这是 Vue 故意这样设计的,当然如果传入的是一个可监听对象,那对象的属性还是响应式的。
所以,我们可以用 provide
/inject
代替 prop 传递数据。这时候你可能会问,通过 provide
/inject
传递数据,那组件的复用性不就大大降低了吗,因为在使用的时候,必须保证被注入数据的子组件在有同样 provide
的父组件中使用。确实如此,但其实这种担心全是多余的,因为这一原则只适用于业务组件,业务组件的封装主要目的是提升代码的可维护性,也很少有复用的场景,即使有也是一个完整的业务流程,既 provide
和 inject
一定会同时存在。 如果出现了特殊的场景,可能要考虑组件的粒度是否需要更细致一些。当然这种方式千万不要用在基础组件中,比如上文所提到 Button 。
这样一来,我们的三个组件代码类似下面这样:
export default { |
export default { |
第二个要解决的问题是,如何解决两个兄弟组件间的数据通信。既在写处方完成之后如何通知历史处方组件
更新数据,和如何将历史处方的数据发送到写处方组件中
。
有一些经验的开发者可能早就想到用 Event Bus 了。在完成对应的操作之后,将相关的数据通过 Event Bus emit
一个事件发送出去,然后在需要订阅的组件内订阅相关事件,简略的代码大概如下:
export default { |
export default { |
这种方式实现起来很简单,甚至不需要父组件的参与。但是这就造成了事件满天飞的后果,一堆的 emit(‘xxx’) 这样的魔法自字符串不是很好维护,如果将这些事件名单独维护在一个文件中,又会像 redux 那样罗里吧嗦的。本人是很讨厌这样的模式,甚至因为魔法字符串的问题,我已经在新的项目中放弃了 Vuex 。
如果不用 Event Bus 的方式,还可使用 ref + $emit 的形式,主要思路就是由他们相同的父组件去订阅各自派发出的事件:
<PrescriptionAdd ref="PrescriptionAdd" |
export default { |
这样可以从一定程度上减少事件满天飞情况,但是已经让父组件参与进来了。而且不论是哪一种,两个完整的功能都被分散到三个(或以上的)文件当中。那从站在可维护的角度来说,我们肯定希望相关功能的代码不要被拆的七零八散,写的到出都是。如果能写在一起,不仅对后续的维护者是一种便利,就连 code review 也变得轻松很多。
provide & inject
如何将这个功能写在一起呢?这时候就可以用到 provide
/inject
了。在 Vue 中用 js 写依赖注入有连个很明明显的缺陷:
- 非响应式
- 没有一点点的类型提示
这时候我们可以借助 vue-property-decorator 提供的一些装饰器,通过 ts 的方式来写 Vue ,社区中已经有很多很多关于 vue-property-decorator 的介绍了,这里不再便赘述了(安利一下自己写的ppt)。
这里主要使用 ProvideReactive
和 InjectReactive
这两个装饰器。 ProvideReactive
/InjectReactive
是 provide
/inject
的反射版本,直白的讲就是让注入的值由非响应变为响应式。
额外补充:
ProvideReactive 是将所有被注入数据包装成名称为__reactiveInject__
的对象传入子孙组件,子孙组件中 的 InjectReactive 再通过属性计算的方式将__reactiveInject__
映射到当前组件中,从而实现数据的同步更新,原理还是利用 当然如果传入的是一个可监听对象,那对象的属性还是响应式的 。
接下来,我们忘记前面所有的,甚至忘记组件封装,原型图长什么样。要做的就是将开处方、查看历史处方、复制处方这些一连串的功能在一个类中实现,业务场景中也可以拆分成多个类,再通过 Mixins 的方式聚合到一起,这样一来我们大概会得到这样一份代码:
import { Component, Prop, Vue } from 'vue-property-decorator'; |
接下来我们再按照最初的思路去拆分并编写组件:
({ |
({ |
写到这里就会发现,父组件和子孙组件有很多相同的数据和方法,那就在父组件(根组件)中,为需要注入到子孙组件中的属性加上 @ProvideReactive
装饰器,在子孙组件中相对应的数据上加上 @InjectReactive
装饰器,同时删除细节只保留声明,需要注意的是,虽然通过 Reactive 注入的数据虽然是响应式的,但依旧是单向数据流,所以对子孙组件而言仍旧是只读的,所以需要像 prop 一样加上 readonly
修饰符;需要注入到子孙组件中的方法则加上 @Provide
装饰器,这也可以节省更多的内存开销,修改之后代码如下:
({ |
({ |
({ |
这样我们就把主要的(连续的)逻辑全部塞进了 Prescription
组件当中。这样做的优势不言而喻, code review 简直太爽了,同样代码的可维护性也有一定的提升。
但缺点也很明显:
- 有太多冗余的声明
- 部分子孙组件完全弱化成了一个壳子,主要作用也就是模板了
- 在某些场景下,注入的数据可能会被子孙组件修改,虽然我们可以在父组件中为子孙组件
Provide
修改数据的方法,但还是会显得极为繁琐,比如上文例子中的prescriptionFormList
,我们需要修改给药途径
,单次剂量
,数量
,备注
。虽然可以直接在子组件内修改(因为是引用类型,是可以直接修改的),但是这毕竟违背了单向数据流的原则。而且对于浅层次的数据,直接在子孙组件中修改,也会在控制台中抛出错误。
Vue3 中的依赖注入
虽有 Vue 中的依赖注入有这些缺点,官方文档中也不推荐使用,但不妨碍它成为一个组织业务代码的大杀器。而且随着 Vue3 的到来,上面的这些缺点也都统统解决了。
Vue3 的组合式 API 为我们提供了 provide
和 inject
这两个方法,它们的作用和 Vue2 的依赖注入相同。但是它们可以将响应式数据 ref
和 reactive
注入到子孙组件中,这意味着我们不需要再用 hack 的方式让注入的值变为响应式,也不用再考虑因单向数据流而带来的复杂更新操作了,再配合上自定义 hook ,也能省去很多繁琐的声明,如果非要确保通过 provide
传递的数据不会被 inject
的组件更改,则可以使用 readonly
方法。
同样是开处方的需求为例,我们用 Composition Api 的方式来实现一下:
- 首先写一个比较长的 hook ,将所需的业务代码代码组织起来
interface PrescriptionContext { |
- 剩下的工作就是愉快的复制粘贴模板了
defineComponent({ |
defineComponent({ |
defineComponent({ |
这样一番改造之后,有没有发现单文件组件完全沦为了 模板
和 setup
的容器,我们的将所有业务逻辑全都迁移到了自定义 hook 当中,是不是更加清爽了许多,而且还可以利用所学的设计模式,继续魔改我们的 usePrescriptionProvider
。什么?你觉得烦?但这不就是架构师该做的事情么(让别人参照自己的规范编写代码,同时提供周边的工具链)。 webpack 配置工程师是不会的。