前端依赖注入的探索和实践

本文作者:来自MoonWebTeam的nicolasxiao腾讯高级工程师

本文编辑:kanedongliu

1. 引言

随着前端领域快速发展,前端的触手不断的延伸,从客户端,服务端,再到前端的富客户端都有前端技术的踪迹。前端工程的业务职责也在不断的增加,比如基于web浏览器的富文本编辑器,低代码编辑器。随着前端涉及的业务逻辑的复杂度提升,前端的职责不再停留于"切图",以前简单的html + css + js,直接开搞的开发逻辑已经很难 hold 住现在的前端项目复杂度。一旦面临复杂度的提升,项目的整体设计就显得很重要了。而在后端领域已经很成熟的架构设计思想,整洁架构,DDD,控制反转等,正好能够应对前端项目当前面临的问题。

本文着重讲解在前端领域,如何深入运用依赖注入来解决前端项目复杂度高所带来的可维护性,可测试性等问题。

2. 什么是依赖注入

2.1 依赖注入

依赖注入,英文全称是 Dependency Injection,字面上很好理解,将依赖帮你注入进去。 但如果没有从头到尾亲身经历一个大型的复杂项目,可能很难理解是什么把依赖注入到哪里。 下面我们以一个例子来解释什么是依赖注入。

假设我们用一个类来描述一架飞机的构造,一架飞机有一个引擎,一个飞控系统,类定义如下:

kotlin 复制代码
class Airplane {
  private engine: TurbofanEngine;
  private controlSystem: EletronicFlightControlSystem;
  
  constructor() {
    this.engine = new TurbofanEngine();
    this.controlSystem = new EletronicFlightControlSystem();
  }
  
  fly() {
    this.controlSystem.control();
    this.engine.inject();
  }
}

可以从上面的例子看到,我们的飞机类 Airplane 依赖了两个具体的类 TurbofanEngine 和 ElectronicFightControlSystem,并且飞机类自己对这两个依赖的类主动进行了实例化:

ini 复制代码
this.engine = new TurbofanEngine();
this.controlSystem = new EletronicFlightControlSystem();

而依赖注入要解决的就是由框架帮你将依赖注入,而不是你主动实例化依赖。我们将上面例子改造成依赖注入的方式:

kotlin 复制代码
class Airplane {
  private engine: TurbofanEngine;
  private controlSystem: EletronicFlightControlSystem;
  
  constructor(
      engine: TurbofanEngine,
      controlSystem: ElectronicFlightControlSystem
  ) {
    this.engine = engine;
    this.controlSystem = controlSystem;
  }
  
  fly() {
    this.controlSystem.control();
    this.engine.inject();
  }
}

这样我们的飞机类就不需要主动实例化自己的依赖了。因为依赖是从构造函数"注入"进来的,所以这个设计原则叫做依赖注入。我们已经知道依赖注入这个动作做了什么(从飞机类构造函数注入依赖的实体),我们也知道依赖注入是注入到了哪里(飞机类)

2.2 依赖注入的核心------who injects?

那下一步是谁帮我们把依赖注入进去的?

答案是 IoC 容器。

在使用依赖注入的框架里,都会有一个 IoC 容器,它的职责就是帮忙管理类的依赖关系,负责类的实例化,以及类的依赖注入。我们通过一个简单的伪代码来了解下 IoC 容器都做了什么?

js 复制代码
    class IoCContainer {
      public static create<T>(classType: Newable<T>) {
        const dependencyInfos = this.getDependenciesFrom(classType);
        const depInjections :Record<Function, any> = [];
        for (const depInfo of dependencyInfos) {
          const depClass = this.findClassByDependencyInfo(depInfo);
          depInjections[depClass] = this.create(depClass);
        }
        
        const instance = new classType();
        this.injectDependencies(instance, depInjections);
        
        return instance;
      }
    }

从上面的伪代码可以看到, IoC 负责创建对象实例,首先扫描类的依赖,递归生成依赖实例,然后注入到待创建的对象实例中,再将对象实例返回。

而我们使用依赖注入框架来创建对象的过程如下:

js 复制代码
    const c919 = IoCContainer.create(Airplane);

IoC 帮忙把 c919 的引擎和飞控系统实例化并注入到 c919 的实例里。

3. 为什么需要依赖注入

可能会有质疑的声音,"搞了这么多花里胡哨的,不就创建个对象吗?我自己 new 不就行了。你这明显是增加了复杂性"。

