博客
JavaScript 中的依赖注入

前端中的依赖注入

软件开发最富有挑战性也最有趣的一项任务就是如何化繁为简。

在保证业务需求得到满足的前提下,我们(软件工程师们)需要确保软件的可用性、可扩展性、性能、一致性、容错性、稳定性、可重用性等等非功能特性,这决定了软件天生是复杂的。但是人的思维能力是有限的,我们没法兼顾这么多的方面,因此,我们借助“抽象”思想来帮助我们设计软件的架构,征服软件的复杂性。

抽象思想给开发者的武器库里增加了“分层”,“函数、类、模块”,“客户端-服务端”这样的思维工具,开发者们将软件抽象成各个组成部分,然后确定它们内部的工作原理和相互之间的协作方式,这样就能通过一次解决一个小问题,最终解决一个大问题。进一步地,为了更好地做“将软件划分成各个组成部分”这件事,业界提出了各种各样的设计原则和设计方法:职责驱动设计、SOLID 原则、GRASP 原则、高内聚低耦合等。但是这样的原则和设计方法可能还是不够具体,因此设计模式被提出了,最著名的就是 GoF 的 23 种设计模式,这样我们实际编写代码时就能够按图索骥了。

依赖注入是最近几年在前端领域用得越来越多的一种设计模式,本文的目的就是要介绍这种模式如何贯彻抽象思想并反映面向对象设计原则的,还会讨论依赖注入模式本身的特性,以及如何在项目中运用它。

💡

上面的文字似乎暗示:设计模式是通过设计原则演绎出来的,实际上并非如此,设计原则往往是通过设计模式归纳出来的。我在这里想表达的是:抽象的武器,可以很“抽象”,也可以很“具体“,而本文要讨论的是一种“具体”的武器,而且它反映了那些更加“抽象”的抽象思想。

我们先来看一些例子,然后归纳出问题,接着引出 依赖注入(Dependency injection, DI) 模式,最后介绍如何在大型应用和 React 项目中使用 DI。对于那些对 DI 技术选型感兴趣的朋友,我在最后补充了一些内容。

欢迎大家评论和指出错误。

依赖的相关问题

对于大型项目而言,依赖的管理是一个相当复杂的问题。

问题一

应用的某个功能在不同的操作系统上有不同的实现方式。例如对于“弹出对话框”这个功能,你可能会写出下面这样的代码:

import pcDialog from 'pcDialog'
import mobileDialog from 'mobileDialog'
 
class OrderService {
  public askUser() {
    const title = 'Hello'
    if (platformUtil.isDesktop()) {
      pcDialog.show({
        title,
      })
    } else {
      mobileDialog.prompt({
        text: title,
      })
    }
  }
}

看起来没什么问题,但是有天产品经理说:

为了让 iOS 和 Android 上对话框的效果能够尽量贴近原生样式,我们用平台原生的对话框吧!对了,我们还要支持小程序!

糟糕了,之前在代码里写了多少处 mobileDialog.prompt,就要修改多少处代码,好把更多的 if else 塞进去,然后还需要把移动端的实现改为对 js bridge 的调用,就像下面这样:

const title = 'Hello'
if (platformUtil.isDesktop()) {
  pcDialog.show({
    title,
  })
} else if (platformUtil.isIOS()) {
  iOSDialogBridge.prompt({
    text: title,
  })
} else if (platformUtil.isMiniProgram()) {
  miniProgramDialogBridge.dialog({
    title,
  })
} else {
  androidDialogBridge.prompt({
    text: title,
  })
}

有经验的开发者马上就能够指出:只要把对话框的调用封装在一个 npm 包就可以了,并不用去修改原来的代码。这的确可以省不少事情(真希望所有问题都能通过第三方依赖来解决!),我们只需要替换对 mobileDialog 的引用就可以了。到目前为止,只是多了一些维护 npm 包的成本,还能接受。

