什么是依赖注入
依赖注入(Dependency Ineject)是 IOC 思想的一种实现。它通过面向接口编程,将直接依赖,转为间接依赖,使得被依赖的类型实现,在发生变化的时候,只需要在 DI 容器中变更接口与具体实现的关系即可,而不用去变更每一个应用依赖的地方。
这里我们用经典的 Car 与 Engine 举例,在不使用 DI 的时候,我们的 Car 需要在内部创建一个新的 Engine 实例,类图如下。

我们用代码来实现它
// Engine Interfaceinterface Engine{drive():void}// 电力引擎class ElectricEngine implements Engine{drive(){ console.log("电力引擎")}}// Car Interfaceinterface Car{drive()}// 特斯拉class Tesla implements Car{constructor(){this.engine = new ElectricEngine();}private readonly engine:ElectricEngine;driver(){this.engine.drive()}}
在这个实现里,我们为特斯拉安装了一个用电的引擎,随着后续的修改,如果出现了更清洁的能源,就需要更换新的引擎,此时我们就要回来 重新为特斯拉 new 一个新的引擎,这违反了开闭原则,当我们有多个新能源汽车实现类的时候,我们不得不挨个打开他们,去修改构造函数中引擎的实例。
现在我们来看看应用了依赖反转思想的实现,这里我们要说一下,依赖注入的方式,有两种,一种是通过属性的方式进行注入,而另一种则是通过构造函数进行注入,他们看起来像这样
// 通过构造函数注入class ByConstructor{constructor(private readonly service:Service){}}// 通过属性注入class ByProperty{public service:Service;}
嗯 依赖注入的本质就是传参,把本应该由自己构造的对象,由外部传进来,实现解耦。下边我们来看看车的 依赖注入版本

我们用代码来实现它
// Engine Interfaceinterface Engine{drive():void}// 电力引擎class ElectricEngine implements Engine{drive(){ console.log("电力引擎")}}// Car Interfaceinterface Car{drive()}// 特斯拉class Tesla implements Car{constructor(engine:Engine){this.engine = engine;}private readonly engine:Engine;drive(){this.engine.drive()}}// 使用的时候const tesla:Car = new Tesla(new ElectricEngine());tesla.drive()
到目前为止,我们看到的除了 Engine 提供的实际延迟到了构造 Car 的时候,并没有什么优势,反而增加了代码量,并且在 Engine 有新的实现的时候,还要再每一个 创建 Car 的地方都要重新修改。但是此时我们的 Tesla 实现类,已经是可以测试的了。为什么呢?因为我们可以再测试的时候,为 Car 提供一个我们自己设定好的,可控的 Mock Engine。
另一个构造函数进行传参的问题,则是 DI 框架要为我们解决的了,它解决了接口与实现类之间的映射关系,也解决了如何在构造一个实例的同时,如何 构造相关依赖的问题。
Angular 中的依赖注入
在 Angular 中,为我们提供了如下几个类/函数,帮助我们处理依赖注入的问题
- @Injectable() 一个装饰器 用于标记一个类是可被注入的,这个类一定是个实现类
- Injector 注入器,它维护了相关依赖的缓存,以及 接口与实现的 关联 Map
- @Inject() 一个装饰器,用于指定从 Injector 中获取哪个接口的实现
还用我们刚刚的 Car 举例子,由于 JS 中没有接口的概念,因此我们替换成抽象类,它在 Angular 中的实现是这样的。
// Engine Interfaceabstract class Engine{abstract drive();}// 可被注入的Engine实现@Injectable()class ElectricEngine implements Engine{drive(){ console.log ("电力引擎"); }}// Car Interfaceabstract class Car{abstrct drive()}// 可被注入的Car实现@Injectable()class Tesla implements Car{constructor(engine:Engine){this.engine=engine;}private engine:Enginedrive(){ this.engine.drive();}}
到这里为止,我们只是提供了 2 个可以注入的实现,但是没有维护他们和接口之间的关系。如何进行维护呢?在 Angular 中,NgModule,Component,Directive 都为我们提供了 Providers,我们现在只关心在 NgModule 中的 Providers。它的应用方式如下。
@NgModule({providers:[{provide:Engine,useClass:ElectricEngine},{provide:Car,useClass:Tesla},]})export class AppComponent{}
在这里我们注册好了基于类的提供者,Angular 的 Injector(注入器),会帮我们创建它们的单例,并进行缓存。同样的,我们的 Car 现在也是可被注入的,下面我们在 AppComonent 里注入我们的 Car
@Component({template:`<h1>一个Car的依赖注入Demo</h1>`,})export class AppComponent{constructor(car:Car){car.drive();}}
在这里,我们可以看到最终的输出 “电力引擎”,这表示注入器已经帮我们构建好了 ElectricEngine 与 Car 的实例,并且将 EletricEngine 实例注入到了 Car 中。
依赖注入的特性
依赖注入是懒惰的
我们在 NgModule 的 Providers 属性中,虽然提供了 Car 和 Engine 的实现,但是如果代码中并没有对 Car 的依赖的情况下,注入器不会为我们创建 Car 和 Engine 的实现
被注入的实例都是单例
在每一个 Injector(注入器)中,接口的实例都是单例的,会被缓存在 Injector 中。有时候我们可能会看到多个实例,这是因为 Injector 可能会存在多个。
Injector 是棵树
刚刚我们说过,Injector 可能有多个,这是因为每一个懒加载的 NgModule,每一个 Component,都会有一个新的 Injector 实例,他们会设置上一级的 Injector 为父级 Injector,形成树形结构。
为什么 Injector 是树形结构呢? 因为 Angular 的 DI 支持向上递归查找依赖。
比如当前的注入器中没有某个 Service 的实现,那么我就会往上找,直到找到某一级的实现并返回,或者找到头也没有,就直接返回 NullInjector 了。
然而 Injector 不但是棵树,而且它还是两颗树,一颗树的根叫 ModuleInjector,另一个棵树,叫 ElementInjector,我们一会会在后边分析他们。他们这俩树 大概长这样。

