14
头图

本文由 Worktile 产品研发部负责人 @徐海峰 分享

依赖注入是前端开发者也是 Angular 开发者一道很难迈过去的坎,依赖注入到底是啥?为什么要依赖注入?Angular 的依赖注入怎么有那么多概念,看了官方文档一遍后感觉是懂了,但是过一段时间发现又不懂了,这是前端开发者普遍遇到的问题,我司的前端也一样,那么这篇文章尝试用更容易理解的语言全面解析一下 Angular 的依赖注入,内容有点多,可以先收藏观看。

Angular 官方文档关于依赖注入介绍的其实挺详细的,但是组织的语言过于官方,不易理解,其次就是文档太过分散,没有把依赖注入聚合在一起,有些核心的概念可能在示例中出现了解读,你很难在一个地方找到所有依赖注入的讲解。
在开始之前,我简单列了一些关于 Angular 依赖注入的问题,如果你每个问题都非常了解可以划过这篇文章了。
  1. 依赖注入和控制反转(Ioc)的区别是什么?
  2. providedIn: 'root' ​ 的作用是什么,指定 root 的好处有哪些?
  3. providedIn ​除了 root 外还可以设置哪些值?
  4. 构造函数注入 constructor(heroService: HeroService) ​ 是依赖注入的简写,那么完整的写法是什么?
  5. 依赖注入可以注入一个接口吗? constructor(heroService: IHeroService) ​为什么?
  6. useClass ​ 和 useExisting ​ 提供者的区别是什么?
  7. providers: [ Logger ] ​ 这种写法 Logger ​使用的是什么类型的供应商?
  8. DI Token 可以是字符串吗?如果可以如何注入?
  9. providers ​ 与 viewProviders ​的区别是什么?
  10. InjectableInject ​ 、 Injector ​ 和 Provider ​ 这些名词到底是什么?
  11. ReflectiveInjector ​ 和 StaticInjector ​的区别是什么?为什么 Angular 在 V5 版本废弃了 ReflectiveInjector ​API?
  12. 懒加载模块中的供应商和 AppModule ​中提供的供应商有什么区别?

简单一张思维导图完整覆盖这篇文章的内容:

image.png

前言

控制反转和依赖注入

那么在开始正题之前,肯定要先理解依赖注入的概念,如果已经理解的同学可以忽略这一章节,一提起依赖注入,大家就会和控制反转联系在一起,我在看过很多文章之后发现知乎上的一个回答 Spring IoC有什么好处呢? 介绍的特别详细和易懂,大家不了解的可以阅读一下,简单总结一下就是:

软件只有到达了一定的复杂度才会需要各种设计原则和模式,那么依赖倒置原则(Dependency Inversion Principle )就是为了解决软件模块之间的耦合性提出的一种思想,让大型软件变的可维护,高层模块不应该依赖低层模块,两者都应该依赖其抽象,抽象不应该依赖细节,细节应该依赖抽象。那么控制反转(Inversion of Control) 就是依赖倒置原则的一种代码设计思路,具体采用的方法就是所谓的依赖注入(Dependency Injection),通过依赖注入实现控制权的反转,除了依赖注入外,还有可以通过模板方法模式实现控制反转,那么所谓依赖注入,就是把底层类作为参数传入上层类,实现上层类对下层类的“控制”。

image.png

以下是一个通过构造函数注入的示例,那么除了构造函数注入外,还会有 setter 注入和接口注入。

class Logger {
    log(message: string) {}
}
class HeroesService {
    constructor(logger: Logger) {}
}

const logger = new Logger();
const heroesService = new HeroesService(logger);

通过上述示例发现, HeroesService ​ 不直接创建 Logger ​ 类的实例,统一在外层创建后通过构造函数好传入 HeroesService ​如果我们的类成千上万,那么实例化类的工作变得相当繁琐,会有一大推样板代码,为了管理创建依赖工作,一般会使用 控制反转容器(IoC Container) 进行管理。只需要通过如下一行代码即可实现 HeroesService ​ 的创建, IocContainer ​ 会通过 HeroesService ​的构造函数寻找 Logger ​ 的依赖并实例化。

const heroesService = IocContainer.get(HeroesService);

如果类很多,依赖层级比较深,那么 IocContainer 会帮我们统一管理依赖, IocContainer ​ 其实也叫注入器 Injector ​, 说的其实就是一回事,Angular 框架中叫 Injector ​。
image.png
关于控制反转和依赖注入更多参考:
Inversion of Control vs Dependency Injection
Wikipedia Dependency Injection

依赖注入的优势:

  • 更容易维护
  • 协同合作
  • 方便单元测试
  • 松耦合
  • 减少了样板代(Ioc容器/注入器维护管理依赖)
  • 扩展应用程序变得更加容易

依赖注入的缺点:

  • 学习起来有点复杂
  • 阅读代码变得抽象
  • 依赖注入框架是通过反射或动态编程实现,导致IDE对“查找引用”,“显示调用层次结构”和安全重构变得困难
  • 编译时错误被推送到运行时

为什么 Angular 有依赖注入

那么 Angular 为什么会有依赖注入?
前面我已经说过,只有程序到达一定的复杂度,才会需要各种设计模式和原则等工程化方法提升程序的可维护性,那么 Angular.js 起初是为了解决谷歌内部复杂中大型的前端应用,同时是一批 Java 程序打造的,所以首次在前端中大胆引入了依赖注入,那么 Angular 是基于 Angular.js 打造的新一代前端框架,所以延续了依赖注入特性,并改善了层级注入器,同时采用了更优雅的装饰器 API 形式。

服务和依赖注入的关系
另外 Angular 为了解决数据共享和逻辑复用问题,引入了服务的概念,服务简单理解就是一个带有特性功能的类,Angular 提倡把与视图无关的逻辑抽取到服务中,这样可以让组件类更加精简、高效,组件的工作只管用户体验,把业务逻辑相关功能(比如:从服务器获取数据,验证用户输入或直接往控制台中写日志等)委托给各种服务,最后通过 Angular 的依赖注入,这些带有特定功能的服务类可以被任何组件注入并使用。
Angular 依赖注入: 连接服务的桥梁,在需要的地方(组件/指令/其他服务)通过构造函数注入依赖的服务,依赖注入 + 服务的组合造就了使用 Angular 可以轻松组织复杂应用。