但是修改代码的麻烦还只是这个问题的一个比较小的方面,另外一个方面更值得考虑:为什么 PC 端要加载 mobile 端组件的代码呢,为什么 iOS 要加载 Android 端组件的代码呢?如果每个平台差异问题都要通过写 if else 来处理,你的打包产物中就会有很多套不同的实现,而很多代码实际并不会在用户的设备上运行。如果你的确在乎包体积,可能会选择在 npm 包中懒加载各个平台的实现,这样你就不得不要求所有 npm 包用户把他们的代码改成异步调用的,这种方案看起来就很不现实,不做考虑。

你可能会想到这样的方案:在构建的时候通过 webpack alias 切换 mobileDialog 实际引用的 npm 包,对不同平台分别构建一次就可以了,但这种方案要求开发者为每一种实现都做一次封装,同时也会极大地增加 CI 时间,显然也是不够优雅的。

虽然我们早就知道产品经理才是导致代码质量降低的罪魁祸首(大雾),但回头想想:这里的一开始的设计是不是就有问题?没错,即使产品经理没有提后面的这些需求,用户还是会在桌面端上加载移动端的对话框。

问题的本质就在于——用我们熟知的 OO 设计原则来描述—— OrderService (还有其他写了类似代码的类)不够内聚,它耦合了“应该弹哪种对话框“的逻辑。如何让这个类更加内聚呢?应该把“弹对话框“这件事交给一个专家来处理,然后依赖它。封装一个第三方依赖确实是一种解决途径,但是它没有办法解决包体积的问题。我们后面会看到,依赖注入模式给出了一种更加灵活的解决方案。

问题二

类终究是要被实例化的。通常的做法是创建一个类作为应用的入口,在这个类里面创建很多类作为模块的入口,然后在模块入口里再去创建各个子类……然后通过实例化入口类来启动整个应用,就像下面这样:

class App {
  constructor() {
    this.model = new Model()
    this.controller = new Controller(this.model)
    this.state = new State(this.model)
    this.httpService = new HttpService(new AuthInterceptor(this.state))
  }
}
 
class Controller() {
  constructor(model: Model) {
    this.model = model
  }
}
 
const app = new App()

这种做法存在如下问题:

  1. 这种写法最终会随着项目变得复杂而变得难于追踪和理解。模块越多,我们就需要花越多的时间去手动调配它们初始化的先后顺序,手动传递构造函数需要的参数;
  2. 其次,这种写法违背了最少知识原则,提高了模块之间的耦合性。在这个例子中 App 为了调用 HttpService 需要知道 AuthInterceptor 是如何构造的,如果 AuthInterceptor 的构造函数新增了一个参数,那么 App 的代码也必须对应做出修改;
  3. 最后,这种写法还导致了一个问题,即很难隔离出一个模块单独进行测试。假设你要测试上文中的 HttpService,就必须把 AuthInterceptor State Model 等等全部构造一遍,也不得不根据这些依赖的行为来编写用例。

问题三

当应用当中的模块多了之后,模块之间的依赖关系就会变得越来越复杂,越来越难管理。

首先是依赖关系的建立会逐渐地失去控制。通常来说在模块之间建立依赖关系有下面几种做法:

  1. 通过构造函数传入依赖
  2. 通过类的静态方法或成员获取实例,例如 AuthService.INSTANCE
  3. 通过一些全局可访问的对象获取实例,例如 window.authService

第一种方法是相对可控的,由于类的依赖都被声明在构造函数中,很容易就能知道一个类有哪些依赖。第二、三种方法较为灵活,在类的任意位置中,你都可以通过这两种方法建立依赖,但是灵活的代价是降低了代码的可维护性,我们需要浏览这个类全部的代码,才能知道它究竟依赖了什么东西,依赖关系会变得混乱,对第二、三种方法的滥用,很容易在不经意间导致循环引用:

export class AuthService {
  static get INSTANCE() {
    // ...
  }
 
  public changeAuth(auth) {
    FileService.INSTANCE.changePermission(auth)
  }
}
 
export class FileService {
  static get INSTANCE() {
    if (!init) {
      return new FileService()
    }
  }
 
  public changeFile(file) {
    if (file.locked) {
      AuthService.INSTANCE.changeAuth({ editable: false })
    }
  }
}

上面是一个较为简单的例子,实际情况远比这个更复杂。

小结

我们对上面三个例子做一个小结,这三个例子实际上反映了依赖管理的三个问题。

