作者介绍
Arno(青楠),专有钉钉前端成员,工具和效率的狂热追求者 🤩,目前负责专有钉钉协同产品体系的前端研发。
缘一,专有钉钉前端团队成员,负责专有钉钉 PC 客户端、端上应用、端上模块插件化的开发。
背景
以 Web 技术为中心的前端技术,在近几年发展尤为迅猛,诞生了诸多如 Typescript、Angular 等前端技术,支撑了 VSCode 这类史诗级项目的诞生。在这些大型工程 / 项目中,架构师们为了让项目在如此大规模的协同下,依旧能够有效控制复杂度。他们在这些工程中深度实践了面向对象(OO)的编程范式,其中 控制反转(Inversion of Control,后文简称 IoC)以及 依赖注入(Dependency Injection,后文简称 DI),这两种技术手段被大量使用。
本文希望通过前端视角,以 Typescript 作为编程语言,谈谈如何使用 IoC 和 DI 等机制,让大型的前端项目在解决代码依赖、复用和扩展的时候,轻松自如,游刃有余。
什么是控制反转
首先,我们来聊一聊什么是控制反转。假设我们是一家造车的企业,我们有自己的汽车零部件供应商,早期我们和我们的供应商深度合作共创,建立了汽车引擎的依赖关系:
1 | import { V8Engine } from 'vendor'; |
这样咋一看没啥问题,但随着时间的推移,我们希望用更新的引擎,比如 V9
来更换存量的引擎。这个时候,我们就会发现,如果我们各种车型都直接依赖了 vendor
提供的 V8Engine
,这种紧耦合的关系,在遇到变化的时候,是非常不灵活的,牵一发而动全身。为了解决这个问题,我们便引用了 IoC 的思想,让我们不直接依赖 V8Engine
的具体设计,而是依赖一个 Engine
的抽象设计,即:发动机引擎的标准。
落到程序设计的语境下,面向对象程序设计(OO Design)中有一个很重要的思想:面向接口编程 (Interface Oriented) ,接口描述了类等抽象结构应当遵循的标准规范。因此我们对代码做一次升级,让实现依赖接口而非别的具体实现。
首先,我们定义出能够描述 Engine 规格的抽象接口:
1 | // standard-engine-interface.ts |
接下来,供应商只需要按照引擎接口实现出具体的引擎即可,我们按照 IEngine
对引擎类的抽象,实现必要的接口:
1 | import { IEngine } from 'standard-engine-interface'; |
接下来,同一种车型,我们就可以尝试装配不同的引擎了:
1 | import { IEngine } from 'standard-engine-interface'; |
这便是面向对象程序设计中,经典的控制反转(IoC)原则:
上层模块不应该依赖于下层模块,他们共同依赖于一个抽象,抽象不能够依赖于具体 ,具体必须依赖于抽象。
我们的造车工厂,不再具体依赖引擎的型号去装配,而是依赖引擎的设计标准。程序设计也是如此,实现不再依赖具体的实现,而是依赖抽象的接口,以至于在实例发生变化的时候,上层模块可以无感替换下层的模块,这样岂不美哉?再补一张 UML 结构示意图,就更能够清晰描述这样的设计在 OO 设计中结构的变化:
Before:CarA
与 V8Engine
类是紧耦合关系(直接耦合)。
After:CarA
和 V8Engine
以及 V9Engine
,共同依赖一个抽象接口,即 IEngine
。
这样做的好处在于未来的 Engine 类型,只要实现 IEngine 要求的接口,便可以兼容 CarA,CarA 也可以做到无感知替换具体的 Engine 类型。
什么是依赖注入
理解了 IoC,DI 也就更容易理解了。
我们继续以造车为例,来讲述 IoC 和 DI 这对「海尔兄弟」是如何配合好,解决实际生产中的问题的。
设想在这个造车过程中,引擎(Engine)还会依赖发动机的轴承(Bearing),而轴承的规格也有许多种,因此当我们在生产一辆汽车(Car)的时候,我们不仅仅要上游制造出合理的引擎,上游的供应商还需要向它的上游索要依赖的轴承,轴承还需要向它的上游索要轴承的材料 … 为此会形成一个复杂的生产关系,在程序设计中,我们叫它依赖网络(Dependency Network)。
如果用代码做示意那便是:
1 | const car = new CarA(new V8Engine(new Bearing(new BearingMaterial(), ... ))); |
依赖注入主要意在帮我们系统性地分析依赖网络,将一个对象或者一个类的生产过程做了封装简化。依赖注入的框架也便是名副其实的是类或者对象的 生产工厂。
一个简单的 DI 系统,能够在程序的运行时中动态的解析出调用方需要的对象或者其它数据类型,在强类型的语言设计中,这是一种解耦合的重要实现方式。
如果我们使用 TS 来表示的话,就好比:
1 | // 依赖的提供方可以通过 bindDependency 方法提供引擎和轴承等等的构造函数(类) |
我们将组装的过程做了封装,只需要一个轻松的 get
函数,就实现了将汽车零部件进行装配和组装,这个动态组合组装的过程(对象依赖网络实例化的过程),便是依赖注入的过程。
我们再展开细一点:
-
'engine'
和'car'
以及'bearing'
这种标识符,我们一般叫做 依赖注入 Token,它们一般会被设计成全局唯一,用于标注唯一的类型。 -
bindDependency
函数,我们称之为 依赖绑定,即将类型实现(类、对象等)和控制反转容器(IoCContainer
,部分框架中也叫做Injector
依赖注入器)做关联。 -
get
函数,则是一个对象析出(分析依赖关系,组装生成对象)的过程,依赖注入框架会分析当前索要的依赖注入 Token 对应的类型,然后递归地分析它的依赖,最终实例化这些对象,完成最终对象的拼装。
业界比较著名的控制反转框架,以 Inversify 为典型。
早期笔者分析了 Inversify 5.0 的实现原理,这里就不展开了,如果对依赖注入框架比较感兴趣可以阅读 这篇文章 深入,框架的实现中关键的类使用 UML 表示参考如下,可形成一种初步的印象。
图:Inversify 框架中主要的类和对象关系
最佳实践の探索
理解了 DI 和 IoC,那么接下来我们一起来结合实际的案例,尝试在前端项目中找到 DI 和 IoC 目前的最佳实践,尤其在程序结构设计和工程上找到一种设计平衡。
Angular 的实现机制
该小节,对 Angular 不太熟悉的同学来说,可能会比较晦涩,可以跳到下一节阅读。
笔者一直觉得 Angular 是一个最适合构筑复杂 Web UI 系统的框架。它里面定义了一个非常完备的模块化(Modular)以及 DI 系统,尤其适合 OO 经验比较丰富的前端团队。以 Angular 12 定义的几个关键类型为例:
Angular 它的主要类型分为了上面的结构。可以看到服务(Service)作为 Angular 依赖注入系统的提供者(Provider),通过 @injectable()
装饰器注入到 Angular 的 Injector
(依赖注入器) 中。而在 NgModule / Directive / Component 中可以通过声明式的方式将服务在类的装饰器(Decorator)上提供。Angular 的运行时实例化这些对象的时候,会使用内部的 Injector
析出符合配置要求的对象,在构造器函数中注入。
1 | import { Injectable } from '@angular/core'; |
其 DI 形式颇为优雅 😎 ~
笔者曾经就 Angular 12 的 DI 系统源代码做了简单分析,感兴趣的同学可以移步 这篇文章 深入了解其设计和实现思路。
基于 Inversify 打造 DI 业务框架
看完了 Angular 系统的设计,像阿里作为 React 大厂,如果期望在业务中实现一套类似的 DI 系统做好 IoC,又应该怎么去完成呢?笔者在这里,给出一些实战性的思考,也抛砖引玉,和大家一起探讨最佳的实践范式。
假定读者:
- 熟悉 Typescript,并熟悉 Typescript Decorator ,并开启相关的配置
- 了解了基本的 Inversify 框架的 API。
1. 使用装饰器实现依赖注入系统
💍 Decorators to rule them all !
得益于 Java 的 注解系统(Annotation)的设计,Typescript 给出了类似的 Decorator 方法,用于对类、类的构造器参数、属性等等,通过装饰器的方式,以清晰易懂,且侵入程度较低的方式实现了 DI 系统的要求,我们继续使用造车为例看一下 Inversify 的基础 API 使用:
1 | import { Container, injectable, inject } from "inversify"; |
我们可以看到整个 DI 过程通过 @injectable()
完成了 DI 框架的轻量侵入,使得编写及其少量的代码,就能够和框架产生关联。
2. 对 IoC 容器的更进一步封装
🚀 Inversify 本身是一个好框架,但是各种花里胡哨的 API 使用,也给业务层带来更高的认知成本,我们可以考虑巧妙地封装部分方法,提供给上层使用,以降低「认知成本」。Inversify 还是有一定的学习成本的,光 API 就超过了 40+,直接使用对于新手和小白用户非常不友好,以 👇 的文档为例,理解 Inversify 的使用,至少要通读下面的文章,形成理解。
Inversify 对提供了比较原始版本的 IoC 容器,它提供了最原子化的功能。我们其实可以对它做进一步封装,来让使用者更好地使用,而不用关心其实现细节。
这里举几个可行的设计规则:
- 业务依赖注入框架中,只能够析出对象类型(单一类型析出,可以降低大量的理解成本,其实对象就已经可以 Cover 住 99% 的设计了,可以杜绝复杂的 API 使用带来的认知成本)
- 将
bind
的过程,在业务框架中自动化掉,开发者不用关心bind
过程 - 在 IoC 容器层提供
get
过程中的稳定性保证,以至于解析出错的时候有 fallback (降级)的方案 - 在 IoC 容器层内置统计
IoC
析出的性能分析插件,可以对高性能操作进行高静 - 在 IoC 容器层提配置式的方式,来封装对 Inversify 的 API,让业务层框架使用者根本不用关心 Inversify 底层容器的实现
- …
IoC 容器在业务层可以做很多事情,来屏蔽底层的复杂度,对业务开发提供简单的解决方案。对于使用者而言,他们只需要知道有 get
方法即可。
1 | // di-framework.ts |
3. 依赖注入 Token 优化
通过业务框架指定 token 的设计规则,我们可以简化依赖注入 token 带来的认知复杂度,为此我们可以做如下的规则限制:
- Token 使用字符串保证唯一性,比如:
ninja
保证是忍者类型的全局唯一性 - Token 可以是实现了
toString
方法的任何对象,该规则可以让后续依赖注入的解析 Token 和后续面向接口的设计整合在一起
1 | // tokens.ts |
后续我们可以让 Engine 和 Car 等通过 createServiceIdentifier
这个 Token 的工厂函数,加工,实现 toString()
的方法,同时有可以承当具备「语义」的功能型 Decorator,稍后在最终效果中,可以看到其优雅地实现方式。
4. 封装 Provide 装饰器
为了进一步收敛装饰器,我们对用户提供 @provide
装饰器,用于提供类或者对象。
1 | // impl.ts |
在不同的文件中使用 @provide
装饰器,提供不同的类来绑定 Engine
Token,即可以实现对汽车引擎的无感知替换,只要实现了 IEngine
的引擎均可以。
5. 实现消费依赖关系
在 MyCar
类 Constructor 中,我们可以直接在类构造函数中注入引擎和轮胎:
1 | // car.impl.ts |
可以看到,上述过程,将复杂的 Inversify 框架的用法,凝练为:
createServiceIdentifier
创建依赖注入的唯一 Token,同时也是构造器参数的装饰器(两用对象,具备语义)@provide(ServiceIdentifier)
提供依赖注入的服务,并自动将 Token 和装饰的类做绑定@ServiceIdentifier
具备装饰语义的构造器参数的装饰器,在类的构造函数中表示依赖注入关系container.get
获取依赖分析,析出需要的对象,完成整个 DI 过程
促使使用者(业务开发者)只需要掌握 4 个 API 就可以完成主要的 DI 系统设计,整体简化了依赖注入的理解成本,同时兼顾 DI 风格的优雅性。可以在实战过程中通过较低的成本封装 Inversify 实现。当然对于本身需求就比较简单的,也可以手撸一个简单版本的 Inversify 或者使用其它业界开源的 DI 框架辅助实现。
小结
聊了这么多,相信大家对 DI & IoC 应该会有更深层次的理解吧,我相信随着未来前端技术的规模化发展,Web 应用必然会引入更多的复杂性,当大家面对自己的模块,在设计其模块化系统、依赖系统、扩展系统的时候,一定会发现 DI 和 IoC 的设计机制会在这里大放光彩!
参考
- IoC wiki & DI wiki,维基百科官方定义
- Angular DI 系统的设计和实现分析:Angular 官方文档
- Inversify 框架介绍和使用指南(中文)
- Inversify 框架设计思考 by @Arno(surfacew) 个人思考
作者:专有钉钉前端团队
链接:https://juejin.cn/post/7046927021028409351