那么其他框架 React 和 Vue 有依赖注入吗?
可以说有,也可以说没有,React 为了解决全局数据的共享问题,提出了 Context,那么创建好 Context 后需要在上层组件通过 <MyContext.Provider value={/* 某个值 */}> ​ 提供依赖值,然后在任何的子组件中通过 <MyContext.Consumer> ​ 进行消费(Vue 中也有类似的 provide ​和 inject ​),其实这可以狭隘的理解成最简单的依赖注入,只不过 Context 只解决了数据共享的问题,虽然也可以作为逻辑复用,但是官方不推荐,React 官方先后推出 Mixin、高阶组件、Render Props 以及最新的 Hooks 用来解决逻辑复用问题。

<MyContext.Consumer>
  {value => /* 基于 context 值进行渲染*/}
</MyContext.Consumer>

那么回到 Angular 框架来说,Angular 的服务 + 依赖注入完美解决了数据共享和逻辑复用问题,服务本质上和 React Hooks 没有太多的区别,只是 API 形态不一样,一个是通过函数形式一个是通过类+依赖注入,因为这两个框架的底层机制和思想不一样,导致了 API 表现形式的不同,但是最终都是在解决数据共享和逻辑复用的问题。

入门到高级

那么接下来我会从依赖注入的基本使用 => 依赖提供者 => 多级注入器三个方面详细讲解一下。

基本使用

在 Angular 中,通过 @angular/cli ​提供的命令 ng generate service heroes/hero (简写 ng g s heroes/hero ​ ) 可以快速的创建一个服务,创建后的服务代码如下:

// src/app/heroes/hero.service.ts
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class HeroService {
  constructor() { }
}

HeroService 通过 @Injectable() ​装饰器标记为可以被注入的服务, providedIn: 'root' ​表示当前服务在 Root 注入器中提供,简单理解就是这个服务在整个应用所有地方都可以注入,并全局唯一实例。
添加完服务后,我们就可以在任何组件中通过构造函数注入 HeroService, 通过 TS 的构造函数赋值属性的特性设置为公开,这样组件内和模板中都可以使用该服务端的函数和方法。

// src/app/heroes/hero-list.component
constructor(public heroService: HeroService)

简单的代码如下:

import { Hero } from '../hero';
import { HeroService } from '../hero.service';

@Component({
    selector: 'app-hero-list',
    template: 'Heroes: {{heroes | json}}'
})
export class HeroListComponent implements OnInit {
    heroes!: Hero[];
    
    constructor(public heroService: HeroService) {}

    ngOnInit(): void {
        this.heroes = this.heroService.getHeroes();
    }
}

除了在组件中注入服务外,在 Angular 中还可以在服务中注入其他服务,当某个服务依赖于另一个服务时,遵循与注入组件相同的模式,比如: HeroService ​ 要依靠 Logger ​ 服务来记录日志。

// src/app/heroes/hero.service.ts
import { Injectable } from '@angular/core';
import { HEROES } from './mock-heroes';
import { Logger } from '../logger.service';

@Injectable({
  providedIn: 'root',
})
export class HeroService {

  constructor(private logger: Logger) {  }

  getHeroes() {
    this.logger.log('Getting heroes ...');
    return HEROES;
  }
}

以上就是在 Angular 中最简单的使用依赖注入的姿势,是不是觉得和 React 的 Hooks 一样,只是通过面向对象的 API 共享数据和业务逻辑,个人感觉更加的简单和易读。

Angular 依赖注入简介

下面简单的介绍一下 Angular 依赖注入的几个基本的元素:

  • @Injectable() 装饰器来提供元数据,表示一个服务可以被注入的(在之前的版本中不加也是可以被注入的,后来5.0版本改成静态注入器后必须要标识一下才可以被注入,否则会报错)
  • 注入器(Injector) 会创建依赖、维护一个容器来管理这些依赖,并尽可能复用它们,Angular 默认会创建各种注入器,甚至感觉不到他的存在,但是理解注入器的底层逻辑后再看依赖注入就更简单了
  • @Inject() 装饰器表示要在组件或者服务中注入一个服务
  • 提供者(Provider) 是一个对象,用来告诉注入器应该如何获取或创建依赖值。

image.png
上面的示例中虽然没有出现 @Inject() ​装饰器,但是这是 Angular 提供的简写,注入一个服务的全写如下所示,我们通常定义服务都是使用类,所以省略 @Inject(HeroService) ​ 极大的简化了样板代码。

// src/app/heroes/hero-list.component
class HeroListComponent {
  constructor(@Inject(HeroService) private heroService: HeroService) {}
}

依赖提供者(Dependency providers)

Angular 官方文档对于依赖提供者,也就是 providers ​ 的解释如下:
image.png
说实话我读了很多遍都无法理解具体含义,后来我简单总结为依赖提供者就做了两件事:

  • 告诉注入器如何提供依赖值
  • 限制服务可使用的范围

在上述的示例中,使用 @Inject(HeroService) ​注入一个服务时,Angular 注入器会通过 new HeroService() ​ 实例化一个类返回依赖值,实例化类其实就是 如何提供依赖值, 那么 Angular 中除了实例化类提供依赖值外还提供给了如下类型的 Provider ​,每种 Provider ​都有其使用场景。

export declare type Provider = TypeProvider | ValueProvider | ClassProvider 
| ConstructorProvider | ExistingProvider | FactoryProvider | any[];

export declare type StaticProvider = ValueProvider | ExistingProvider | 
  StaticClassProvider | ConstructorProvider | FactoryProvider | any[];

关于 限制服务可使用的范围 就更好理解了 满足只能在某个模块或者组件中注入 HeroService ​的场景。

如何定义提供者