抽象问题。 即一个模块 B 依赖的另一个模块 A,到底应该是一个抽象的接口,还是一个具体的实现?SOLID 原则当中的 D(Dependency inversion principle,依赖反转原则)告诉我们:

高层不应当依赖低层,两者都应该依赖抽象。

第一个例子展示了:如果如果高层(OrderService)依赖于一个(更糟地情况下,多个)具体的低层实现(比如一个第三方 npm 包),那么在业务要求改变低层行为的时候,改变就会泄漏到高层中去。相反,如果高层依赖的只是一个“支持同步唤起对话框”的接口,通过在低层提供满足同一接口的不同实现,我们就能在不改动高层代码的前提下满足低层的业务需求。概念是简单的,问题是: 我们如何才能把低层不同的实现交给高层呢?

构造问题。 我们已经看到了手动构造类需要:

  1. 确定构造的顺序
  2. 确定构造的参数
  3. 确定谁来构造

在上面的例子中,构造的顺序和参数是由开发者来制定的,类的构造者则往往是一个知道如何构建其他类的一个巨大的入口类,我们也看到了这种方式的问题所在。对于构造问题我们需要解答:如何让程序自动确定构造的顺序和构造的参数?

关系问题。 复杂的依赖关系容易导致循环依赖,而循环依赖是导致软件行为不可控的很大的风险因素。所以对于关系问题我们需要解答:如何才能避免代码中形成循环依赖

DI 对以上三个问题提出了系统的解决方案。接下来,我们来介绍 DI 模式。

DI 的基本概念

💡
本小节的内容请参考阅读这部分文档

使用 DI 一般分为以下三步:

第一步,声明依赖之间的依赖关系。假设 B 类要依赖于 A 类,就可以通过 Inject 装饰器来声明:

import { Inject } from '@wendellhu/redi'
 
class A {}
 
class B {
  constructor(@Inject(A) private readonly a: A) {}
}

第二步,将依赖项添加到注入器中

import { Injector } from '@wendellhu/redi'
 
const injector = new Injector([[A], [B]])

第三步,获取依赖。 注入器解析 B 并发现它依赖 A,就会先解析 A,构造 A 的实例之后再将它传递给 B 的构造函数,并最终返回 B 的实例。

const b = injector.get(B)

就这么简单!现在你已经了解了 DI 的基本运作方式,让我们看看 DI 是如何解决上面提出的三个问题的。

依赖问题的解决

抽象问题的解决。 DI 通过标识符-依赖项对来做到接口和实现的解耦。回到前面的对话框的例子,用 DI 可以这么写:

interface IDialogService {
    prompt({ title: string; okText: string; }): DialogRef
}
 
interface DialogRef {
  close(): void
}
 
const IDialogService = createIdentifier<IDialogService>('dialogService')
 
class OrderService {
  constructor(@Inject(IDialogService) private readonly dialogService: IDialogService ) {}
}
 
// pc.ts
const injector = new Injector([
  [IDialogService, {
    useClass: PcDialogAdapter
  }]
])
 
// mobile.ts
const injector = new Injector([
  [IDialogService, {
    useClass: MobileDialogAdapter
  }]
])
 
const orderService = injector.createInstance(OrderService)

通过 DI,将抽象的 IDialogService 注入到 OrderService 当中,在通过 createInstance 方法构造 OrderService 的实例时,具体会是实例化 PcDialogAdapter 还是 MobileDialogAdapter,取决于向 Injector 中注册了哪种类型的实现。

通过在不同的打包入口提供不同的实现,就可以同时做到:

  1. 业务代码依赖于抽象的 IDialogService 接口而不是具体实现
  2. 不会 ship 任何多余的代码
💡

webpack 等打包器支持设置多个 entry,因此你可以方便地为不同平台打包,而且只需要打包一次。

无论后面对话框需求发生了怎样的变化,我们只需要维护打包入口和适配器的实现,而不需要修改业务代码。比如需要将移动端拆分成 iOS 和 Android 的原生实现,就可以这样做:

// android.ts
const injector = new Injector([
  [
    IDialogService,
    {
      useClass: AndroidDialogAdapter,
    },
  ],
])
 