3.1 提升可维护性

我们继续看上面那个例子,假如现在我们的经理告诉飞机设计师,由于降本增效,厂里所有的飞机的零部件都需要降低成本,引擎不再用涡扇发动机(Turbofan Engine),改用螺旋桨飞机(Propeller Engine)了。 那我们就要修改我们的飞机类的实现了:

js 复制代码
    class Airplane {
    -  private engine: TurbofanEngine;
    +  private engine: PropellerEngine;
      private controlSystem: EletronicFlightControlSystem;
      
      constructor() {
    -   this.engine = new TurbofanEngine();
    +   this.engine = new PropellerEngine(); 
        this.controlSystem = new EletronicFlightControlSystem();
      }
      
      fly() {
        this.controlSystem.control();
    -   this.engine.inject();
    +   this.engine.rotate(); 
      }
    }

我们发现遇到两个问题:

首先是,我们陷入了细节,我们需要知道每一个引擎的细节,比如涡扇发动机驱动飞机飞行是通过喷射(inject)的,而螺旋桨发动机驱动飞机飞行是通过螺旋桨的旋转(rotate)。 其次是,我们需要修改多次代码来接入一个新的依赖。

可能这时候有人会说,"这不是还好吗?我就改了几行代码"为了回应这个问题,我们假设飞机厂里,不止制造普通的飞机,也制造战斗机(fighter)。

js 复制代码
    class Fighter {
      private engine: TurbofanEngine;
      private controlSystem: EletronicFlightControlSystem;
      
      constructor() {
        this.engine = new TurbofanEngine();
        this.controlSystem = new EletronicFlightControlSystem();
      }
      
      fly() {
        this.controlSystem.control();
        this.engine.inject();
      }
    }

很显然战斗机的类里面也面临跟飞机类一样的改造问题。假设有 N 个类依赖引擎类,并且平均每个类里有 M 次对引擎类的调用,那涉及的改造量就是 N x M。这会让项目的复杂度急剧上升,可维护性很低。

而依赖注入就是为了解决这种随着项目业务复杂度上升带来的可维护性降低的问题,而产生的。

先看看依赖注入是如何降低我们上面例子的修改成本的。

首先,依赖注入的思想指导我们不应该依赖一个具体的类实现,而是依赖于一个抽象的接口类。

普通飞机和战斗机依赖于 IEngine 和 IControlSystem 两个抽象类

js 复制代码
    class Airplane {
      @inject(ENGINE)
      private engine: IEngine;
      private controlSystem: IControlSystem;
    }

    class Fighter {
      @inject(ENGINE)
      private engine: IEngine;
      private controlSystem: IControlSystem;
    }

然后是具体的类实现抽象接口。螺旋桨发动机和涡扇发动机分别实现 IEngine 抽象接口类

js 复制代码
    interface IEngine {
      work(): void;
    }
    // 螺旋桨发动机
    @injectable()
    class PropellerEngine implements IEngine {
      work() {
        this.rotate();
      }
    }

    // 涡扇发动机
    @injectable()
    class TurbofanEngine implements IEngine {
      work() {
        this.inject();
      }
    }

最后要解决的是怎么给 Airplane 类和 Fighter 类的 engine 注入一个实例。这就是上一节讲到的 IoC 容器所做的事情,无需开发业务的程序员关注。我们来看看假如我们用了依赖注入,碰到厂里统一更换引擎的需求,应该怎么做:

js 复制代码
    -IoCContainer.register(ENGINE, TurbofanEngine);
    +IoCContainer.register(ENGINE, PropellerEngine);

我们只需修改一行代码就能完成需求。

3.2 提升可测性

依赖注入还有另外一个优势,就是大大提升了项目的可测性。

因为我们的类对外部的依赖都是抽象的,没有依赖具体的实现,所以很容易被 mock 掉。

比如我们要测试飞机的功能是否正常运转,但要让飞机运行起来,我们需要给它一个引擎实例,假设引擎实例的初始化条件很苛刻。这时候我们只需要把引擎 mock 掉,就可以轻松的让飞机跑起来。

js 复制代码
    class MockEngien implements IEngine {
      work() {
        noop();
      }
    }

    IoCContainer.register(ENGINE, MockEngine);

4. 控制反转和依赖注入的区别

通常我们上网上学习依赖注入,控制反转之类的知识,常常会有多个名词出现,导致我们傻傻分不清楚。