在组件或者模块中通过装饰器元数据 providers 定义提供者。
比如: 类提供者
无标题-2021-08-18-1229.png

  • provide 属性是依赖令牌,它作为一个 key,在定义依赖值和配置注入器时使用,可以是一个 类的类型InjectionToken 、或者字符串,甚至对象,但是不能是一个 Interface、数字和布尔类型
  • 第二个属性是一个提供者定义对象,它告诉注入器要如何创建依赖值。 提供者定义对象中的 key 可以是 useClass —— 就像这个例子中一样。 也可以是 useExisting ​ 、 useValue ​ 或 useFactory ​, 每一个 key 都用于提供一种不同类型的依赖。
  • image.png

类提供者(TypeProvider 和 ClassProvider)

类提供者应该是最常用的一种,文章开始中的示例就是,简写和全写的配置如下:

provides: [ Logger ]   // 简写
provides: [{ provide: Logger, useClass: Logger }]  // 全写
  • provide: Logger 意思是把类的类型作为 DI Token(依赖令牌)
  • useClass 表示使用此类实例化作为依赖值,其实就是通过 new Logger() ​返回依赖值

使用场景:

  • 所有 class 定义的服务默认都是用 类提供者
  • 指定替代性的类提供者,替换原有服务的行为实现可扩展性,这样我在使用的时候还是注入 Logger ​,但是实际返回的对象是 BetterLogger ​ 示例
[{ provide: Logger, useClass: BetterLogger }] 
// 当使用 Logger 令牌请求 Logger 时,返回一个 BetterLogger

别名类提供者(ExistingProvider)

在下面的例子中,当组件请求新的或旧的 Logger 时,注入器都会注入一个 NewLogger 的实例。 通过这种方式, OldLogger ​ 就成了 NewLogger ​ 的别名。

[ 
  NewLogger,
  // Alias OldLogger reference to NewLogger
  { provide: OldLogger, useExisting: NewLogger}
]

那么别名类提供者和类提供者有什么区别呢?

[ 
  NewLogger,
  { provide: OldLogger, useClass: NewLogger}
]
  • useExisting 值是一个 DI Token ,provide 也是一个 DI Token, 2个 Token 指向同一个实例
  • useClass 值是一个可以实例化的类,也就是可以 new 出来的类,这个类可以是任何类

使用场景:

  • 收窄类型 MinimalLogger , Logger 类的返回的方法和属性太多,当前场景只需要使用少量的属性和函数,可以定义一个简化版的 MinimalLogger, 通过注入 MinimalLogger ​ 使用,运行时返回的其实还是 Logger 对象
  • 重构,替换命名,一次性无法完全修改,先临时提供一个新的别名,将来逐步替换
  • 解决循环引用问题,为类接口(抽象)指定别名
// Class used as a "narrowing" interface that exposes a minimal logger
// Other members of the actual implementation are invisible
export abstract class MinimalLogger {
  abstract logs: string[];
  abstract logInfo: (msg: string) => void;
}

{ provide: MinimalLogger, useExisting: LoggerService },
// parent.ts
class abstract Parent {
     ...
}

// alex.component.ts
providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }]
class AlexComponent {
   // ChildComponent
}

// child.component.ts
class ChildComponent {
    constructor(parent: Parent)
}

对象提供者(ValueProvider)

要注入一个对象,可以用 useValue ​ 选项来配置注入器,下面的提供者定义对象使用 useValue ​ 作为 key 来把该变量与 Logger ​ 令牌关联起来。

// An object in the shape of the logger service
function silentLoggerFn() {}

export const silentLogger = {
  logs: ['Silent logger says "Shhhhh!". Provided via "useValue"'],
  log: silentLoggerFn
};

[{ provide: Logger, useValue: silentLogger }]

使用场景:
通过对象提供者注入一个配置对象,一般推荐使用 InjectionToken ​作为令牌​

// src/app/app.config.ts
import { InjectionToken } from '@angular/core';

export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');

export const HERO_DI_CONFIG: AppConfig = {
  apiEndpoint: 'api.heroes.com',
  title: 'Dependency Injection'
};
// src/app/app.module.ts (providers)
providers: [
  { provide: APP_CONFIG, useValue: HERO_DI_CONFIG }
],

当使用 InjectionToken ​作为令牌时,在组件或者服务中必须借助参数装饰器 @Inject() ​ ,才可以把这个配置对象注入到构造函数中。

// src/app/app.component.ts
constructor(@Inject(APP_CONFIG) config: AppConfig) {
  this.title = config.title;
}
Inject, 类构造函数中依赖项参数上的参数装饰器,用于指定依赖项的自定义提供者,参数传入 DI Token,映射到要注入的依赖项。
同时在定义 InjectionToken ​ 的时候还可以设置 providedIn ​ 和 factory
export const TOKEN_FACTORY = new InjectionToken('factory-token', {
    providedIn: 'root',
    factory: () => {
        return 'I am from InjectionToken factory';
    }
});

工厂提供者(FactoryProvider)

要想根据运行前尚不可用的信息创建可变的依赖值,可以使用工厂提供者。也就是完全自己决定如何创建依赖。
比如:只有授权用户才能看到 HeroService ​ 中的秘密英雄。

// src/app/heroes/hero.service.ts (excerpt)
constructor(
  private logger: Logger,
  private isAuthorized: boolean) { }

getHeroes() {
  const auth = this.isAuthorized ? 'authorized ' : 'unauthorized';
  this.logger.log(`Getting heroes for ${auth} user.`);
  return HEROES.filter(hero => this.isAuthorized || !hero.isSecret);
}
// src/app/heroes/hero.service.provider.ts (excerpt)

const heroServiceFactory = (logger: Logger, userService: UserService) => {
  return new HeroService(logger, userService.user.isAuthorized);
};

export const heroServiceProvider =
  { 
    provide: HeroService,
    useFactory: heroServiceFactory,
    deps: [Logger, UserService]
  };
  • useFactory ​ 字段指定该提供者是一个工厂函数,其实现代码是 heroServiceFactory
  • deps ​ 属性是一个提供者令牌数组, LoggerUserService 类都是类提供者的令牌。该注入器解析了这些令牌,并把相应的服务注入到 heroServiceFactory ​ 工厂函数的参数中

理解了工厂提供给者后再回过头看, useValue ​ 、 useClass ​ 和 useExisting ​ 的区别就更简单了, provider ​ 就是在装饰器中通过 providers ​数组配置的元数据对象。
image.png