我们在查找依赖的时候,会优先从 ElementInjector 树中进行寻找,如果没有,则会转到隔壁 ModuleInjector 中进行查找,要是都没有,就返回个 NullInjector,如果当前不是可选的依赖的话,就会报错。
ModuleInjector 与 ElementInjector
ModuleInjector,顾名思义,就是 NgModule 为我们创建的 Injector。这个 Injector 树比较简单,只有在懒加载模块中,才会产生新的子注入器实例。
ElementInjector,是为我们的组件提供的,它挂在组件所在 Module 的 ModuleInjector 下边。无论是否提供 Provider,我们的组件都会为我们生成一个 ElementInjector,并且将组件自身实例注入进去。 所以当我们在组件的 Providers 里提供实现类的时候,每一个组件都会产生一个新的实例。
他们实际看起来像是这样

Provider
provider,是依赖注入的提供者,它支持一下几种方式提供实现
- useValue 直接使用一个值
- useClass 使用一个可被注入的类,在存在依赖的时候,会创建该类的单例
- useFactory 提供一个工厂方法,用于在运行时动态产生实例
- useExisting 使用一个已存在的实例,这个实例仅限于当前 NgModule 的 providers 中
它的 provider 要求是一个类/抽象类/InjectionToken,不能使用 interface,number,string 这些,是因为要么 JS 中没有,要么不能确认唯一。
他们的使用方式如下
// Injectokenconst A_VALUE = new InjectionToken("a_value");const A_EXIST = new InjectionToken("a_exist");@NgModule({providers:[{provide:Engine,useClass:ElectricEngine},{provide:A_VALUE,useValue:1},{provide:Car,useFctory:(engine:Engine)=>{if(engine instanceof ElectricEngine){return ()=> new Car(engine);} else{return ()=> null}},deps:[Engine]}{provide:A_EXIST,useExist:forwardRef(()=>Engine)},]})export class AppComponent{}
我们在使用 useExisting 的时候,通常会在前面加一个 forwardRef() 这是为了避免存在依赖的循环引用,比如 A 引用了 B,B 又引用了 A 这种情况。
限定符
Angular 为我们提供如下几种限定符,他们决定查找依赖的方式
- @Optional()
- @Self()
- @SkipSelf()
- @Host()
@Optional()
这个限定符比较简单,表示遇到 NullInjector 不报错,只是把依赖设置为可选的(空值)
@Self()
self 限定符,表示只在自己这里找依赖,找不到就是 NullInjector。
在 ModuleInjector 中,它表示的是当前 ModuleInjector 实例,在 ElementInjector 中同理。
因此在 Service 中的依赖,@Self()会限制在当前的 ModuleInjector 进行查找,而在组件中的依赖,则会限制在当前组件的 ElementInjector 中进行查找。
@SkipSelf()
正好与 self 相反,表示从父 Injector 开始进行查找,工作原理是一致的
@Host()
在 ModuleInjector 中,宿主就是自己的 ModuleInjector。
在 ElementInjector 中 组件和组件中的指令,公用一个宿主 ElementInjector,这个 ElementInjector 是由组件创建的。
默认情况组件的上一个宿主,是根 ElementInjector,也就是最外层提供了 ElementInjector 的组件。
但是如果这个组件在投影中的时候,它的宿主,会变成提供投影的组件的 ElementInjector。
在只使用@Host()时,我们得到的结果与@Self 是类似的,但是我们在组合@Host()与@SkipSelf()时,结果就要遵循上边的规则。
这里我们提供了一个 Stackblitz 示例 方便大家直观的观察在组件嵌套与组件投影情况下限定符的效果。
依赖注入的应用
有了依赖注入,我们可以在通过提供新的 provide 的方式,去更换某些已有的实现,最常见的应用就是我们的 RouterRueseStartegy,HttpInterceptor 等。
同时在应用过程中,也会带来一些新的问题,下面我们来探讨下可能会遇到的问题。
依赖注入的查找方式带来的坑
上边我们说过,依赖注入会逐层递归查找,如果找到了 provider,会优先返回缓存,再考虑构造新的实例。因此在实际应用中,会出现一些我们在子模块提供了新的 provide,但是却不生效的问题,例如之前的 HTTP_INTERCEPTORS。因此我们需要为子 Injector 重新提供 providers,以避免它从父级 Injector 直接取缓存。
今天的文章到这里,后续的文章会写一下 Injector 的高级用法,敬请期待。