// ios.ts
const injector = new Injector([
  [
    IDialogService,
    {
      useClass: IOSDialogAdapter,
    },
  ],
])

构造问题的解决。 从 DI 获取依赖项时,不需要手动调用构造函数,因此构造问题也就不存在了。

我们把上面的例子改写为 DI:

class App {
  constructor(
    @Inject(Model) private readonly Model,
    @Inject(Controller) private readonly Controller,
    @Inject(State) private readonly State,
    @Inject(HttpService) private readonly HttpService
  ) {}
}
 
class Controller() {
  constructor(@Inject(Model) private readonly Model) {}
}
 
const injector = new Injector([
  [Model],
  [Controller],
  // ...
])
 
const app = injector.createInstance(App)

通过注入器获取 App 实例时,注入器会自动分析依赖关系,按照顺序构建各个类,并确定构造函数的参数。可以看到改写过后,不仅代码变得十分简洁,App 的职责明显更少了,它并不需要了解 HttpInterceptor 是如何构造的就可以调用 HttpService

关系问题的解决。 从 DI 获取依赖项时,DI 会分析依赖项之间是否存在循环依赖,如果有就会抛出错误:

export class AuthService {
  constructor(@Inject(FileService) private readonly fileService: FileService) {}
 
  public changeAuth(auth) {
    this.fileService.changePermission(auth)
  }
}
 
export class FileService {
  constructor(@Inject(AuthService) private readonly authService: AuthService) {}
 
  public changeFile(file) {
    if (file.locked) {
      this.authService.changeAuth({ editable: false })
    }
  }
}
 
const injector = new Inject([[AuthService], [FileService]])
 
injector.get(FileService) // ERROR!

在大型项目中使用 DI

现在我们已经看到了 DI 模式是如何解决依赖问题的,下面我们进一步讨论如何把 DI 解决依赖问题的能力应用到大型项目中去。

分层架构与 DI

在大型项目中,往往会将各个模块分层规划,在分层架构中,只允许上层模块依赖下层模块,而禁止反方向的依赖。DI 可以很容易地做到这一点。

所有的 DI 工具都允许多个注入器形成多叉树结构,从树中的任意一个节点(一个节点即为一个注入器)获取某个依赖时,如果该注入器无法提供,那么它就会委托父节点获取该依赖,如果一个节点没有父节点(即根节点)且无法获取到该依赖项时,才会抛出错误。

class StorageService {}
 
const parentInjector = new Injector([[StorageService]])
const childInjector = parentInjector.createChild([])
 
childInjector.get(StorageService) // -> 最终从 parentInjector 获取 StorageService

由于这种委托关系只能是从子节点到父节点,因此只需要将下层模块放在父容器中,上层模块放在子容器中,就可以保证下层模块可以被注入到上层模块中,反过来则不可以。

class DataModel {}
 
const parentInjector = new Injector([[DataModel]])
 
class Controller {
  constructor(@Inject(DataModel) private readonly dataModel: DataModel) {}
}
 
const childInjector = parentInjector.createChild([[Controller]])
 
childInjector.get(Controller) // -> Controller

DI 与设计原则

有了 DI 之后,由于在各个类之间建立依赖关系非常容易——只需要声明依赖关系然后将依赖添加到某个注入器中就好了——将一个大的类拆分成小的类的成本就会得变低,因此 DI 会鼓励开发者尽可能地去拆分模块,那么如何进行拆分就会变得尤为关键。

我们可以寻求一些经典 OO 设计原则的帮助:

  • 单一职责原则。一个类应当仅有一个职责,这样才能做到高内聚,才能让能让修改范围可控。由于引入 DI 之后拆分类的成本很低,因此应当将含有过多的职责的类拆分成若干个仅有一个职责的小类,然后通过 DI 在它们之间建立依赖关系;
  • 依赖倒置原则。我们上文已经介绍了如果通过 DI 来注入一个接口而非具体实现,在使用 DI 时应当尽可能地注入接口。
  • 最小接口原则。一个类在调用另一个类的时候,被依赖的类的接口应当最小化,如果暴露的接口越多,两个类之间的耦合性就越高。在通过 DI 注入依赖时,首先应当根据单一职责原则将依赖拆分开,然后尽可能注入少的依赖。如果注入的是一个接口,那么需要对接口进行仔细的设计,避免注入一个很大的接口。
  • 单一真实信息来源。履行一种职责所需要的数据应当存放于一处(比如某个类),其他需要相关数据的模块应该订阅此处数据发生的变化而不是自己维护另一份数据,以保持应用内的数据同步。通过 DI,你可以轻松讲这种类注入到相关模块,让相关模块来订阅它。