接口和依赖注入

虽然 TypeScript 的 AppConfig 接口可以在类中提供类型支持,但它在依赖注入时却没有任何作用。在 TypeScript 中,接口只能作为类型检查,它没有可供 DI 框架使用的运行时表示形式或令牌。

  • 当转译器把 TypeScript 转换成 JavaScript 时,接口就会消失,因为 JavaScript 没有接口。
  • 由于 Angular 在运行期没有接口,所以该接口不能作为令牌,也不能注入它。
// Can't use interface as provider token
[{ provide: AppConfig, useValue: HERO_DI_CONFIG })]

// Can't inject using the interface as the parameter type
constructor(private config: AppConfig){ }

multi 多个依赖值

当配置提供者时设置 multi 为 true 时,通过 @Inject(DIToken) ​ 参数注入获取的依赖值就会返回一个数组。

export declare interface ClassProvider extends ClassSansProvider {
    /**
     * An injection token. (Typically an instance of `Type` or `InjectionToken`, but can be `any`).
     */
    provide: any;
    /**
     * When true, injector returns an array of instances. This is useful to allow multiple
     * providers spread across many files to provide configuration information to a common token.
     */
    multi?: boolean;
}

使用场景:
内置 API ,比如: NG_VALUE_ACCESSOR ​、 HTTP_INTERCEPTORS ​、 APP_INITIALIZER ​ 等

@Component({
    selector: 'select',
    templateUrl: './select.component.html',
    changeDetection: ChangeDetectionStrategy.OnPush,
    providers: [
        {
            provide: NG_VALUE_ACCESSOR,
            useExisting: forwardRef(() => ThySelectComponent),
            multi: true
        }
    ]
})
export class ThySelectComponent implements ControlValueAccessor {}

以上就是依赖注入者 provider 相关的介绍,理解了 factory 提供依赖值后再看其他类型就会简单很多,其他的类型就是 factory 之上高级的 API 而已,满足不同的场景需要,这是 Angular 依赖注入入门比较难懂的知识,那么接下来的多级注入器是另一个重要的知识点,这两部分都深入理解那么 Angular 依赖注入就不在是难点了。

多级注入器

通过依赖注入的概念我们知道,创建实例的工作都交给 Ioc 容器(也就是注入器)了,通过构造函数参数装饰器 @Inject(DIToken) ​告诉注入器我们需要注入 DIToken ​对应的依赖,注入器就会帮我们查找依赖并返回值,Angular 应用启动会默认创建相关的注入器,而且 Angular 的注入器是有层级的,类似于一个 Dom 树。

两个注入器层级

Angular 中有两个注入器层次结构:

  • ModuleInjector 层次结构 —— 使用 @NgModule()@Injectable() 装饰器在此层次结构中配置 ModuleInjector
  • ElementInjector 层次结构 —— 在每个 DOM 元素上隐式创建。除非你在 @Directive()@Component()providers 属性中进行配置,否则默认情况下, ElementInjector 为空

ModuleInjector

可以通过以下两种方式之一配置 ModuleInjector

  • 使用 @Injectable()providedIn 属性引用 NgModuleTyperoot ​、 platform ​ 或者 any ​ 。
  • 使用 @NgModule()providers 数组。
Tree-shaking and @Injectable() - 摇树优化与 @Injectable()
使用 @Injectable()providedIn 属性优于 @NgModule()providers 数组,因为使用 @Injectable()providedIn 时,优化工具可以进行摇树优化,从而删除你的应用程序中未使用的服务,以减小捆绑包尺寸。 摇树优化对于库特别有用,因为使用该库的应用程序不需要注入它。在 服务与依赖注入简介了解关于可摇树优化的提供者的更多信息。
需要特别注意:
  • ModuleInjector@NgModule.providersNgModule.imports 属性配置。 ModuleInjector 是可以通过 NgModule.imports 递归找到的所有 providers 数组的扁平化。
  • ModuleInjector 是在惰性加载其它 @NgModules 时创建的。

第一点的意思就是 AppModule ​导入了 FeatureAModule ​ 和 FeatureBModule ​,那么 Angular 会根据模块树找到所有模块配置的 providers ​并打平存放到一起,这就意味着整个应用程序所有地方都可以注入这些打平的 providers ​,这就是 Angular 模块下的服务和组件/指令/管道所不同的地方,组件/指令/管道在某个模块定义,只要没有导出,其他模块都无法使用,必须导出才可以被导入该模块的组件使用。
第二点可以不严谨的认为整个应用程序只有一个根模块注入器,只有通过路由的懒加载才会创建子模块注入器,当懒加载模块 FeatureCModule ​ 还没有被加载时, AppModule ​并不能通过模块树找到 FeatureCModule ​, 那么 Angular 有两种做法,第一种就是现在的行为,创建一个子模块注入器,第二种就是把 FeatureCModule ​中的所有 providers ​动态追加到根模块注入器中,第二种做法显然会造成前后不一致的问题,那么只能选择第一种,因为选择了第一种,所以会导致惰性加载的模块注入服务和根模块注入器注入不一致的一些列行为。

Platform 和 Root 注入器

除了惰性加载模块提供的供应商外,所有模块的 providers 和 @Injectable({providedIn: "root"}) ​供应商都时在 root ​根注入器中提供,那么在 root 之上还有两个注入器,一个是额外的平台 ModuleInjector ,一个是 NullInjector
image.png
我们简单看一下 Angular 应用的启动过程

platformBrowserDynamic().bootstrapModule(AppModule).then(ref => {...})
  • bootstrapModule() 方法会创建一个由 AppModule 配置的注入器作为平台注入器的子注入器,也就是 root ModuleInjector
  • platformBrowserDynamic() 方法创建一个由 PlatformModule 配置的注入器,该注入器包含特定平台的依赖项,这允许多个应用共享同一套平台配置。例如,无论你运行多少个应用程序,浏览器都只有一个 URL 栏,你可以使用 platformBrowser() 函数提供 extraProviders ,从而在平台级别配置特定平台的额外提供者。
  • 层次结构中的顶级父注入器是 NullInjector() ,它是树的顶部。如果你在树中向上走了很远,以至于要在 NullInjector() 中寻找服务,那么除非使用 @Optional() ,否则将收到错误消息。

