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 = {
name: 'ButtonBase',
props: {
type: {
type: String,
defualt: 'default',
},
size: {
type: String,
defualt: 'small',
},
disabled: {
type: Boolean,
defualt: false,
},
},
template: '...'
};
常用的事件则会通过
$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 {
name: 'Prescription', // 强烈建议书写Name,方便在devtool调试
props: {
// 通过路由组件传参,将路由参数以 prop 方式传递给路由组件
patientId: {
type: [String, Number],
required: true,
},
},
provide() {
return {
// 通过 provide 将 patientId 注入到子孙后代组件中
patientId: this.patientId,
}
},
}
export default {
name: 'PrescriptionHistory',
// 通过 inject 从父组件获取注入的 patientId
inject: ['patientId'],
}
第二个要解决的问题是,如何解决两个兄弟组件间的数据通信。既在写处方完成之后如何通知
历史处方组件
更新数据,和如何将历史处方的数据发送到
写处方组件中
。
有一些经验的开发者可能早就想到用 Event Bus 了。在完成对应的操作之后,将相关的数据通过 Event Bus
emit
一个事件发送出去,然后在需要订阅的组件内订阅相关事件,简略的代码大概如下:
export default {
name: 'PrescriptionAdd',
methods: {
onSubmit() {
/** some code */
// 提交表单完成后,发布事件
bus.$emit('prescription-add-success', this.prescriptionFormList);
},
/** 将历史数据转换为表单数据 */
history2From(data) { },
},
mounted() {
/** 在生命周期中订阅和取消订阅事件 */
bus.$on('copy-prescription', this.history2From);
this.$once('hook:beforeDestroy', () => {
bus.$off('copy-prescription', this.history2From);
});
},
}
export default {
name: 'PrescriptionHistory',
methods: {
onCopy(row) {
// 将选中的数据发送出去
bus.$emit('copy-prescription', row);
},
/**
* 获取历史处方数据
* 不要怕名字长,一定要做到表意清晰
*/
getPrescriptionHistoryList() {},
},
mounted() {
/** 在生命周期中订阅和取消订阅事件 */
bus.$on('prescription-add-success', this.getPrescriptionHistoryList);
this.$once('hook:beforeDestroy', () => {
bus.$off('prescription-add-success', this.getPrescriptionHistoryList);
});
},
}
这种方式实现起来很简单,甚至不需要父组件的参与。但是这就造成了事件满天飞的后果,一堆的 emit(‘xxx’) 这样的魔法自字符串不是很好维护,如果将这些事件名单独维护在一个文件中,又会像 redux 那样罗里吧嗦的。本人是很讨厌这样的模式,甚至因为魔法字符串的问题,我已经在新的项目中放弃了 Vuex 。
如果不用 Event Bus 的方式,还可使用 ref + $emit 的形式,主要思路就是由他们相同的父组件去订阅各自派发出的事件:
<PrescriptionAdd ref="PrescriptionAdd"
@prescription-add-success="onPrescriptionAddSuccess"/>
<PrescriptionHistory ref="PrescriptionHistory"
@copy-prescription="onCopyPrescription"/>
export default {
name: 'Prescription',
computed: {
PrescriptionAdd: {
cache: false,
get() {
return this.$refs.PrescriptionAdd;
},
},
PrescriptionHistory: {
cache: false,
get() {
return this.$refs.PrescriptionHistory;
},
}
},
methods: {
onPrescriptionAddSuccess() {
this.PrescriptionHistory
.getPrescriptionHistoryList();
},
onCopyPrescription(row) {
this.PrescriptionHistory
.history2From(row);
}
}
}
这样可以从一定程度上减少事件满天飞情况,但是已经让父组件参与进来了。而且不论是哪一种,两个完整的功能都被分散到三个(或以上的)文件当中。那从站在可维护的角度来说,我们肯定希望相关功能的代码不要被拆的七零八散,写的到出都是。如果能写在一起,不仅对后续的维护者是一种便利,就连 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';
@Component({
name: 'Prescription',
})
export default class Prescription extends Vue {
@ProvideReactive()
@Prop({ type: Number, required: true })
readonly patientId!: number;
prescriptionFormList = [];
onDelFormItem(index) { }
onSubmit() {
fetch('').then(() => this.getPrescriptionHistoryList());
}
prescriptionHistoryList = [];
getPrescriptionHistoryList() { }
onCopyPrescription(row: any) {
this.history2From(row);
}
history2From(data: any) {
const item = { /** */ };
this.prescriptionFormList.push(item);
}
}
接下来我们再按照最初的思路去拆分并编写组件:
@Component({
name: 'PrescriptionAdd',
})
export default class Prescription extends Vue {
@InjectReactive()
readonly patientId!: number;
prescriptionFormList = [];
onDelFormItem(index) { }
onSubmit() { }
}
@Component({
name: 'PrescriptionHistory',
})
export default class Prescription extends Vue {
@InjectReactive()
readonly patientId!: number;
prescriptionHistoryList = [];
getPrescriptionHistoryList() { }
onCopyPrescription(row: any) { }
}
写到这里就会发现,父组件和子孙组件有很多相同的数据和方法,那就在父组件(根组件)中,为需要注入到子孙组件中的属性加上
@ProvideReactive
装饰器,在子孙组件中相对应的数据上加上
@InjectReactive
装饰器,同时删除细节只保留声明,需要注意的是,虽然通过 Reactive 注入的数据虽然是响应式的,但依旧是单向数据流,所以对子孙组件而言仍旧是只读的,所以需要像 prop 一样加上
readonly
修饰符;需要注入到子孙组件中的方法则加上
@Provide
装饰器,这也可以节省更多的内存开销,修改之后代码如下:
@Component({
name: 'Prescription',
})
export default class Prescription extends Vue {
@ProvideReactive()
@Prop({ type: Number, required: true })
readonly patientId!: number;
@ProvideReactive()
prescriptionFormList = [];
onDelFormItem(index) { }
onSubmit() { }
@ProvideReactive()
prescriptionHistoryList = [];
getPrescriptionHistoryList() { }
onCopyPrescription(row: any) { }
history2From(data) { }
}
@Component({
name: 'PrescriptionAdd',
})
export default class PrescriptionAdd extends Vue {
@InjectReactive()
readonly patientId!: number;
@InjectReactive()
readonly prescriptionFormList!: any[];
@Inject()
readonly onDelFormItem!: (index) => void;
@Inject()
readonly onSubmit!: () => void;
}
@Component({
name: 'PrescriptionHistory',
})
export default class PrescriptionHistory extends Vue {
@InjectReactive()
readonly patientId!: number;
@InjectReactive()
readonly prescriptionHistoryList!: any[];
@Inject()
readonly getPrescriptionHistoryList!: () => void;
@Inject()
readonly onCopyPrescription!: (row: any) => void;
mounted() {
this.getPrescriptionHistoryList();
}
}
这样我们就把主要的(连续的)逻辑全部塞进了
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 {
prescriptionFormList: Ref<any[]>;
onDelFormItem(index: number): void;
onSubmit(): void;
prescriptionHistoryList: Ref<any[]>;
getPrescriptionHistoryList(): void;
onCopyPrescription(row: any): void;
}
// 通过 InjectionKey 接口提供类型支持
const PrescriptionToken: InjectionKey<PrescriptionContext> = Symbol('PrescriptionToken');
export function usePrescriptionProvider(patientId) {
const prescriptionFormList = ref([]);
function onDelFormItem(index: number) { }
function onSubmit() { }
const prescriptionHistoryList = ref([]);
function getPrescriptionHistoryList() { }
function onCopyPrescription(row: any) { }
provide(PrescriptionToken, {
prescriptionFormList,
onDelFormItem,
onSubmit,
prescriptionHistoryList,
getPrescriptionHistoryList,
onCopyPrescription,
});
onMounted(() => {
getPrescriptionHistoryList();
});
}
export function usePrescriptionInject() {
const prescriptionContext = inject<PrescriptionContext>(PrescriptionToken);
if (!prescriptionContext) {
throw new Error('usePrescriptionInject must be used after usePrescriptionProvider');
}
return prescriptionContext;
}
-
剩下的工作就是愉快的复制粘贴模板了
defineComponent({
name: 'Prescription',
props: {
patientId: {
type: Number,
required: true,
},
},
setup(props) {
usePrescriptionProvider(props.patientId);
},
});
defineComponent({
name: 'PrescriptionAdd',
setup() {
const {
prescriptionFormList,
onDelFormItem,
onSubmit,
} = usePrescriptionInject();
return {
prescriptionFormList,
onDelFormItem,
onSubmit,
};
},
});
defineComponent({
name: 'PrescriptionHistory',
setup() {
const {
prescriptionHistoryList,
getPrescriptionHistoryList,
onCopyPrescription,
} = usePrescriptionInject();
return {
prescriptionHistoryList,
getPrescriptionHistoryList,
onCopyPrescription,
};
},
});
这样一番改造之后,有没有发现单文件组件完全沦为了
模板
和
setup
的容器,我们的将所有业务逻辑全都迁移到了自定义 hook 当中,是不是更加清爽了许多,而且还可以利用所学的设计模式,继续魔改我们的
usePrescriptionProvider
。什么?你觉得烦?但这不就是架构师该做的事情么(让别人参照自己的规范编写代码,同时提供周边的工具链)。webpack 配置工程师是不会的。
参考资料
一、https://github.com/kaorun343/vue-property-decorator
二、https://onlymisaky.gitee.io/2020/08/17/
全文完
以下文章您可能也会感兴趣:
我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。