比如有不少的文章都有相同的观点:"控制反转就是依赖注入,两者没什么区别"。其实这样的说法是不恰当的。

4.1 什么是控制反转

首先说下,控制反转,英文名是 Inversion of control,缩写为 IoC,也就是大家经常看到的专有名词。既然有控制反转,那不反转的控制是怎么样的?可以看看下面的代码,它就是不反转的:

js 复制代码
    std::cout << "请输入你的名字:" << std:endl;
    std::cin >> name;

在第二行代码,我们的程序主动的请求了控制台的输入信息,并把输入信息存到了 name 变量里。我们的程序是主动的,所以它的控制流是正向的。

那如果把控制流反转了,就变成这样了:

上图的问号是什么呢?是我们设计的框架,框架的设计,就是一种控制反转,我们不主动调用,而是被框架所调用。

这种设计原则叫做好莱坞/原则"你不用主动找我们,让我们来找你"。

控制反转的好处,就是隐藏了底层的细节,让上层的逻辑与底层细节解耦。

最常见的控制反转的例子,其实我们开发过程中常常会接触到,就是 GUI 的事件绑定,看看下面的例子,使用 vue 实现了刚才我们的命令行例子:

html 复制代码
    <template>
      <div>请输入你的名字:</div>
      <input @input="handleInput" />
    </template>

    <script setup>
      const handleInput = (e) => {
        // e.target.value 就是用户的输入
      }
    <script>

这里我们并没有主动去获取用户的输入,而是由框架(Vue)帮我们返回用户的输入。我们不需要知道输入是怎么获得的,这些交给框架(Vue)来帮我们解决。

只要符合控制流反转的设计模式,都统称为控制反转,比如回调函数,也可以是一种控制反转。

4.2 依赖注入如何实现控制反转

看到这里,相信大家已经知道控制反转的真正定义是什么了。

那按照控制反转的原则,就是控制流从主动变为被动。而依赖注入哪个步骤是符合这个特征的呢?

很显然,是类的依赖是如何被创建的这个过程。

当我们的类的依赖没有控制反转之前,是这样的:

我们的类实例化过程伪代码是这样的,下面都是我们需要写的代码

scss 复制代码
// 实例化过程
new XXX();
-> new DepA();
-> new DepB();
-> new DepC();

当我们通过依赖注入时,控制反转,是这样的:

当依赖注入时,我们实例化类 XXX 的实例的过程是这样的,下面都是框架帮我们做的

js 复制代码
    const x = DI.create(XXX);
    DI.inject(DepA).to(x);
    DI.inject(DepB).to(x);
    DI.inject(DepC).to(x);

我们需要做的就是标记依赖,绑定依赖的具体实现

js 复制代码
    // 标记依赖
    // src/xxx.ts
    class XXX {
      @inject('depA')
      private depA: IDepA;
      @inject('depB')
      private depB: IDepB;
      @inject('depC')
      private depC: IDepC;
    }
    // 绑定具体实现,我们随时可以更换具体类,业务代码无需任何改动
    // di-config.ts
    DI.bind('depA', DepA);
    DI.bind('depB', DepB);
    DI.bind('depC', DepC);

5. 前端依赖注入生态

5.1 依赖注入库

InversifyJS 是一个支持 TypeScript 和 JavaScript 的轻量级依赖注入框架。目前是使用人数最多的类库,github star 1万多。

InversifyJS 支持 typescript 的注解方式实现依赖注入,同时也可以支持 javascript 非注解方式的依赖注入。

tsyringe 是微软开发的依赖注入框架,在使用上与 inversify 大同小异。在 github 上也有 4.5k 的 star

5.2 支持依赖注入的框架

nest.js 是波兰软件工程师 Kamil Mysliwiec 开发的一个基于 node.js 的后端框架。除了封装大量的实用功能,其核心也是基于依赖注入的机制搭建而成。

midway 是阿里淘宝团队开发的 node.js 后端框架,支持云函数部署。同样也是以依赖注入为核心机制来搭建其整体架构。

angular 是一个超前的前端框架,也是目前主流前端框架里唯一支持依赖注入的框架。

类似 midway,个人开发的 node.js 后端框架。在阿里云函数模版有收录。

6. 依赖注入的实现原理分析

6.1 依赖注入实现的前置依赖