对于普通的开发者而言,我们一般接触不到平台注入器和 NullInjector ​,可以假设整个应用程序只有一个 root 模块注入器。

ElementInjector

除了模块注入器外, Angular 会为每个 DOM 元素隐式创建 ElementInjector
可以用 @Component() 装饰器中的 providersviewProviders 属性来配置 ElementInjector 以提供服务。

@Component({
  ...
  providers: [{ provide: ItemService, useValue: { name: 'lamp' } }]
})
export class TestComponent
  • 在组件中提供服务时,可以通过 ElementInjector 在该组件以及子组件/指令处通过注入令牌使用该服务
  • 当组件实例被销毁时,该服务实例也将被销毁
  • 组件是一种特殊类型的指令,这意味着 @Directive()@Component() 都具有 providers 属性

解析规则

前面已经介绍了 Angular 会有2个层级的注入器,那么当组件/指令解析令牌时,Angular 分为两个阶段来解析它:

  • 针对 ElementInjector 层次结构(其父级)
  • 针对 ModuleInjector 层次结构(其父级)

Angular 会先从当前组件/指令的 ElementInjector ​查找令牌,找不到会去父组件中查找,直到根组件,如果根还找不到,就去当前组件所在的模块注入器中查找,如果不是懒加载那么就是根注入器,一步一步到最顶层的 NullInjector ​,整个解析过程如下所示:
无标题-2021-08-16-1531.png

解析修饰符

默认情况下, Angular 始终从当前的 Injector 开始,并一直向上搜索,修饰符可以更改开始(默认是自己)或结束位置,从而达到一些高级的使用场景,Angular 中可以使用 @Optional()@Self()@SkipSelf()@Host() 来修饰 Angular 的解析行为,每个修饰符的说明如下:
@Optional()
@Optional() 允许 Angular 将你注入的服务视为可选服务。这样,如果无法在运行时解析它,Angular 只会将服务解析为 null,而不会抛出错误。

export class OptionalComponent {
  constructor(@Optional() public optional?: OptionalService) {}
}

@Self()
使用 @Self() 让 Angular 仅查看当前组件或指令的 ElementInjector

@Component({
  selector: 'app-self',
  templateUrl: './self.component.html',
  styleUrls: ['./self.component.css'],
  providers: [{ provide: FlowerService, useValue: { emoji: '🌼' } }]

})
export class SelfComponent {
  constructor(@Self() public flower: FlowerService) {}
}

@Optional() ​组合使用。

@Component({
  selector: 'app-self-no-data',
  templateUrl: './self-no-data.component.html',
  styleUrls: ['./self-no-data.component.css']
})
export class SelfNoDataComponent {
  constructor(@Self() @Optional() public flower?: FlowerService) { }
}

@SkipSelf()
@SkipSelf() @Self() 相反,使用 @SkipSelf() ,Angular 在父 ElementInjector 中开始搜索服务,而不是从当前 ElementInjector 中开始搜索服务。

@Injectable({
    providedIn: 'root'
})
export class FlowerService {
    emoji = '🌿';
    constructor() {}
}
import { Component, OnInit, SkipSelf } from '@angular/core';
import { FlowerService } from '../flower.service';

@Component({
    selector: 'app-skipself',
    templateUrl: './skipself.component.html',
    styleUrls: ['./skipself.component.scss'],
    providers: [{ provide: FlowerService, useValue: { emoji: '🍁' } }]
})
export class SkipselfComponent implements OnInit {
    constructor(@SkipSelf() public flower: FlowerService) {}

    ngOnInit(): void {}
}

上面的示例会得到 root 注入器中的 🌿,而不是组件所在的 ElementInjector ​中提供的 🍁。
如果值为 null 可以同时使用 @SkipSelf()@Optional() 来防止错误。

class Person {
  constructor(@Optional() @SkipSelf() parent?: Person) {}
}

@Host()
@Host() 使你可以在搜索提供者时将当前组件指定为注入器树的最后一站,即使树的更上级有一个服务实例,Angular 也不会继续寻找。

@Component({
  selector: 'app-host',
  templateUrl: './host.component.html',
  styleUrls: ['./host.component.css'],
  //  provide the service
  providers: [{ provide: FlowerService, useValue: { emoji: '🌼' } }]
})
export class HostComponent {
  // use @Host() in the constructor when injecting the service
  constructor(@Host() @Optional() public flower?: FlowerService) { }
}

由于 HostComponent 在其构造函数中具有 @Host() ,因此,无论 HostComponent 的父级是否可能有 flower.emoji 值,该 HostComponent 都将使用 🌼(黄色花朵)。
那么问题来了 @Host@Self 到底有什么区别?

**@Host** 属性装饰器会禁止在宿主组件以上的搜索,宿主组件通常就是请求该依赖的那个组件,不过,当该组件投影进某个父组件时,那个父组件就会变成宿主,意思就是 ng-content 中的组件所在的宿主组件不是自己,而是 ng-content 提供的父组件。

注:

所有修饰符都可以组合使用,但是不能互斥,比如: @Self()@SkipSelf@Host()@Self()

在 @Component() 中提供服务

Angular 中所有的依赖都是单例的,由于存在模块和 Element 注入器层级,导致可以在不同的注入器中提供同一个令牌,从而实现非全局单例的效果,在组件中提供服务达到限制某些依赖只能在当前组件/指令以及子元素中使用,或者不同的组件注入各自的单独实例。
在组件/指令中可以通过 providersviewProviders ​分别提供服务依赖项,可以通过阅读 guide/hierarchical-dependency-injection#providing-services-in-component 章节深入理解,官方文档内容有点多,我尝试简化一下。
首先在 Angular 中定义一个如下模板,实际的逻辑结构中会多一个 VIEW 视图的概念:

<app-root>
    <app-child></app-child>
</app-root>
<app-root>
  <#VIEW>
    <app-child>
     <#VIEW>
       ...content goes here...
     </#VIEW>
    </app-child>
  <#VIEW>
