Angular 项目中的可摇树依赖 - Tree-shakable dependencies

Tree-shakable dependencies in Angular projects

Tree-shakable 依赖更容易推理和编译成更小的包。

Angular 模块 (NgModules) 曾经是提供应用程序范围依赖项(例如常量、配置、函数和基于类的服务)的主要方式。 从 Angular 版本 6 开始,我们可以创建可摇树的依赖项,甚至可以忽略 Angular 模块。

当我们使用 NgModule 装饰器工厂的 providers 选项提供依赖项时,Angular 模块文件顶部的 import 语句引用了依赖项文件。

这意味着 Angular 模块中提供的所有服务都成为包的一部分,即使是那些不被 declarable 或其他依赖项使用的服务。 让我们称这些为硬依赖,因为它们不能被我们的构建过程摇树。

相反,我们可以通过让依赖文件引用 Angular 模块文件来反转依赖关系。 这意味着即使应用程序导入了 Angular 模块,它也不会引用依赖项,直到它在例如组件中使用依赖项。

Providing singleton services

许多基于类的服务被称为应用程序范围的单例服务——或者简称为单例服务,因为我们很少在平台注入器级别使用它们。

Pre-Angular 6 singleton service providers

在 Angular 版本 2 到 5 中,我们必须向 NgModule 的 providers 选项添加单例服务。 然后我们必须注意,只有急切加载的 Angular 模块才会导入提供的 Angular 模块——按照惯例,这是我们应用程序的 CoreModule。

// pre-six-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

@Injectable()
export class PreSixSingletonService {
  constructor(private http: HttpClient) {}
}
// pre-six.module.ts
import { NgModule } from '@angular/core';

import { PreSixSingletonService } from './pre-six-singleton.service';

@NgModule({
  providers: [PreSixSingletonService],
})
export class PreSixModule {}
// core.module.ts
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';

import { PreSixModule } from './pre-six.module.ts';

@NgModule({
  imports: [HttpClientModule, PreSixModule],
})
export class CoreModule {}

以上是 Pre-Angular 6 singleton service.

如果我们在延迟加载的功能模块中导入提供 Angular 的模块,我们将获得不同的服务实例。

Providing services in mixed Angular modules

当在带有可声明的 Angular 模块中提供服务时,我们应该使用 forRoot 模式来表明它是一个混合的 Angular 模块——它同时提供了可声明和依赖项。

这很重要,因为在延迟加载的 Angular 模块中导入具有依赖项提供程序的 Angular 模块将为该模块注入器创建新的服务实例。 即使已经在根模块注入器中创建了一个实例,也会发生这种情况。

// pre-six-mixed.module.ts
import { ModuleWithProviders, NgModule } from '@angular/core';

import { MyComponent } from './my.component';
import { PreSixSingletonService } from './pre-six-singleton.service';

@NgModule({
  declarations: [MyComponent],
  exports: [MyComponent],
})
export class PreSixMixedModule {
  static forRoot(): ModuleWithProviders {
    return {
      ngModule: PreSixMixedModule,
      providers: [PreSixSingletonService],
    };
  }
}

以上是 The forRoot pattern for singleton services.

静态 forRoot 方法用于我们的 CoreModule,它成为根模块注入器的一部分。

Tree-shakable singleton service providers

幸运的是,Angular 6 向 Injectable 装饰器工厂添加了 providedIn 选项。 这是声明应用程序范围的单例服务的一种更简单的方法。

// modern-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class ModernSingletonService {
  constructor(private http: HttpClient) {}
}

以上是 Modern singleton service.

单例服务是在第一次构建依赖它的任何组件时创建的。

始终使用 Injectable 装饰基于类的服务被认为是最佳实践。 它配置 Angular 以通过服务构造函数注入依赖项。

在 Angular 版本 6 之前,如果我们的服务没有依赖项,则 Injectable 装饰器在技术上是不必要的。 尽管如此,添加它仍然被认为是最佳实践,以便我们在以后添加依赖项时不会忘记这样做。

现在我们有了 providedIn 选项,我们还有另一个理由总是将 Injectable 装饰器添加到我们的单例服务中。

这个经验法则的一个例外是,如果我们创建的服务总是打算由工厂提供者构建(使用 useFactory 选项)。 如果是这种情况,我们不应指示 Angular 将依赖项注入其构造函数。

providedIn: 'root'

该选项将在根模块注入器中提供单例服务。 这是为引导的 Angular 模块创建的注入器——按照惯例是 AppModule.事实上,这个注入器用于所有急切加载的 Angular 模块。

或者,我们可以将 providedIn 选项引用到一个 Angular 模块,这类似于我们过去对混合 Angular 模块使用 forRoot 模式所做的事情,但有一些例外。