通过调研目前市面上的面向前端的依赖注入库,除了少数使用函数方式调用的依赖注入,其他大部分都是基于装饰器(decorator)和反射(reflect)实现的声明式依赖注入,这也是主流的依赖注入的开发方式。

声明式依赖注入所需要的这两个特性在目前浏览器的支持程度是,装饰器当前所有的浏览器都不支持,需要转译器转移,而反射是不完全支持,需要有 polyfill。

所以一般在前端项目实现声明式依赖注入的前提是,需要有转译器和 polyfill 的工程才能实现。

通常使用的转译器是 typescript 编译器, babel 来转译装饰器语法,用 reflect-metadata 来补齐反射的能力。

我们有必要先了解下 typescript 编译器 和 reflect-metadata 都帮我们干了什么

6.1.1 typescript 编译器的作用

装饰器的转译:typescript 编译器会帮忙转译装饰器的代码,装饰器的本质就是给被装饰的对象添加"装饰"(往上面挂数据,比如修改被装饰对象的属性,原型链,元数据等等)

例如下面的这段伪代码:

js 复制代码
    function Dec() {
      return (target) => Reflect.defineMetadata('mydata', 'aha');
    }

    @Dec()
    class NoopClass {
    }

通过转译后会变成这样的(伪代码展示,tsc 不是这么转译的)

js 复制代码
    function Dec() {
      return (target) => Reflect.defineMetadata('mydata', 'aha');
    }

    const NoopClass = (function () {
      class _NoopClass {
      }
      applyDecorator(_NoopClass, Dec);
      return _NoopClass;
    })();

转译器会在转译过程中,会额外在类声明语句后面生成对装饰器调用的语句逻辑,并且生成把装饰后的类替换原来的类的语句逻辑。

注意这里看网上文章讲解依赖注入的文章普遍有一个误区,就是认为转译器在转译阶段就执行了装饰器的逻辑,这是错误的理解。装饰器的逻辑是在运行时才会被执行。

emitDecoratorMetadata

emitDecoratorMetadata 是 tsc 的一个能力,开启它可以让 typescript 编译器在转译装饰器的时候,把被装饰对象的元数据生成到代码里。

什么是元数据(metadata),可以简单粗暴的理解为就是跟源代码有关的数据,借助这些数据,编程语言可以在运行时,获得与代码结构相关的信息,比如函数的原型定义,类的属性,方法定义等等。

为什么要注入元数据呢?是为了解决运行时的语言反射问题。不像其他语言,比如 java, go本身自带了反射能力。javascript 本身就是个弱类型语言,无所谓什么反射,而 typescript 最终也要被转成 javascript, 类型信息也全部丢失。为了解决类型信息丢失,才设计出了这个注入元数据的方法。

我们可以看看下面的伪代码,转译器是如何注入元数据的

js 复制代码
    // 编译前
    class AClass {
    }

    function Dec() {
      return (target) => Reflect.defineMetadata('mydata', 'aha');
    }

    @Dec()
    class NoopClass {
      constructor(a: AClass) {
      }
    }

转译后,构造函数里的参数信息会被保存到代码里

js 复制代码
    class AClass {
    }

    function Dec() {
      return (target) => Reflect.defineMetadata('mydata', 'aha');
    }

    const NoopClass = (function () {
      class _NoopClass {
      }
      applyDecorator(_NoopClass, [
        Dec,
        defineMetadata('design:paramtypes', [AClass])
      ]);
      return _NoopClass;
    })();

转译器在生成注入装饰器的逻辑时,也把类型信息一并注入了。

这样在运行时就可以通过读取类的元数据的 design:paramtypes 这个key 下对应的值来获得构造函数的参数类型信息

6.1.2 reflect-metadata 的作用

而 reflect-metadata 这个库,就是用来 polyfill javascript的反射能力,让 javascript 支持通过 Reflect.defineMetadata 和 Reflect.getMetadata 来定义或者获取对象的元数据。

比如我们上面 typscript 编译器已经帮我们生成了类型元素数据,我们就可以通过 Reflect.getMetadata 来获取到构造函数的参数类型:

js 复制代码
    const paramTypes = Reflect.getMetadata('design:paramtypes', NoopClass);
    // paramTypes = [AClass]

除了可以获得函数的参数类型,元数据还支持其他的类型信息获取:

design:type: 获取当前对象的类型,比如函数会返回函数本身

design:paramtypes: 获取当前对象的参数类型列表,通常是一个数组,里面按顺序存放了每个参数的类型。