</app-root>

如果 <app-child></app-child> ​模板内部有一个 A 组件,A 组件注入服务会先从虚拟的 #VIEW 中查找依赖,然后再到 app-child 组件,解析顺序为 app-child #VIEW => app-child => app-root #View => app-root ​,那么 providers ​提供的服务其实就是挂载在组件上, viewProviders ​提供的服务挂载在 #VIEW 这个虚拟结构之上。
正常情况下, providers ​和 viewProviders ​没有任何区别,只要当在组件中使用投影时会不同的表现,比如下面的示例:

<app-root>
    <app-child>
      <a-component></a-component>
    </app-child>
</app-root>

a-component ​ 是通过 ng-content 投影传递到 app-child 组件中的,那么如果在 a-component 中注入 FlowerService ​,此时如果 app-child ​ 是通过 viewProviders ​提供的依赖,那么 A 组件会找不到依赖值,有投影时实际逻辑图如下:

<app-root>
  <#VIEW>
    <app-child>
     <#VIEW>
       ...content goes here...
     </#VIEW>
     <a-component>
       <#VIEW>
       </#VIEW>
     </a-component>
    </app-child>
  <#VIEW>
</app-root>

此时,a-component 和 app-child 的 #VIEW 是平级的,所以往上找不到 <#VIEW> 中提供的依赖项(也就是 viewProviders ​提供的依赖性)
解析和查找的逻辑关系如下所示,其实关于 viewProviders ​我们基本上很少使用,所以不理解或者不知道也没有关系,但是通过这个可以深入的理解 Angular 关于视图相关的依赖注入底层逻辑。

<app-root @NgModule(AppModule)
        @Inject(AnimalService) animal=>"🐳">
  <#VIEW>
    <app-child>
      <#VIEW
       @Provide(AnimalService="🐶")
       @Inject(AnimalService=>"🐶")>
       <!-- ^^using viewProviders means AnimalService is available in <#VIEW>-->
       <p>Emoji from AnimalService: {{animal.emoji}} (🐶)</p>
      </#VIEW>
      <app-inspector>
        <#VIEW>
          <p>Emoji from AnimalService: {{animal.emoji}} (🐳)</p>
        </#VIEW>
      </app-inspector>
     </app-child>
  </#VIEW>
</app-root>

ng-template 注入器

ng-template 在定义的视图层级上下找注入器,并不是在渲染的视图层级找注入器的。这一点特别容易踩坑,当我们编写高度的灵活的组件时经常会支持模板传递,那么渲染模板的节点注入器模板中不一定能够找到,感兴趣不理解的可以看示例: https://stackblitz.com/edit/angular-ivy-9tsdhh

provideIn: "any" | "root" | "platform" | NgModuleType

  • root ​ 表示在根模块注入器(root ModuleInjector )提供依赖
  • platform ​ 表示在平台注入器提供依赖
  • 指定模块表示在特定的特性模块提供依赖(注意循环依赖)
  • any ​ 所有急性加载的模块都会共享同一个服务单例,惰性加载模块各自有它们自己独有的单例

ElementInjector 使用场景

  • 服务隔离(多个详情页、编辑器)
  • 多重编辑会话
  • 在当前组件替换使用第三方服务的行为

当我们理解了 Angular 依赖注入的基本使用,依赖提供者的作用和常用的提供者类型,以及多级注入器的原理,那么可以说 Angular 依赖注入已经达到中高级水平了,那么接下来就是通过这些基础知识学习一些高级的使用技巧,知识死的,如何灵活应用才是关键,建议看到这可以喝杯茶休息一会,接下来会简单介绍一下 Angular 依赖注入的一些高级技巧。

高级技巧

接下来我简单的列举一些使用 Angular 依赖注入比较重要的高级技巧,也是官方文档中涵盖的知识点:
image.png

轻量级注入令牌 - Lightweight injection tokens

在我们开发类库的时候,支持摇树优化是一个重要的特性,要减少体积,那么在 Angular 类库中需要做以下几点:

  • 分模块打包和导入,按钮模块和模态框模块分别打包
  • 服务尽量使用 @Injectable({ provideIn: "root" | "any" | }) 优先
  • 使用轻量级注入 Token

令牌什么时候会被保留

那么在同一个组件模块中,提供了很多个组件,如果只想打包被使用的组件如何做呢?
比如我们定义如下的一个 card 组件,包含了 header,同时 card 组件中需要获取 header 组件示例。

<lib-card>
  <lib-header>...</lib-header>
</lib-card>
@Component({
  selector: 'lib-header',
  ...,
})
class LibHeaderComponent {}

@Component({
  selector: 'lib-card',
  ...,
})
class LibCardComponent {
  @ContentChild(LibHeaderComponent)
  header: LibHeaderComponent|null = null;
}

因为 <lib-header> 是可选的,所以元素可以用最小化的形式 <lib-card></lib-card> 出现在模板中,在这个例子中, <lib-header> 没有用过,你可能期望它会被摇树优化掉。
但是因为代码中出现了如下的一段导致无法被优化:
@ContentChild(LibHeaderComponent) header: LibHeaderComponent;

  • 其中一个引用位于 类型位置 上 - 即,它把 LibHeaderComponent 用作了类型: header: LibHeaderComponent;
  • 另一个引用位于 值的位置 - 即, LibHeaderComponent@ContentChild() 参数装饰器的值: @ContentChild(LibHeaderComponent)

编译器对这些位置的令牌引用的处理方式时不同的。

  • 编译器在从 TypeScript 转换完后会删除这些 类型位置 上的引用,所以它们对于摇树优化没什么影响
  • 编译器必须在运行时保留 值位置 上的引用,这就会阻止该组件被摇树优化掉。

什么时候使用轻量级注入令牌模式

当一个组件被用作注入令牌时,就会出现摇树优化的问题,有两种情况:

  • 令牌用在内容查询中值的位置上, 也就是 @ContentChild ​或者 @ViewChild ​ 等查询装饰器
  • 该令牌用作构造函数注入的类型说明符, @Inject(OtherComponent) ​, 下面的代码虽然没有出现 @Inject(),通过前面的章节可以知道这只是简写而已。