DI 与 React

一些前端库具备上下文(Context)功能,允许不通过组件 props 直接传递参数到子孙组件。通过 Context 来传递注入器,就可以在这些库中使用 DI 模式,从而可以做到:

  • 视图与状态和逻辑分离。把业务逻辑逻辑封装到某个类当中,然后注入到组件里面去,组件只负责通过约定的接口从这些类中获取数据并进行渲染,从而做到关注点分离,修改 UI 的时候,不用去考虑业务逻辑,反之亦然。
  • 基于依赖注入的状态管理。基于上面这一点,可以使用 DI 来做应用状态管理,将状态与改变这些状态的方法都封装都某个类中,组件通过 DI 获取到这个类,然后订阅数据的变化,在数据发生变更的时候重新渲染;当用户做出交互时,调用类的方法触发状态变更。相比于 Redux 等类似方案,DI 不需要引入 reducer, side effect, action 等新概念和 redux-saga 等副作用管理工具,更易编写、阅读和调试。
💡

阅读文档以了解如何使用相关 API。

什么时候使用 DI

DI 想要解决的问题十分明确,就是软件中的依赖问题,即依赖的抽象问题、依赖的构造问题、依赖的关系问题,因此,如果软件中有许多不同的模块,模块之间的依赖关系复杂,而你又需要以一种低心智负担的方式来管理这些依赖关系,让各个模块尽量做到高内聚低耦合,确保架构具备一定的灵活性,那么使用 DI 就非常合适。

DI 设计方案的讨论

💡
以下内容和 DI 的实现有关,如果你并不关心,可以停止阅读了。

要不要用 reflect-metadata

DI 已经是一种非常成熟的方案了,因此各个框架的功能和 API 的设计大同小异。而由于 JavaScript 的语言特性(缺乏反射机制),设计一个 JavaScript 语言的 DI 库时,需要特别考虑声明依赖关系的 API,这也成为了各个 DI 库的主要差异。这类 API 的设计,一种是以 InversifyJS 和早期 Angular 为代表,利用 TypeScript 编译器和 reflect-metadata 做编译时依赖的自动推导,以及以 vscode 为代表,让用户手动声明。

第一种方案需要在依赖项上增加一个 Injectable 装饰器:

@Injectable()
class A {
  constructor(private readonly b: B) {}
}

通过 TypeScript 的编译,A 类的原型上会记录下 B 类是 A 的构造函数的第一个参数,注入器在构造 A 时,会去读取相应的记录,并先构造 B。

第二种方案需要用户手动标明哪些参数是需要依赖注入框架负责处理的:

class A {
  constructor(@Inject(B) private readonly b: B) {}
}

这里装饰器会负责记录 B 是 A 的第一个依赖。

看起来如果注入的项目比较多的话,方案一可以让开发者少写一些代码,但是我们认为第二种方案是更优的选择,基于如下这些原因:

  1. 方案一需要 TypeScript 在编译时记录构造函数参数的类型信息,因此无法用在 JavaScript 当中。方案二可以通过一些 babel 的配置用在 JavaScript 当中;
  2. TypeScript 的推导能力较为有限,只能处理注入类这一种情况,对于基于 identifier 的注入,必须要像方案二一样编写,倒不如直接用方案二保持 API 的一致性;
  3. 方案一需要引入 reflect-metadata 库。
  4. 方案二可以允许一些构造参数不由依赖注入库传入而是由开发者自行传入,因此可以构建一些不需要加入依赖注入体系的小类;
  5. 通过配置编辑器的 snippet,方案二并不会比方案一多写代码。

这份清单 (opens in a new tab)通过实现原理列出了一些开源的 DI 实现,可供参考。