design:returntype: 获取当前对象的返回类型

6.2 依赖注入的实现

那声明式依赖注入如何实现,我们一步步来实现一个最小的依赖注入库。

依赖注入的方式有两种,一种是从类的属性注入,另外一种是从类的构造函数注入。

首先我们看看类属性注入的实现。

6.2.1 类属性注入的实现

依赖的注解

类属性注入首先会提供给开发者可以标记依赖注入的装饰器,这里是为了记录当前依赖的类型,方便后续 IoC 容器注入依赖时,可以找到具体的类。

通常类属性依赖注入是这么编写

js 复制代码
    class XXXClass {
      @inject()
      private a: AClass;
    }

inject 装饰器在这里做的逻辑是,记录当前属性 a 的类型到 XXXClass 的元数据里

依赖注入的元数据的保存结构示意:

下面是 inject 的实现伪代码:

js 复制代码
    // inject 装饰器
    function inject() {
      return (target: any, prop: string) => {
        // 获取属性的类型
        const type = Reflect.getMetadata('design:type', target, prop);
        // 获取跟依赖注入相关的元数据
        const DIData = Reflect.getMetadata(DIDataKey, target);
        // 设置依赖注入的类型关系
        DIData.injections.set(prop, type);
        // 保存依赖注入元数据
        Reflect.defineMetadata(DIDataKey, DIData, target);
      } 
    }

依赖注入过程

有了依赖的注解元数据,IoC 容器在创建时处理依赖就有迹可循了。

通常情况下需要依赖注入的类的实例化,需要借助 IoC 容器,如下所示:

js 复制代码
    const xxx = IoCContainer.create(XXXClass);

用上面的语句替代我们常规的 new XXXClass。

IoCContainer.create 所做的事就是帮助实例化类,并在类的依赖注入元数据里找到属性依赖的类型,并逐个实例化依赖,并把依赖注入到创建的实例中。

下面是 IoC 容器在 create 的逻辑伪代码:

js 复制代码
    class IoCContainer {
    ...

    public static create<T>(classType: Newable<T>) {
      // 获取类的依赖元数据
      const DIData = Reflect.getMetadata(DIDataKey, classType.prototype);
      // 创建类的实例
      const obj = new classType();
      
      if (DIData?.injections) {
        // 遍历每一个依赖类型
        for (const depclsInfo of Object.entries(DIData.injections)) {
          // 获取属性名和属性类型
          const [field, ctor] = depclsInfo;
          // 根据属性类型创建属性对象,并注入到当前对象内
          (obj as any)[field] = create(ctor);
        }
      }
      return obj;
    }

    ...
    }

create 是一个递归过程,可以逐层将依赖实例化,即使依赖是逐层嵌套的,也可以被注入。

6.2.2 类构造函数注入的实现

除了通过属性进行依赖注入,依赖注入还有另外两种形式,构造函数注入和接口注入。

接口注入太不常见,这里不做深入探讨。本小节将讲解下构造函数注入的实现机制。

构造函数注入在一些框架中也被包装成"自动装配"(autowired)的概念,因为这种方式只需要在类声明前注解一次,就可以自动查找所有依赖并注入,比较黑魔法,也比较自动化。

依赖的注解

可以看看使用类构造函数注入的调用方式:

js 复制代码
    @autoWired()
    class XXXClass {
      constructor(
        private a: AClass, 
        private b: BClass,
        private c: CClass,
      )
    }

从前面了解到的装饰器和反射的知识可以知道,当装饰器写在类申明前,其实是获取的信息是类的构造函数的信息。所以可以在装饰器的逻辑内获取到构造函数的参数列表的元数据,这就好办了。

下面的图展示了依赖的类型是如何被记录的。

它们被按照构造函数的顺序,依次记录到类的元数据中的依赖注入类型信息里。

我们看看具体 autoWired 怎么实现:

js 复制代码
    // autoWired 装饰器
    function autoWired() {
      return (target: any, prop: string) => {
        // 获取构造函数的参数列表类型
        const types = Reflect.getMetadata('design:paramtypes', target, prop);
        // 获取跟依赖注入相关的元数据
        const DIData = Reflect.getMetadata(DIDataKey, target);
        // 记录依赖类型
        DIData.autoWires = tyeps;
        // 保存依赖注入元数据
        Reflect.defineMetadata(DIDataKey, DIData, target);
      } 
    }