class MyComponent {
  constructor(@Optional() other: OtherComponent) {}

  @ContentChild(OtherComponent)
  other: OtherComponent|null;
}

使用轻量级注入令牌

轻量级注入令牌设计模式简单理解就是: 使用一个小的抽象类作为注入令牌,并在稍后为它提供实际实现,该抽象类固然会被留下(不会被摇树优化掉),但它很小,对应用程序的大小没有任何重大影响。

abstract class LibHeaderToken {}

@Component({
  selector: 'lib-header',
  providers: [
    {provide: LibHeaderToken, useExisting: LibHeaderComponent}
  ]
  ...,
})
class LibHeaderComponent extends LibHeaderToken {}

@Component({
  selector: 'lib-card',
  ...,
})
class LibCardComponent {
  @ContentChild(LibHeaderToken) header: LibHeaderToken|null = null;
}

总结一下,轻量级注入令牌模式由以下几部分组成。

  • 一个轻量级的注入令牌,它表现为一个抽象类。
  • 一个实现该抽象类的组件定义。
  • 注入这种轻量级模式时使用 @ContentChild() 或者 @ContentChildren()
  • 实现轻量级注入令牌的提供者,它将轻量级注入令牌和它的实现关联起来。

使用轻量级注入令牌进行 API 定义

为了有类型提示,我们可以为这个轻量级令牌定义函数和属性,不是这个抽象类加多少个 API 定义都不会影响体积,因为 TS 编译后类型都会丢失,加类型只是为了在开发模式下类型更加安全。

abstract class LibHeaderToken {
  name: string;
  abstract doSomething(): void;
}

@Component({
  selector: 'lib-header',
  providers: [
    {provide: LibHeaderToken, useExisting: LibHeaderComponent}
  ]
  ...,
})
class LibHeaderComponent extends LibHeaderToken {
  doSomething(): void {
    // Concrete implementation of `doSomething`
  }
}

@Component({
  selector: 'lib-card',
  ...,
})
class LibCardComponent implement AfterContentInit {
  @ContentChild(LibHeaderToken)
  header: LibHeaderToken|null = null;

  ngAfterContentInit(): void {
    this.header && this.header.doSomething();
  }

为你的轻量级注入令牌命名

轻量级注入令牌只对组件有用。

  • “LibHeaderComponent” 遵循 “Component” 后缀命名约定
  • “LibHeaderToken” 遵循 轻量级注入令牌命名约定。推荐的写法是使用组件基本名加上后缀 “Token”

解决组件循环引用

使用轻量级 Token 不仅仅可以减少体积,还可以解决循环引用的问题,具体可以查看 https://angular.cn/errors/NG3003

惰性加载特性模块 - Lazy-loading Feature Module DI

默认情况下, NgModule 都是急性加载的,也就是说它会在应用加载时尽快加载。
对于带有很多路由的大型应用,肯定会使用惰性加载 -- 一种按需加载 NgModule 的模式

惰性加载入门

const routes: Routes = [
  {
    path: 'items',
    loadChildren: () => import('./items/items.module').then(m => m.ItemsModule)
  }
];
RouterModule.forChild([
   {
     path: '',
     component: ItemsComponent
   }
]),

确保从 AppModule 中移除了 ItemsModule ​模块。

如何设置惰性加载

建立惰性加载的特性模块有两个主要步骤:

  • 使用 --route 标志,用 CLI 创建特性模块。 ng generate module customers --route customers --module app.module
  • 配置相关路由

懒加载和急性加载的区别?

唯一区别就是会:创建子 ModuleInjector

意味着所有的 providers 和 imports 模块的 providers 都是独立的,急性模块并不是知道懒加载模块的 providers。

forRoot() 模式

forRoot()forChild() 的区别?
如果模块同时定义了 providers (服务)和 declarations (组件、指令、管道),那么,当你同时在多个特性模块中加载此模块时,这些服务就会被注册在多个地方。这会导致出现多个服务实例,并且该服务的行为不再像单例一样。

防止这种现象:

  • providedIn: "root" 语法代替在模块中注册服务的方式
  • 把你的服务分离到它们自己的模块中
  • 在模块中分别定义 forRoot()forChild() 方法
static forRoot(config: UserServiceConfig): ModuleWithProviders<GreetingModule> {
  return {
    ngModule: GreetingModule,
    providers: [
      {provide: UserServiceConfig, useValue: config }
    ]
  };
}

我们在开发类库或者使用第三库时经常会用到 forRoot ​模式,比如官方的路由模块,这种模式的本质还是因为惰性加载的模块会独立创建子模块注入器,但是模块中的组件/指令/管道和服务处理模式不一样导致,这也是 Angular 模块一大难点之一(也可以说是坑)。

providedIn: 'any'

通过使用 providedIn: 'any' ,所有急性加载的模块都会共享同一个服务单例,但是惰性加载模块各自有它们自己独有的单例。
image.png

ReflectiveInjector 和 StaticInjector

在 Angular V5 版本之前,内部的注入器是 ReflectiveInjector ,服务不需要通过 @Injectable 标记也可以被使用

class B {}

class A {
  constructor(@Inject(B) b) { }
}

const i = ReflectiveInjector.resolveAndCreate([A, B]);
const a = i.get(A);

下面是 Inject 装饰器实现的代码片段:

function ParamDecorator(cls: any, unusedKey: any, index: number) {
  ...
  // parameters here will be [ {token: B} ]
  Reflect.defineMetadata('parameters', parameters, cls);
  return cls;
}

ReflectiveInjector 依赖于 Reflect 对象提供的反射能力,来搜集隐式依赖,并通过 reflect-metadata 增强包实现相关功能,但是这种处理方式有一些问题:

