依赖注入与贡献点架构
控制反转 (Inversion of Control, IoC) 是面向对象中的一个设计原则,用来降低代码耦合度。其中最常见的方式是 依賴注入 (Dependency Injection, DI)。
TL;DR
随着前端的不断发展,一个工程的规模、复杂度不断提升,对于工程的可扩展性、可维护性都有了更高要求。如果以常规开发思路考虑工程中各个部分的组合,随着工程的不断演进,复杂度不断提升,带来的主要问题就是可维护性和可扩展性的极大下降。此时可以使用依赖注入与贡献点架构组织前端工程,提升可维护性与可扩展性。
依賴注入
假设不使用依赖注入,通常我们有一个单例 Service 的需求时,可能是这样的:
通过 export
/import
,我们可以确保使用的 Service
始终是同一个实例,目前看似乎没什么问题。
但随着业务发展,我们需要另一个服务,他的部分方法重载了 Service
上的部分方法,仍是一个单例,那么写法可能是这样的:
1class _Service2 extends _Service{
2 foo() {
3 return "bar"
4 }
5}
6
7export const Service2 = new _Service2();
咋看似乎没有问题,但随着项目不断演进,工程代码可能充斥这下面这样的判断:
长久发展下去这样的代码是无法维护的。
出现这种问题的原因是:我们依赖了 实现
,而非 接口
。
如前面代码描述的,我们在业务中使用 new
出来的 Service
实体,这个单例一旦创建,就是不可变实体,我们只能通过不同的逻辑去消费不同的它。
那么如何解决这个问题呢?方法是依赖 接口
。
不同的语言有不同的用于定义 接口
这个抽象概念的方式,例如 TypeScript、Go、Kotlin 中的 Interface,Rust 中的 Trait 等。
对于一个 接口
,我们可以有不同实现,根据不同条件,使用不同的实现,对于上面的例子,我们就可以改成:
1interface IService {}
2
3class ServiceA implements IService {}
4class ServiceB implements IService {}
5
6const SA = new ServiceA();
7const SB = new ServiceB();
8
9export function getServiceInstance():IService {
10 return cond ? SA : SB;
11}
此时我们的代码就变成了依赖 接口
,而非 实现
。
除了上面的例子,另一个典型场景是 依赖
。
例如一个基类,它可能有别的数据以依赖,需要在 new
时传进去:
1class BaseService {
2 constructor(private fooService, private barService) {
3 }
4}
5
6class ServiceA extends BaseService {}
7class ServiceB extends BaseService {}
8
9const SA = new ServiceA(fooService, barService);
10const SB = new ServiceB(fooService, barService);
它的任何子类都需要 new
时手动传进去基类上的数据依赖,随着时间的推移,工程维护成本太高了。
该如何解决上面这类问题?答案是使用 依赖注入
。
通常的依赖注入写法类似下面的代码:
1@injectable()
2class fooService implements IFooService {
3 foo() {}
4}
5
6class Service{
7 @inject()
8 private fooService: IFooService
9}
可以看到,我们需要标记一个 class
是 可注入的
,在需要使用它时,标记某个属性 注入
某个目标。这篇文章不注重 依赖注入
如何实现,这是因为 依赖注入
在各个语言上都有着成熟的实现,例如 Koin、 TypeDI、TSyringe 等,这里就不再赘述了,大部分场景下直接使用开源库即可。
简单来说,通过 依赖注入
,我们可以找到 接口
的一个或多个 实现
,让依赖看上去是自动获得的,而不是手动传递。
贡献点
一个可扩展的软件,必然要在架构层面支持功能的扩展,例如各种 IDE 的 扩展、调色软件的插件等。这里我们不讨论这么庞大的扩展机制如何实现,仅从代码角度思考:如何让一个功能是可扩展的?
假设我们的工程既可以跑在浏览器中,又能跑在 Electron 中。有一个功能在浏览器上和 Electron 采用不同的实现,但他们对外暴露的 API 是一致的,例如下面的例子:
1interface ILoad {
2 load(): Promise<ArrayBuffer>
3}
4
5class BrowserImpl implements ILoad {
6 load(): Promise<ArrayBuffer> {
7 // ...
8 }
9}
10
11class ElectronImpl implements ILoad{
12 load(): Promise<ArrayBuffer> {
13 // ...
14 }
15}
那么如何如何根据不同的环境执行不同的 load
方法?或者更进一步说,如果未来又新增了一个平台,怎么新增一个 load
实现,让其方便的融入现有系统?
理解了依賴注入,你就应该知道你应该知道,我们要做的是通过 接口
找到 实现
。
所以再回看上面的代码,可以理解为:多个 实现
向一个 接口
贡献内容,剩下的就是找到一种方法,能让我们快速找到 接口
对应的所有 实现
,这是我们可以继续利用依赖注入实现这个功能。
前面提到过,主流的依赖注入库都可以实现根据 接口
找到多个其对应的 实现
。在这里我们直接视使用 typedi 这个依赖注入库。
1// 标记一个贡献点定义
2export function contribution() {
3 return function (target: any) {
4 Reflect.defineMetadata(target.name, '', target);
5 };
6}
7
8// 标记一个贡献点实现
9export function contributionImplement() {
10 return function<T> (target: T) {
11 const keys: string[] = Reflect.getMetadataKeys(target) ?? [];
12 keys.forEach((key) => {
13 Container.set({
14 id: key,
15 type: target as unknown as Constructable<T>,
16 factory: undefined,
17 multiple: true,
18 });
19 });
20 };
21}
22
23// 根据类型捞取贡献点
24export function getContributions(obj: any) {
25 return function (target: any, propertyKey: string) {
26 Object.defineProperty(target, propertyKey, {
27 get() {
28 return Container.getMany(obj.name);
29 },
30 });
31 };
32}
通过这个三个装饰器,我们可以实现贡献点机制的三步:
- 定义贡献点
- 实现贡献点
- 根据定义捞取所有实现
以下面的例子为例简单看一下怎么使用:
1import { Component, ComponentClass } from "react";
2
3// 定义一个贡献点,内容是一个 React Component Class
4@contribution()
5export abstract class IPanelContribution {
6 abstract component: ComponentClass;
7}
8
9
10// 实现一个贡献点,向贡献点贡献 panel a
11class PanelItemA extends Component {
12 public render(){
13 return <div>panel a</div>
14 }
15}
16
17@contributionImplement()
18export class PanelItemAImpl extends IPanelContribution {
19 public component = PanelItemA;
20}
21
22// 根据贡献点定义捞取所有实现
23export class Panel extends Component {
24 @getContributions(IPanelContribution)
25 private panels: IPanelContribution[];
26
27 public render() {
28 return (
29 <div>
30 {this.panels.map((C, i) => <C.component key={i} />)}
31 </div>
32 );
33 }
34}
如此,我们实现了 PanelItem
与 Panel
的解耦,Panel
不关心具体内容,有多少 IPanelContribution
实现,就会有多少 PanelItem
。
通过 依赖注入
与 贡献点
架构,我们可以方便的进行逻辑解耦,试写出来的代码更具可维护性与可扩展性。