依赖注入过程

跟属性依赖注入过程非常接近,唯一不同的地方是,构造函数依赖注入借助了 typescript 的语法特性:可以在构造函数的参数中直接声明类的属性。所以只要我们按正确的顺序把依赖的实例传给构造函数,对应的值就会赋予到对应的属性上。

下面我们可以通过伪代码来看看是如何实现的:

js 复制代码
    class IoCContainer {
    ...

    public static create<T>(classType: Newable<T>) {
      // 获取类的依赖元数据
      const DIData = Reflect.getMetadata(DIDataKey, classType.prototype);

      if (DIData?.autoWires) {
        // 实例化每一个依赖
        const deps = DIData.autoWires.map(ctor => create(ctor));
      }
      // 创建类的实例,并传入依赖的实例
      return new classType(...deps);
    }

    ...
    }

我们只要在 create 里判断当前的类是基于属性或者构造函数注入的,就可以统一处理两种依赖注入,并实现它们之间的交叉使用。

6.2.3 单例和循环依赖的实现

到这里,我们基本已经实现了一个简单的依赖注入的能力,但实际开发过程中,还有不少细节需要处理,本文的篇幅有限,就不再展开。这里挑两个比较经典的问题:单例和循环依赖,补充说明下其实现的方式。

单例

先说单例,在使用依赖注入过程中,我们的依赖不一定是每次都是 new 出来的对象,可以是一个单例。这种情况在依赖注入中的处理很简单,只需要把依赖类标记为单例,并在 create 阶段读取这个单例就可以了。

假设一个依赖的类 AClass,它是一个单例,那么我们可以在IoC容器注册这个依赖时,标记它为单例

js 复制代码
    IoCContainer.register(AClass).forSingleton();

然后我们需要在 IoC 容器里保存一个单例池,用来保存单例的实例:

