Z
Toggle Nav

依赖注入与贡献点架构

控制反转 (Inversion of Control, IoC) 是面向对象中的一个设计原则,用来降低代码耦合度。其中最常见的方式是 依賴注入 (Dependency Injection, DI)。

TL;DR

随着前端的不断发展,一个工程的规模、复杂度不断提升,对于工程的可扩展性、可维护性都有了更高要求。如果以常规开发思路考虑工程中各个部分的组合,随着工程的不断演进,复杂度不断提升,带来的主要问题就是可维护性和可扩展性的极大下降。此时可以使用依赖注入与贡献点架构组织前端工程,提升可维护性与可扩展性。

依賴注入

假设不使用依赖注入,通常我们有一个单例 Service 的需求时,可能是这样的:

class _Service {
    foo() {
        return "foo"
    }
}

export const Service = new _Service();

通过 export/import,我们可以确保使用的 Service 始终是同一个实例,目前看似乎没什么问题。

但随着业务发展,我们需要另一个服务,他的部分方法重载了 Service 上的部分方法,仍是一个单例,那么写法可能是这样的:

class _Service2 extends _Service{
    foo() {
        return "bar"
    }
}

export const Service2 = new _Service2();

咋看似乎没有问题,但随着项目不断演进,工程代码可能充斥这下面这样的判断:

const targetService = cond ? Service : Service2;
targetService.foo();

长久发展下去这样的代码是无法维护的。

出现这种问题的原因是:我们依赖了 实现,而非 接口

如前面代码描述的,我们在业务中使用 new 出来的 Service 实体,这个单例一旦创建,就是不可变实体,我们只能通过不同的逻辑去消费不同的它。

那么如何解决这个问题呢?方法是依赖 接口

不同的语言有不同的用于定义 接口 这个抽象概念的方式,例如 TypeScript、Go、Kotlin 中的 Interface,Rust 中的 Trait 等。

对于一个 接口,我们可以有不同实现,根据不同条件,使用不同的实现,对于上面的例子,我们就可以改成:

interface IService {}

class ServiceA implements IService {}
class ServiceB implements IService {}

const SA = new ServiceA();
const SB = new ServiceB();

export function getServiceInstance():IService {
    return cond ? SA : SB;
}

此时我们的代码就变成了依赖 接口,而非 实现

除了上面的例子,另一个典型场景是 依赖。 例如一个基类,它可能有别的数据以依赖,需要在 new 时传进去:

class BaseService {
    constructor(private fooService, private barService) {
    }
}

class ServiceA extends BaseService {}
class ServiceB extends BaseService {}

const SA = new ServiceA(fooService, barService);
const SB = new ServiceB(fooService, barService);

它的任何子类都需要 new 时手动传进去基类上的数据依赖,随着时间的推移,工程维护成本太高了。

该如何解决上面这类问题?答案是使用 依赖注入

通常的依赖注入写法类似下面的代码:

@injectable()
class fooService implements IFooService {
    foo() {}
}

class Service{
    @inject()
    private fooService: IFooService
}

可以看到,我们需要标记一个 class可注入的,在需要使用它时,标记某个属性 注入 某个目标。这篇文章不注重 依赖注入 如何实现,这是因为 依赖注入 在各个语言上都有着成熟的实现,例如 Koin、 TypeDI、TSyringe 等,这里就不再赘述了,大部分场景下直接使用开源库即可。

简单来说,通过 依赖注入,我们可以找到 接口 的一个或多个 实现,让依赖看上去是自动获得的,而不是手动传递。

贡献点

一个可扩展的软件,必然要在架构层面支持功能的扩展,例如各种 IDE 的 扩展、调色软件的插件等。这里我们不讨论这么庞大的扩展机制如何实现,仅从代码角度思考:如何让一个功能是可扩展的?

假设我们的工程既可以跑在浏览器中,又能跑在 Electron 中。有一个功能在浏览器上和 Electron 采用不同的实现,但他们对外暴露的 API 是一致的,例如下面的例子:

interface ILoad {
    load(): Promise<ArrayBuffer>
}

class BrowserImpl implements ILoad {
    load(): Promise<ArrayBuffer> {
        // ...
    }
}

class ElectronImpl implements ILoad{
    load(): Promise<ArrayBuffer> {
        // ...
    }    
}

那么如何如何根据不同的环境执行不同的 load 方法?或者更进一步说,如果未来又新增了一个平台,怎么新增一个 load 实现,让其方便的融入现有系统?

理解了依賴注入,你就应该知道你应该知道,我们要做的是通过 接口 找到 实现

所以再回看上面的代码,可以理解为:多个 实现 向一个 接口 贡献内容,剩下的就是找到一种方法,能让我们快速找到 接口 对应的所有 实现,这是我们可以继续利用依赖注入实现这个功能。

前面提到过,主流的依赖注入库都可以实现根据 接口 找到多个其对应的 实现。在这里我们直接视使用 typedi 这个依赖注入库。

// 标记一个贡献点定义
export function contribution() {
  return function (target: any) {
    Reflect.defineMetadata(target.name, '', target);
  };
}

// 标记一个贡献点实现
export function contributionImplement() {
  return function<T> (target: T) {
    const keys: string[] = Reflect.getMetadataKeys(target) ?? [];
    keys.forEach((key) => {
      Container.set({
        id: key,
        type: target as unknown as Constructable<T>,
        factory: undefined,
        multiple: true,
      });
    });
  };
}

// 根据类型捞取贡献点
export function getContributions(obj: any) {
  return function (target: any, propertyKey: string) {
    Object.defineProperty(target, propertyKey, {
      get() {
        return Container.getMany(obj.name);
      },
    });
  };
}

通过这个三个装饰器,我们可以实现贡献点机制的三步:

  1. 定义贡献点
  2. 实现贡献点
  3. 根据定义捞取所有实现

以下面的例子为例简单看一下怎么使用:

import { Component, ComponentClass } from "react";

// 定义一个贡献点,内容是一个 React Component Class
@contribution()
export abstract class IPanelContribution {
    abstract component: ComponentClass;
}


// 实现一个贡献点,向贡献点贡献 panel a
class PanelItemA extends Component {
    public render(){
        return <div>panel a</div>  
    }
}

@contributionImplement()
export class PanelItemAImpl extends IPanelContribution {
    public component = PanelItemA;
}

// 根据贡献点定义捞取所有实现
export class Panel extends Component {
    @getContributions(IPanelContribution)
    private panels: IPanelContribution[];

    public render() {
        return (
            <div>
                {this.panels.map((C, i) => <C.component key={i} />)}
            </div>
        );
    }
}

如此,我们实现了 PanelItemPanel 的解耦,Panel 不关心具体内容,有多少 IPanelContribution 实现,就会有多少 PanelItem

通过 依赖注入贡献点 架构,我们可以方便的进行逻辑解耦,试写出来的代码更具可维护性与可扩展性。