// modern-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

import { ModernMixedModule } from './modern-mixed.module';

@Injectable({
  providedIn: ModernMixedModule,
})
export class ModernSingletonService {
  constructor(private http: HttpClient) {}
}
// modern-mixed.module.ts
import { NgModule } from '@angular/core';

import { MyComponent } from './my.component';

@NgModule({
  declarations: [MyComponent],
  exports: [MyComponent],
})
export class ModernMixedModule {}

单例服务的现代 forRoot 替代方案。

与 'root' 选项值相比,使用此方法有两个不同之处:

  • 除非已导入提供的 Angular 模块,否则无法注入单例服务。
  • 由于单独的模块注入器,延迟加载的 Angular 模块和 AppModule 会创建自己的实例。

Providing primitive values

假设我们的任务是向 Internet Explorer 11 用户显示弃用通知。 我们将创建一个 InjectionToken。

这允许我们将布尔标志注入服务、组件等。 同时,我们只对每个模块注入器评估一次 Internet Explorer 11 检测表达式。 这意味着根模块注入器一次,延迟加载模块注入器一次。

在 Angular 版本 4 和 5 中,我们必须使用 Angular 模块为注入令牌提供值。

首先新建一个 token 实例:

// is-internet-explorer.token.ts
import { InjectionToken } from '@angular/core';

export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag');

然后新建一个 module,通过 factory 为该 token 指定运行时应该注入什么样的值:

// internet-explorer.module.ts
import { NgModule } from '@angular/core';

import { isInternetExplorer11Token } from './is-internet-explorer-11.token';

@NgModule({
  providers: [
    {
      provide: isInternetExplorer11Token,
      useFactory: (): boolean => /Trident\/7\.0.+rv:11\.0/.test(navigator.userAgent),
    },
  ],
})
export class InternetExplorerModule {}

以上是:Angular 4–5 dependency injection token with factory provider.

Angular 6 的改进:

从 Angular 版本 6 开始,我们可以将工厂传递给 InjectionToken 构造函数,从而不再需要 Angular 模块。

// is-internet-explorer-11.token.ts
import { InjectionToken } from '@angular/core';

export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag', {
  factory: (): boolean => /Trident\/7\.0.+rv:11\.0/.test(navigator.userAgent),
  providedIn: 'root',
});

使用工厂提供程序时,providedIn 默认为“root”,但让我们通过保留它来明确。 它也与使用 Injectable 装饰器工厂声明提供者的方式更加一致。

Value factories with dependencies

我们决定将 user agent 字符串提取到它自己的依赖注入令牌中,我们可以在多个地方使用它,并且每个模块注入器只从浏览器读取一次。

在 Angular 版本 4 和 5 中,我们必须使用 deps 选项(依赖项的缩写)来声明工厂依赖项。

// user-agent.token.ts
import { InjectionToken } from '@angular/core';

export const userAgentToken: InjectionToken<string> = new InjectionToken('User agent string');
// is-internet-explorer.token.ts
import { InjectionToken } from '@angular/core';

export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag');
// internet-explorer.module.ts,在一个 module 里同时提供两个 token 的值
import { Inject, NgModule } from '@angular/core';

import { isInternetExplorer11Token } from './is-internet-explorer.token';
import { userAgentToken } from './user-agent.token';

@NgModule({
  providers: [
    { provide: userAgentToken, useFactory: () => navigator.userAgent },
    {
      deps: [[new Inject(userAgentToken)]],
      provide: isInternetExplorer11Token,
      useFactory: (userAgent: string): boolean => /Trident\/7\.0.+rv:11\.0/.test(userAgent),
    },
  ],
})
export class InternetExplorerModule {}

不幸的是,依赖注入令牌构造函数目前不允许我们声明工厂提供程序依赖项。 相反,我们必须使用来自@angular/core 的注入函数。

// user-agent.token.ts
import { InjectionToken } from '@angular/core';

export const userAgentToken: InjectionToken<string> = new InjectionToken('User agent string', {
  factory: (): string => navigator.userAgent,
  providedIn: 'root',
});
// is-internet-explorer-11.token.ts
import { inject, InjectionToken } from '@angular/core';

import { userAgentToken } from './user-agent.token';

export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag', {
  factory: (): boolean => /Trident\/7\.0.+rv:11\.0/.test(inject(userAgentToken)),
  providedIn: 'root',
});

以上是 Angular 6 之后,如何实例化具有依赖关系的 injection token 的代码示例。

注入函数从提供它的模块注入器中注入依赖项——在这个例子中是根模块注入器。 它可以被 tree-shakable 提供者中的工厂使用。 Tree-shakable 基于类的服务也可以在它们的构造函数和属性初始化器中使用它。