js 复制代码
    interface InjectableInfo {
      classType: Function;
      isSingleton: boolean;
      instance: any;
    }
    class IoCContainer {
      ...
      private static injectables: Map<Function, InjectableInfo> = {};
      ...
      private static isClassSingleton(classType: Function): bool {
        return injectables [classType].isSingleton;
      }
      ...
      public static create<T>(classType: Newable<T>) {
        ...
        // 当类是单例,并已经存在时,直接返回
        if (this.isClassSingleton(classType)) {
          if (injectables[classType].instance) {
            return injectables[classType].instance;
          }
        }
        
        ...
        const instance = new classType(...
        // 如果是单例,将单例缓存起来
        if (this.isClassSingleton(classType)) {
          injectables[classType].instance = instance;
        }
        
    }

循环依赖

面向对象开发不可避免的话题,就是循环依赖。循环依赖不仅会让一些没有垃圾回收的语言产生内存泄露。

同时在依赖注入框架中,循环依赖也会导致出错,比如 create 会陷入无限递归,最后 stack overflow。

但循环依赖,在实际开发中,确实是有实际的运用场景。所以,依赖注入框架不会简单的检测到循环依赖,直接给你报错,而是通过一些机制来支持这种特性。

这里我们只考虑单例依赖的场景。

假设我们有两个类,AClass 和 BClass,它们互相依赖:

js 复制代码
    class AClass {
      @inject()
      private b: BClass;
    }

    class BClass {
      @inject()
      private a: AClass;
    }

如果我们按照上面的 create 函数实现,那它的逻辑会是这样的:

流程形成了一个环,会无限的执行下去。两个实例都无法创建出来。

这里讲一种解决方法,就是通过 proxy 来为依赖实例生成一个替身。

因为替身只有在被实际调用的时候才会惰性创建,这个时候,A的实例已经创建出来了,所以不会再执行创建 B 的过程。

7. 基于依赖注入的案例改造

早期的云游项目是没有对底层的云游戏 SDK 进行抽象接口设计的,直接在业务逻辑依赖了先锋云游戏 SDK 的接口。这就导致后面我们接到需求要切到架平云游戏时,面临大量的改动。

早期的云游代码(示例伪代码):

js 复制代码
    class MidGame {
      public init() {
        ...
        // 强依赖先锋的 SDK
        this.cloudGame = new GameMatrixSDK();
        ...
      }
      public play() {
        ...
        this.cloudGame.play();
        ...
      }
      
    }

    class NormalGame {
      // 同样一大堆先锋 SDK 依赖
    }

后面通过架平 SDK 引入的契机,我们对 SDK 调用重新设计,引入了 SDK 的抽象接口,具体业务不再依赖具体的 SDK 接口。并使用 SDKManager 来管理 SDK 的实例化。

js 复制代码
    interface GameSDK {
      init(config: Config): void;
      play(): void;
      pause(): void;
      setBitrate(min: number, max: number): void;
      ...
    }

    class GamematrixSDK implements GameSDK {
      ...
      public init(...
    }

    class MetahubSDK implements GameSDK {
      ...
      public init(...
    }

    class MidGame {
      public init() {
        ...
         const SDK = SDKManager.getSDK();
         
         this.cloudGame = new SDK();
         ...
      }
      public play() {
        ...
        this.cloudGame.play();
        ...
      }
      
    }

    class NormalGame {
      ...
    }

其实我们这次重构已经实现了 IoC 控制反转。SDKManager 也担任了部分 IoCContainer 的职责。

而这种职责是通用的,不仅仅 SDK 可以用,其他单例,实例都可以用。

所以接下来,我们可以更进一步,将这个项目改造为依赖注入的方式。

js 复制代码
    interface GameSDK {
      init(config: Config): void;
      play(): void;
      pause(): void;
      setBitrate(min: number, max: number): void;
      ...
    }

    class GamematrixSDK implements GameSDK {
      ...
      public init(...
    }

    class MetahubSDK implements GameSDK {
      ...
      public init(...
    }

    IoCContainer.bind(GAME_SDK).to(GamematrixSDK);

    class MidGame {
      @inject(GAME_SDK)
      private cloudGame: GameSDK;
      public init() {
        ...
      }
      public play() {
        ...
        this.cloudGame.play();
        ...
      }
      
    }

    class NormalGame {
      @inject(GAME_SDK)
      private cloudGame: GameSDK;
      ...
    }

这样所有控制反转和实例化单例的职责都丢给了依赖注入框架,我们的代码对业务的专注度更高,无需处理这些非业务的职责,只需把我们要实现的业务,要实现的依赖写好,填进去,依赖注入就会自动帮我们关联,实例化。

这里只是举了一个方面,如果把所有同质化的问题都拿出来一起改造,从可维护性和可测性上,都会有明显的提升。

8. 总结

本文从依赖注入的概念,到前端依赖注入的生态,再到实现原理剖析,以及最后的案例改造,全面的讲解了笔者对依赖注入在前端领域的探索和实践得到的知识。相信阅读完之后,应该自己动手实现一个依赖注入库没啥问题。但本文的本意是希望能够通过较深入的剖析原理,让读者在后续实践过程中能更好的理解依赖注入的机制,在开发业务时能选择正确的解决方案。

最后,如果客官觉得文章还不错,👏👏👏欢迎点赞、转发、收藏、关注,这是对小编的最大支持和鼓励,鼓励我们持续产出优质内容。

9. 关于我们

MoonWebTeam目前成员均来自于腾讯,我们致力于分享有深度的前端技术,有价值的人生思考。

10. 往期推荐

MoonWebTeam前端技术月刊第1期

2024年前端技术演进&优秀实践技术扫描

E2E测试用例生成方案探索

穿越时空:2023年前端技术盘点与2024年技术展望

基于 Appium2 的 H5 E2E 测试实践

相关推荐
小镇程序员2 小时前
vue2 src自定义事件
前端·javascript·vue.js
AlgorithmAce4 小时前
Live2D嵌入前端页面
前端
nameofworld4 小时前
前端面试笔试(六)
前端·javascript·面试·学习方法·递归回溯
前端fighter5 小时前
js基本数据新增的Symbol到底是啥呢?
前端·javascript·面试
GISer_Jing5 小时前
从0开始分享一个React项目:React-ant-admin
前端·react.js·前端框架
川石教育5 小时前
Vue前端开发子组件向父组件传参
前端·vue.js·前端开发·vue前端开发·vue组件传参
GISer_Jing6 小时前
Vue前端进阶面试题目(二)
前端·vue.js·面试
乐闻x6 小时前
Pinia 实战教程:构建高效的 Vue 3 状态管理系统
前端·javascript·vue.js
weixin_431449686 小时前
web组态软件
前端·物联网·低代码·编辑器·组态
橘子味小白菜6 小时前
el-table的树形结构后端返回的id没有唯一键怎么办
前端·vue.js