  • ReflectiveInjector ​依赖 Reflect ​和 reflect-metadata 增强包,兼容性差
  • 包体积变大
  • 性能问题,使用反射需要维护一个大的 Map

使用 StaticInjector 的代码如下:

class B {}
class A { constructor(b) {} }
const i = Injector.create([{provide: A, useClass: A, deps: [B]]};
const a = i.get(A);

为什么叫静态注入器,是因为很多依赖关系在编译时就已经确定,我不需要在运行时通过反射获取。

注入组件/指令/模块/管道

在 Angular 中不仅仅服务可以注入,所有的内置装饰器 @Component() @Diretive() @Module() @Pipe() 等都可被注入,注入的解析逻辑和服务一样,先从 ElementInjector 层级找,再从 ModuleInjector ​层级找,这些都是 Angular 框架底层提供的能力,其实已经超出了依赖注入本身的范畴,所以为什么很难把 StaticInjector ​独立出去呢,因为很多功能和 Angular 的视图强绑定的。​

防止重复导入 CoreModule

只有根模块 AppModule 才能导入 CoreModule ,如果一个惰性加载模块也导入了它, 该应用就会为服务生成多个实例,要想防止惰性加载模块重复导入 CoreModule ,可以添加如下的 CoreModule 构造函数。

src/app/core/core.module.ts
constructor(@Optional() @SkipSelf() parentModule?: CoreModule) {
  if (parentModule) {
    throw new Error(
      'CoreModule is already loaded. Import it in the AppModule only');
  }
}

派生类注入服务

如果基础组件有依赖注入,必须要在派生类中重新提供和重新注入它们,并将它们通过构造函数传给基类。
原则:

  • 让构造函数保持简单 - 构造函数应该只用来初始化变量,获取数据在 ngOnInit 中进行
  • 通过覆写排序函数达到自定义行为的目的
  • 尽量避免使用组件基类,如果需要特别注意生命周期函数

image.pngimage.png

forwardRef 打破循环

这是一个很有意思的问题,本质上和 Angular 无关,应该是 JS 特性有关。

Javascript 中的 Hoisting(变量提升)

我们简单通过下面三个示例了解一下 JS 中的变量提升

console.log(num); // 打印 undefined
var num;
num = 6;
console.log(square(5)); // 会打印出 25
/* ... */
function square(n) { return n*n }
console.log(square); // 打印 undefined
console.log(square(5)); // 抛出异常 Uncaught TypeError: square is not a function
const square = function (n) {
  return n * n;
}

通过上述的示例可以得出一下结论:

  • 变量只有声明被提升,不提升初始化
  • 函数可以在声明之前调用,函数的声明被提升
  • 函数和变量相比,会被优先提升

那么 class 是 ES 2015 的新特性,它的行为和函数不一样, class 不会被提升。
因为提升会带来一些列问题,比如如下代码,是否还有其他原因暂时没有过多了解。

const Foo = class {};
class Bar extends Foo {}
// class 如果提升的话这段代码就会报错

组件注入 NameService

既然 class 不会提升变量,那么如果我在组件后面加一个服务,在 providers 中设置注入提供者就会报错: Class 'NameService' used before its declaration.

@Component({
    selector: 'app-forward-ref',
    templateUrl: './forward-ref.component.html',
    styleUrls: ['./forward-ref.component.scss'],
    providers: [
        {
           provide: NameService,
           useClass: NameService
        }
    ]
})
export class ForwardRefComponent {}

@Injectable()
class NameService {
    name = 'why520crazy';
}

解决这个问题有2个办法:

  • 第一就是把 NameService 移动到 ForwardRefComponent ​组件前
  • 第二就是使用 provide: forwardRef(() => NameService)

forwardRef ​实现原理很简单,就是让 provide ​存储一个闭包的函数,在定义式不调用,在注入的时候获取 Token 再调用闭包函数返回 NameService 的类型,此时 JS 已经完整执行过, NameService ​已经定义。

那么此处大家可以想一个有意思的问题,如果在 AClass ​ 的装饰器 MyDecorator ​传入参数 AClass ​会和上面的结果一样,报 Class 'AClass' used before its declaration. 错误吗?

@MyDecorator(AClass)
class AClass {}

function MyDecorator(type: Function) {
    return function (target: any) {};
}

最后

至此, Angular 依赖注入所有的内容都介绍完了,这是我在公司内部2个小时的培训内容,基本覆盖了大部分 Angular 依赖注入的知识点,因为是培训转文字稿,所以很多东西无法更生动的描绘出来,演示示例也无法进行,大家凑合看吧,如果有疑问或者改善的地方评论指出,如果你看完后不理解,说明我写的还不够好,我会继续努力,如果你能耐心的看到结尾,说明你应该理解了,点个赞再走吧,哈哈哈!

引用材料

https://angular.io/guide/dependency-injection
https://angular.io/guide/dependency-injection-providers
https://angular.io/guide/hierarchical-dependency-injection
https://angular.io/guide/dependency-injection-in-action
https://angular.io/guide/lazy-loading-ngmodules
https://angular.io/guide/lightweight-injection-tokens
https://angular.io/guide/providers
https://angular.io/guide/singleton-services
https://github.com/mgechev/injection-js
https://www.zhihu.com/question/23277575
https://www.simplilearn.com/tutorials/angular-tutorial/angular-dependency-injection
https://stackoverflow.com/questions/51062235/angular-6-providedin-a-non-root-module-is-causing-a-circular-dependency
https://stackoverflow.com/questions/45152995/useclass-vs-useexisting
https://zhuanlan.zhihu.com/p/97116999

最后,推荐我们的智能化研发管理工具 PingCode 给大家。

PingCode官网

关于PingCode

PingCode是由国内老牌SaaS厂商Worktile 打造的智能化研发管理工具,围绕企业研发管理需求推出了Agile(敏捷开发)、Testhub(测试管理)、Wiki(知识库)、Plan(项目集)、Goals(目标管理)、Flow(自动化管理)、Access (目录管理)七大子产品以及应用市场,实现了对项目、任务、需求、缺陷、迭代规划、测试、目标管理等研发管理全流程的覆盖以及代码托管工具、CI/CD流水线、自动化测试等众多主流开发工具的打通。

自正式发布以来,以酷狗音乐、商汤科技、电银信息、51社保、万国数据、金鹰卡通、用友、国汽智控、智齿客服、易快报等知名企业为代表,已经有超过13个行业的众多企业选择PingCode落地研发管理。


PingCode研发中心
126 声望23 粉丝