Providing platform-specific APIs

为了利用特定于平台的 API 并确保高水平的可测试性,我们可以使用依赖注入令牌来提供 API。

让我们看一个 Location 的例子。 在浏览器中,它可用作全局变量 location,另外在 document.location 中。 它在 TypeScript 中具有 Location 类型。 如果你在你的一个服务中通过类型注入它,你可能没有意识到 Location 是一个接口。

接口是 TypeScript 中的编译时工件,Angular 无法将其用作依赖注入令牌。 Angular 在运行时解决依赖关系,因此我们必须使用在运行时可用的软件工件。 很像 Map 或 WeakMap 的键。

相反,我们创建了一个依赖注入令牌并使用它来将 Location 注入到例如服务中。

// location.token.ts
import { InjectionToken } from '@angular/core';

export const locationToken: InjectionToken<Location> = new InjectionToken('Location API');
// browser.module.ts
import { NgModule } from '@angular/core';

import { locationToken } from './location.token';

@NgModule({
  providers: [{ provide: locationToken, useFactory: (): Location => document.location }],
})
export class BrowserModule {}

以上是 Angular 4 - 5 的老式写法。

Angular 6 的新式写法:

// location.token.ts
import { InjectionToken } from '@angular/core';

export const locationToken: InjectionToken<Location> = new InjectionToken('Location API', {
  factory: (): Location => document.location,
  providedIn: 'root',
});

在 API 工厂中,我们使用全局变量 document. 这是在工厂中解析 Location API 的依赖项。 我们可以创建另一个依赖注入令牌,但事实证明 Angular 已经为这个特定于平台的 API 公开了一个——由@angular/common 包导出的 DOCUMENT 依赖注入令牌。

在 Angular 版本 4 和 5 中,我们将通过将其添加到 deps 选项来声明工厂提供程序中的依赖项。

// location.token.ts
import { InjectionToken } from '@angular/core';

export const locationToken: InjectionToken<Location> = new InjectionToken('Location API');
// browser.module.ts
import { DOCUMENT } from '@angular/common';
import { Inject, NgModule } from '@angular/core';

import { locationToken } from './location.token';

@NgModule({
  providers: [
    {
      deps: [[new Inject(DOCUMENT)]],
      provide: locationToken,
      useFactory: (document: Document): Location => document.location,
    },
  ],
})
export class BrowserModule {}

下面是新式写法:

和以前一样,我们可以通过将工厂传递给依赖注入令牌构造函数来摆脱 Angular 模块。 请记住,我们必须将工厂依赖项转换为对注入的调用。

// location.token.ts
import { DOCUMENT } from '@angular/common';
import { inject, InjectionToken } from '@angular/core';

export const locationToken: InjectionToken<Location> = new InjectionToken('Location API', {
  factory: (): Location => inject(DOCUMENT).location,
  providedIn: 'root',
});

现在我们有了一种为特定于平台的 API 创建通用访问器的方法。 这在测试依赖它们的 declarable 和服务时将证明是有用的。

Testing tree-shakable dependencies

在测试 tree-shakable 依赖项时,重要的是要注意依赖项默认由工厂提供,作为选项传递给 Injectable 和 InjectionToken。

为了覆盖可摇树依赖,我们使用 TestBed.overrideProvider,例如 TestBed.overrideProvider(userAgentToken, { useValue: 'TestBrowser' })。

Angular 模块中的提供者仅在将 Angular 模块添加到 Angular 测试模块导入时才用于测试,例如 TestBed.configureTestingModule({imports: [InternetExplorerModule] })。

Do tree-shakable dependencies matter?

Tree-shakable 依赖对于小型应用程序没有多大意义,我们应该能够很容易地判断一个服务是否在实际使用中。

相反,假设我们创建了一个供多个应用程序使用的共享服务库。 应用程序包现在可以忽略在该特定应用程序中未使用的服务。 这对于具有共享库的 monorepo 工作区和 multirepo 项目都很有用。

Tree-shakable 依赖项对于 Angular 库也很重要。 例如,假设我们在应用程序中导入了所有 Angular Material 模块,但仅使用了部分组件及其相关的基于类的服务。 因为 Angular Material 提供了摇树服务,所以我们的应用程序包中只包含我们使用的服务。

Summary

我们已经研究了使用 tree-shakable 提供程序配置注入器的现代选项。 与前 Angular 6 时代的提供者相比,可摇动树的依赖项通常更容易推理且不易出错。

来自共享库和 Angular 库的未使用的 tree-shakable 服务在编译时被删除,从而产生更小的包。