前端中的依赖注入
软件开发最富有挑战性也最有趣的一项任务就是如何化繁为简。
在保证业务需求得到满足的前提下,我们(软件工程师们)需要确保软件的可用性、可扩展性、性能、一致性、容错性、稳定性、可重用性等等非功能特性,这决定了软件天生是复杂的。但是人的思维能力是有限的,我们没法兼顾这么多的方面,因此,我们借助“抽象”思想来帮助我们设计软件的架构,征服软件的复杂性。
抽象思想给开发者的武器库里增加了“分层”,“函数、类、模块”,“客户端-服务端”这样的思维工具,开发者们将软件抽象成各个组成部分,然后确定它们内部的工作原理和相互之间的协作方式,这样就能通过一次解决一个小问题,最终解决一个大问题。进一步地,为了更好地做“将软件划分成各个组成部分”这件事,业界提出了各种各样的设计原则和设计方法:职责驱动设计、SOLID 原则、GRASP 原则、高内聚低耦合等。但是这样的原则和设计方法可能还是不够具体,因此设计模式被提出了,最著名的就是 GoF 的 23 种设计模式,这样我们实际编写代码时就能够按图索骥了。
依赖注入是最近几年在前端领域用得越来越多的一种设计模式,本文的目的就是要介绍这种模式如何贯彻抽象思想并反映面向对象设计原则的,还会讨论依赖注入模式本身的特性,以及如何在项目中运用它。
我们先来看一个例子,然后探究大型项目中常见的依赖管理问题,接着介绍 依赖注入(Dependency injection, DI) 模式,最后介绍如何在大型应用和 React 项目中使用 DI。
从构造一个类说起
我们在使用一个类之前需要调用它的构造函数。假设 FindReplaceController
依赖 ShortcutService
,可编写如下代码:
export class FindReplaceController extends RxDisposable {
constructor() {
this._shortcutService = new ShortcutService(
commandService,
platformService,
contextService,
layoutService,
);
}
}
这一段代码看似简单,但是暗藏玄机:
如何获取 ShortcutService
所需的参数,即 commandService
等?
这些参数大概率不是构造方(此例中即 FindReplaceService
)所持有的变量,因此构造方得先想办法获取到参数。换句话说 ShortcutService
如何构造的细节被暴露给了 FindReplaceController
。
一种常见的解决方案是把这些构造参再通过调用方的构造参数传递过来:
export class FindReplaceController extends RxDisposable {
constructor(
commandService: ICommandService,
platformService: IPlatformService,
contextService: IContextService,
layoutService: ILayoutService,
) {
this._shortcutService = new ShortcutService(
commandService,
platformService,
contextService,
layoutService,
);
}
}
这样导致了新的问题:如果我们要修改 ShortcutService
的构造参数,我们必须同时修改 FindReplaceController
。
如果我们不在 FindReplaceController
里构造 ShortcutService
而是直接将它作为 FindReplaceController
的参数传入,问题是否能够得到解决呢?
export class FindReplaceController extends RxDisposable {
constructor(private readonly _shortcutService: IShortcutService) {}
}
答案是并不能,这样只是将耦合的两者从 FindReplaceController
和 ShortcutService
变为 ShortcutService
和另一个模块(例如下面的 SomeModule
)而已,总有一个模块必须要负责构建 ShortcutService
。更糟糕的是, FindReplaceController
还依赖其他的模块,如果 SomeModule
还要负责构建其他依赖,它的构造参数就会更加地多,代码也就会越来越难以维护。
export class SomeModule {
constructor(
commandService: ICommandService,
platformService: IPlatformService,
contextService: IContextService,
layoutService: ILayoutService,
) {
this._findReplaceController = new FindReplaceController(
new ShortcutService(commandService, platformService, contextService, layoutService);
);
}
}
但是对于 FindReplaceController
而言,此时它确实不用再关心 ShortcutService
是如何构建的,换句话说,对于 FindReplaceController
,ShortcutService
的构造和使用是解耦的。这一解耦非常重要,它是依赖注入的核心:使用方只需声明要使用哪些依赖,而不必关心如何构建这些依赖。
大型项目中的依赖问题
场景一
这个问题是上一个问题在大型项目中的延伸。实例化整个应用时,通常的做法是创建一个类作为应用的入口,递归地构造整个应用的依赖关系。例如:
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()
这种做法的问题上面已经提到,另外还存在如下问题:
- 难以理清模块的依赖关系。模块越多,我们就需要花越多的精力去调配它们初始化的先后顺序,手动传递构造函数需要的参数等等;
- 这种写法违背了最少知识原则,提高了模块之间的耦合性。在这个例子中
App
为了调用HttpService
需要知道AuthInterceptor
是如何构造的,如果AuthInterceptor
的构造函数新增了一个参数,那么App
的代码也必须对应做出修改。实际上,所有模块的构造参数信息都暴露给了App
。 - 这种写法还导致很难隔离出一个模块单独进行测试。假设要测试上文中的
HttpService
,就必须把AuthInterceptor
State
Model
等等全部构造一遍,也不得不根据这些依赖的行为来编写用例。
场景二
对于一个大型应用,某个功能在不同的环境上有不同的表现。例如对于“弹出对话框”这个功能,通常可能会下面的代码:
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 包里面,然后让这个 npm 包来考虑跨平台的事情就可以了,确实如此,但是修改代码的麻烦还只是这个问题的一个比较小的方面,另外一个方面是:如果每个平台差异问题都要通过写 if else
来处理,你的打包产物中就会有很多套不同的实现,而很多代码实际并不会在用户的设备上运行。
一种可能的方案是:在构建的时候通过 webpack alias 切换 mobileDialog
实际引用的 npm 包,对不同平台分别构建一次就可以了,但这种方案会极大地增加 CI 时间。
虽然我们早就知道产品经理频繁变更需求才是导致代码质量降低的罪魁祸首(开个玩笑),但回头想想:这里的一开始的设计是不是就有问题?没错,问题的本质就在于 OrderService
(还有其他写了类似代码的类)不够内聚,它耦合了“应该弹哪种对话框“的逻辑。如何让这个类更加内聚呢?应该把“弹对话框“这件事交给一个专门的模块来处理,然后依赖它。前面所述的封装 npm 包一个确实是一种解决途径,但我们后面会看到依赖注入模式给出了一种更加灵活的解决方案。
场景三
当应用当中的模块多了之后,模块之间的依赖关系就会变得越来越复杂,越来越难管理。
首先是依赖关系的建立会逐渐地失去控制。通常来说在模块之间建立依赖关系有下面几种做法:
- 通过构造函数传入依赖
- 通过类的静态方法或成员获取实例,例如
AuthService.INSTANCE
- 通过一些全局可访问的对象获取实例,例如
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,依赖反转原则)告诉我们:
高层不应当依赖低层,两者都应该依赖抽象。
那么如何才能把不同的底层实现传递给高层?
依赖关系问题。
复杂的依赖关系容易导致循环依赖,而循环依赖是导致软件行为不可控的很大的风险因素。所以对于关系问题我们需要解答:如何才能避免代码中形成循环依赖?
DI 对以上三个问题提出了系统的解决方案。
DI 的基本概念
本小节的内容涉及 redi 的 API,请参考阅 这里的文档
使用 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:
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 可以这么写:
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
中注册了哪种类型的实现。
通过在不同的打包入口提供不同的实现,就可以同时做到:
- 业务代码依赖于抽象的
IDialogService
接口而不是具体实现 - 不会 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 会分析依赖项之间是否存在循环依赖,如果有就会抛出错误:
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 等副作用管理工具,更易编写、阅读和调试。
结论:什么时候使用 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 的第一个依赖。
看起来如果注入的项目比较多的话,方案一可以让开发者少写一些代码,但是我们认为第二种方案是更优的选择,基于如下这些原因:
- 方案一需要 TypeScript 在编译时记录构造函数参数的类型信息,因此无法用在 JavaScript 当中。方案二可以通过一些 babel 的配置用在 JavaScript 当中;
- TypeScript 的推导能力较为有限,只能处理注入类这一种情况,对于基于 identifier 的注入,必须要像方案二一样编写,倒不如直接用方案二保持 API 的一致性;
- 方案一需要引入 reflect-metadata 库。
- 方案二可以允许一些构造参数不由依赖注入库传入而是由开发者自行传入,因此可以构建一些不需要加入依赖注入体系的小类;
- 通过配置编辑器的 snippet,方案二并不会比方案一多写代码。
这份清单通过实现原理列出了一些开源的 DI 实现,可供参考。