Angular依赖注入:全面指南


原文链接:https://angular-university.io/course/getting-started-with-angular2

在实际使用Angular依赖注入系统时,你需要知道的一切都在本文中。我们将以实用易懂并附带示例的形式解释它的所有高级概念。

Angular最强大、最独特的功能之一就是它内置的依赖注入系统。

大多数时候,依赖注入就那么工作着,我们使用它,几乎不会想到要归功于它的便利且直观的Angular API。

但也有些时候,我们也许需要深入研究一下依赖注入系统,手动配置它。

这种对依赖注入的深入了解,在下面的情况下是必须的:

  • 为了解决一些古怪的依赖注入错误
  • 为单元测试手动配置依赖
  • 为了理解某些第三方模块的不寻常的依赖注入配置
  • 为了创建一个能够在多个应用中装载跟使用的第三方模块
  • 为了以一个更加模块化的方式来设计你的应用
  • 为了确保你的应用中的各个部分很好的互相独立,不影响彼此

在本指南中,我们将准确理解Angular依赖注入是如何工作的,我们将涵盖它的所有配置项,并学习何时以及为什么使用那些特性。

我们将以一种非常实用并易于理解的方法来实现这一点,从头开始实现我们自己的提供商(provider)和注入令牌(injection token)。作为一个练习,使用基于示例的方式涵盖所有的特性。

随着时间的推移,作为Angular开发者,对Angular依赖注入系统的深入理解对你来说将是非常弥足珍贵的。

内容一览

在本文中,我们将覆盖以下主题:

  • 对依赖注入的介绍
  • 如何从头开始在Angular中设置依赖注入
  • 什么是Angular依赖注入提供商(provider)?
  • 如何编写我们自己的提供商?
  • 对注入令牌的介绍
  • 如何手动配置一个提供商?
  • 使用类名作为注入令牌
  • 提供商的简化配置:useClass
  • 理解Angular的多值依赖
  • 何时使用提供商useExisting
  • 理解Angular的分层依赖注入
  • 分层依赖注入的优势是什么
  • 组建分层依赖注入 - 一个示例
  • 模块分层依赖注入 - 一个示例
  • 模块依赖注入vs组建依赖注入
  • 配置依赖注入解决机制
  • 理解@Optional装饰器
  • 理解@SkipSelf装饰器
  • 理解@Self装饰器
  • 理解@Host装饰器
  • 什么是可摇树(Tree-Shakeable)的提供商?
  • 通过一个示例理解可摇树的提供商
  • 总结

本文是我们正在进行的Angular核心特性系列的一部分,你可以从这里找到所有的文章。

那么话不多说,让我们开始学习关于Angular依赖注入的所有必须知道的内容吧!

对依赖注入的介绍

那么依赖注入具体是什么呢?

当你正在开发系统中的一个更小的部分时,比如一个模块或者一个类,你将需要一些外部的依赖。

举个例子,像其他的依赖一样,你可能需要一个HTTP服务去做调用后端。

每当需要它们的时候,你也许甚至会尝试在本地创建属于你自己的依赖。像下面这样:

export class CoursesService() {

   http: HttpClient;

   constructor() {
     this.http = new HttpClient(... dependencies needed by HTTPClient ...);
   }
...
}

01.ts

这看起来像是一个简单的解决办法,但是这段代码有个问题:非常难于测试

因为这段代码知道本身的依赖,并直接创建了它们。你无法将实际的HTTP客户端替换为一个模拟HTTP客户端,也无法替换为单元类。

注意这个类不仅知道如何创建自己的依赖,还知道它的依赖的依赖,意味着它也知道HTTPClient的依赖。

按照这段代码的写法,基本上无法在运行时将这个依赖替换为其他可选内容,比如:

  • 出于测试目的
  • 也因为你可能需要在不同的运行时环境中使用不同的HTTP客户端,比如在服务器上和在浏览器中。

把这个跟相同类的一个变更版本进行比较,它用了依赖注入:

@Injectable()
export class CoursesService() {

   http: HttpClient;

   constructor(http: HttpClient) {
     this.http = http;
   }
...
}

02.ts

正如你在这个新版本中看到的,这个类无法知道如何创建它的http依赖。

这个新版的类简单地从构造函数的输入参数中接受它所需要的所有的依赖,就是这样!

这个新版本的类只是知道如何使用它的依赖去实现具体的任务,但是它不知道依赖的内部是如何工作的,依赖是如何被创建的,也不知道依赖的依赖是什么。

用于创建依赖的代码已经从当前类中移除,被放在了你代码库的某个地方,归功于@Injectable()装饰器的使用。

有了这个新的类,可以非常简单地:

  • 为了测试的目的,替换某个依赖的实现
  • 支持多运行时环境
  • 在使用了你的服务作为第三方的代码库中,提供服务的新版本···

这种只从输入中接收你的依赖,不知道它们内部是如何工作,以及如何创建的技术,就叫做依赖注入,它是Angular的基础。

现在让我们来学习Angular依赖注入具体是如何工作的。

如何从头开始在Angular中设置依赖注入?

理解Angular中依赖注入的最好的方法,就是从头开始,取一个简单的TypeScript类,不应用任何的装饰器到该类,然后手动把它转变为Angular可注入服务。

比听起来还要简单。

让我们从一个简单的服务类开始,没有任何的@Injectable()装饰器应用到该类:

export class CoursesService() {

   http: HttpClient;

   constructor(http: HttpClient) {
     this.http = http;
   }
...
}

03.ts

我们可以看到,这不过是一个简单的TypeScript类,它期望从构造函数中注入一些依赖。

但其实这个类根本没有途径联结到Angular依赖注入系统。

让我们来看下,把这个类当作一个依赖注入到另外一个类中,会发生什么:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor(private coursesService: CoursesService) {
      ...
    }
    ...
}

04.ts

我们可以看到,我们尝试注入这个类的一个实例当作依赖。

但是我们的类没有联结到Angular的依赖注入系统,所以我们程序中的哪一块会知道如何通过调用CoursesService构造函数来创建这个类的实例,然后当作依赖传入呢?

答案很简单:不可能!而且我们会得到一个错误!

 NullInjectorError: No provider for CoursesService!

注意错误信息:很明显缺少某个被称为provider的东西。

你可能之前见过类似的信息,在开发中时有发生。

现在让我们来理解一下这个信息具体是什么意思,以及如何解决它。

什么是Angular依赖注入提供商?

错误信息“没有提供商”仅仅表示Agnular依赖注入系统无法实例化一个给定的依赖,因为它不知道如何创建它。

为了让Angular知道如何创建一个依赖,像是比如在CourseCardComponent的构造函数中注入CoursesService的实例,它需要知道什么可以被称为提供商工厂函数。

提供商工厂函数就是一个简单的函数,Angular可以调用它来创建依赖,很简单:它就是一个函数

那个提供商工厂函数可以使用我们即将谈到的一些简单的约定方式被Angular隐式创建。这个实际上就是通常我们的大部分依赖发生的情况。

不过根据需要,我们也可以自主编写那个函数。

在任何情况下,必须要理解的是,在你应用程序的每一个依赖中,让它成为一个服务,一个组件,或者其他什么,在某个地方有一个简单的函数正在被调用,它知道如何创建你的依赖。

如何编写我们自己的提供商?

为了真正理解一个提供商是什么,让我们为CoursesService类编写我们自己的提供商工厂函数:

function coursesServiceProviderFactory(http:HttpClient): CoursesService {
  return new CoursesService(http);
}

05.ts

正如你所看到的,这就是一个普通的函数,它接收CoursesService需要的任何依赖项作为输入。

这个提供商工厂函数接着手动调用CoursesService的构造函数,传入所有需要的依赖项,然后返回CoursesService的新的实例作为输出。

那么任何时候Angular的依赖注入系统需要一个CoursesService的实例,它仅仅只需要调用这个函数!

这看起来很简单,但问题是Angular依赖注入系统暂时还不知道这个函数。

更为重要的是,即使Angular知道这个函数,它如何会知道要调用它去注入这个特殊的依赖项呢:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor(private coursesService: CoursesService) {
      ...
    }
    ...
}

04.ts

我的意思是,没法让Angular将被注入的CoursesService的实例跟这个提供商工厂函数关联起来,对吧?

介绍注入令牌

那么Angular如何知道在哪里注入什么,还有提供商工厂函数要调用什么去创建哪个依赖项?

Angular需要能够以某种方式归类依赖项,为了识别一个给定的依赖项集合是属于同样类型

为了独一无二地识别一组依赖项,我们可以定义一些东西当作Angular的注入令牌。

下面是我们如何为我们的CoursesService依赖项手动创建我们的注入令牌:

export const COURSES_SERVICE_TOKEN = 
      new InjectionToken("COURSES_SERVICE_TOKEN");

06.ts

这个注入令牌对象将在依赖注入系统中被用来明确地识别我们的依赖项CoursesService

这个依赖注入令牌是一个对象,所以它是独一无二的,不像比如说字符串。

因此,可以使用这个令牌对象来唯一地识别一组依赖项。

那么我们如何使用它呢?

如何手动配置一个提供商?

现在我们已经拥有了提供商工厂函数还有注入令牌,我们可以在Angular依赖注入系统中配置一个提供商,它会知道如何根据需要创建CoursesService的实例。

提供商本身就是一个简单的配置对象,我们把它传给一个模块或者组件的providers数组中:

@NgModule({
  imports: [
    ...
  ],
  declarations: [
    ...
  ],
  providers: [
      {
      provide: COURSES_SERVICE_TOKEN,
      useFactory: coursesServiceProviderFactory,
      deps: [HttpClient]
    }
    ]
})
export class CoursesModule { }

07.ts

正如我们所见,这个手动配置的提供商需要定义下列项:

  • useFactory:它应该包含对提供商工厂函数的一个引用,当需要创建依赖项和注入它们的时候,Angular会调用这个提供商工厂函数
  • provide:它包含了关联到这种依赖项的注入令牌。注入令牌会帮助Angular决定何时使用或不使用一个给定的提供商工厂函数
  • deps:这个数组包含了任何useFactory函数需要运行的依赖项,在这个例子中是HTTP client

那么现在Angular知道了如何创建CoursesService的实例,对吧?

让我们看看假如我们尝试注入一个CoursesService的实例到我们的应用程序中,会发生什么:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor(private coursesService: CoursesService) {
      ...
    }
    ...
}

04.ts

我们也许有点惊讶,看到同样的错误又发生了:

NullInjectorError: No provider for CoursesService!

那么这里发生什么了?我们不是刚刚定义了提供商吗?

对,我们是定义了提供商,但是当Angular试图创建这个依赖项时,它无法知道它是否需要使用我们特定的提供商工厂函数,对吧?

那么我们如何做出那个关联呢?

我们需要显式地告诉Angular它应该使用我们的提供商来创建这个依赖项。

我们可以在任何CoursesService被注入的地方使用@Inject注释来做这个:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor( @Inject(COURSES_SERVICE_TOKEN) private coursesService: CoursesService) {
      ...
    }
    ...
}

08.ts

正如我们所看到的,显式使用@Inject装饰器允许我们告诉Angular,为了创建这个依赖项,它需要使用关联到COURSES_SERVICE_TOKEN的指定的提供商。

这个注入令牌从Angular的视角,独一无二地识别出一个依赖项类型,这就是依赖注入系统如何知道使用哪个提供商的。

因此现在Angular知道了调用哪个提供商工厂函数去创建正确的依赖项,它就是这样做了。

然后有了这些,我们的应用程序现在正确地工作着,不再有错误了!

我想现在你对Angular的依赖注入系统是如何工作的应该有了一个很好的理解,不过我猜你可能会在想:

但是为什么我从来没有手动配置过提供商呢?

你看,即使一般你不必自己手动配置提供商工厂函数或者注入令牌,这些其实都在底层发生着。

对于你的应用程序中每个单独的依赖项类型而言,服务、组件后者其他的,永远都会有一个提供商,并且永远都有一个注入令牌,后者其他的机制来独一无二地识别一个依赖类型。

这是有意义的,因为你的类的构造函数需要在你的系统的其他地方被调用,Angular总是需要知道创建哪个依赖项,对吧?

因此即使当你用简化的方式来配置你的依赖项的时候,底层永远都有一个提供商。

为了更好地理解这点,让我们逐渐简化我们提供商的定义,一直到我们碰到那些你更加熟悉的地方。

使用类名作为注入令牌

Angular依赖注入系统的最有趣的特性之一,就是你可以使用在JavaScript运行时能保证唯一的任何事物,来识别一个依赖项类型,它不必非要是一个显式的注入令牌对象。

举例来说,在JavaScript的运行时,构造函数用来代表类名,指向某个函数的引用比如说它的名字,被保证在运行时是唯一的

类名可以在运行时被它的构造函数唯一地表示,因为它保证是唯一的,它可以被用来作为注入令牌。

因此我们可以利用这个强大的特性,稍微简化我们提供商的定义:

@NgModule({
  imports: [
    ...
  ],
  declarations: [
    ...
  ],
  providers: [
      {
      provide: CoursesService,
      useFactory: coursesServiceProviderFactory,
      deps: [HttpClient]
    }
    ]
})
export class CoursesModule { }

09.ts

正如我们所见,我们手动创建的用来识别我们的依赖项类型的注入令牌COURSES_SERVICE_TOKEN,已经不再需要了。

其实,我们已经把那个对象从我们的代码库中一并移除了,因为在服务类的特定用例中,我们可以使用类名本身来识别依赖项类型!

不过如果不做更多修改,我们尝试运行程序的话,我们可能又会得到错误no provider

为了再次正常工作,你还需要使用CoursesService的构造函数来识别你需要哪个依赖项:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor( @Inject(CoursesService) private coursesService: CoursesService) {
      ...
    }
    ...
}

10.ts

然后有了这个,Angular知道了要注入哪个依赖项,一切又如期工作了!

所以好消息是,在大多数情况下,我们无需显式地创建一个注入令牌。

现在让我们来看下我们如何进一步地简化我们的提供商。

简化提供商的配置:useClass

不同于使用useFactory显式地定义一个提供商工厂函数,我们有其他办法告诉Angular如何实例化一个依赖项。

在提供商的情况下,我们可以使用useClass属性。

在这种方式下Angular会知道我们传入的值是一个合法的构造函数,Angular可以简单地使用new操作符来调用它:

@NgModule({
 imports: [
   ...
 ],
 declarations: [
   ...
 ],
 providers: [
     {
     provide: CoursesService,
     useClass: CoursesService,
     deps: [HttpClient]
   }
   ]
})
export class CoursesModule { }

11.ts

这已经相当地简化了我们的提供商,因为我们不需要自己手动编写一个提供商工厂函数。

useClass的另外一个超级便利的特性就是,对于这个依赖项类型,基于TypeScript的类型注释,Angular会在运行时推断注入令牌。

这意味着,有了useClass依赖项,我们甚至不再需要Inject装饰器,这可以为什么你极少看到它:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor(private coursesService: CoursesService) {
      ...
    }
    ...
}

12.ts

那么Angular是如何知道注入哪个依赖项的呢?

Angular可以通过检查被注入属性的类型来决定,这里是CoursesService,然后使用那个类型为那个依赖项决定一个提供商。

正如我们所见,类依赖项使用起来更为方便,相对于不得不显示地使用@Inject

useClass提供商的特定情况下,我们可以更加简化这一切。

无需手动定义提供商对象,我们可以简单地传入类本身的名字作为合法的提供商配置项:

@NgModule({
  imports: [
    ...
  ],
  declarations: [
    ...
  ],
  providers: [
    CoursesService
  ]
})
export class CoursesModule { }

14.ts

Angular会确定这个提供商是一个构造函数,因此Angular会检查这个函数,它会创建一个工厂函数确定必要的依赖项,然后根据需要创建这个类的实例。

这是基于函数的名称隐式地发生的。

这是你通常在大多数情况下用到的记号方法,它超级简单易用!

有了这个简化的记号方法,你甚至不会意识到在幕后有提供商和注入令牌。

不过要注意,仅仅像这样设置你的提供商是不会工作的,因为Angular不会知道如何查找这个类的依赖项(记住属性deps)。

为了让它工作,你仍然需要应用Injectable()装饰器到这个服务类中:

@Injectable()
export class CoursesService() {

   http: HttpClient;

   constructor(http: HttpClient) {
     this.http = http;
   }
...
}

15.ts

这个装饰器将会告诉Angular通过在运行时检查构造函数参数的类型来尝试查找该类的依赖项!

因此正如你所见,这个相当简化了的记号方法,就是我们通常使用Angular依赖注入系统的方式,甚至没有考虑到在底层使用的具体细节。

有件事要记住,就是useClass选项将不会与接口名称工作,它只工作于类的名称

这是因为接口只是TypeScript语言的仅在编译时的结构,因此接口不会存在于运行时。

这意味着接口名称,不像类名(通过它的运行时构造函数),不会被用来唯一地识别依赖项类型。

除了提供商、依赖项和注入令牌的基本概念以外,还有一些其他的你必须记住的关于Angular依赖注入系统的东西。

理解Angular的多值依赖

我们系统中大多数的依赖项都只对应于一个值,比如一个类。

但是有一些情形下,我们想要多个不同值的依赖项。

一个你应该已经遇到的很常见的例子就是表单控件的值的访问器。

这些是特殊的表单指令,它们绑定到一个给定的表单控件,让表单控件的值对于表单模块(Forms module)是可见的。

问题是不会仅有一个像这样的指令,有很多。

但是如果全部独立地配置这些依赖项,那将很不实用,因为通常你想要一次性的一起访问它们。

因为解决办法就是拥有一个特殊的依赖项类型,它会接收多个值,不仅仅一个,关联到相同的依赖注入令牌。

在表单控件的值访问器的情况下,那个特殊的令牌就是NG_VALUE_ACCESSOR注入令牌。

举个例子,下面是一个自定义表单控件的示例,它想把自己注册为一个控件的值访问器:

@Component({
  selector: 'choose-quantity',
  templateUrl: "choose-quantity.component.html",
  styleUrls: ["choose-quantity.component.scss"],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi:true,
      useExisting: ChooseQuantityComponent
    }
  ]
})
export class ChooseQuantityComponent implements ControlValueAccessor {

}

16.ts

注意在这里我们为NG_VALUE_ACCESSOR注入令牌定义了一个提供商。

但是如果我们不使用multi属性,这个注入令牌的值将会被覆写。(后面会提到)

但是因为multi被设置为true,我们实际上把值添加到了依赖项的值的数组中,而不是覆写它。

任何需要所有控件值的访问器的组件或者指令,将会通过请求NG_VALUE_ACCESSOR的注入来接收它们。

这将对应于包含所有标准表单控件值访问器的数组,以及我们自定义的数组。

什么时候使用useExisting提供商

还要注意为了创建提供商,useExisting选择的使用。

当我们想基于其他已经存在的提供商创建一个提供商时这个选项是很有用的。

在这个例子中,我们仅仅想用一种简单的方式通过指明ChooseQuantityComponent的类的名称来定义一个提供商,我们已经学习过可以使用这个类名作为提供商。

useExisting功能也对一个已经存在的提供商定义别名很有用。

现在关于提供商和注入令牌是如何工作的,我们已经有了一个很好的理解,让我们谈谈Angular依赖注入系统的另一个基本方面。

理解Angular的分层依赖注入

跟之前的AngularJS版本不同,Angular的依赖注入系统可以说是分层的。

那么这具体是什么呢?

如果你注意到了,在Angular中你可以在多个地方为你的依赖项定义提供商:

  • 在模块层级
  • 在组件层级
  • 或者甚至在指令层级!

那么在所有这些不同的地方定义提供商,有什么区别呢?它是如何工作的?以及为什么会有那些不同的选择呢?

你能在多个地方定义提供商,是因为Angular的依赖注入系统是分层式的。

你看,如果你在某处需要一个依赖项,比如你需要注入一个服务到组件中,Angular首先会尝试在组件的提供商列表中查找那个依赖项的提供商。

如果Angular在组件本身的层级中没有找到需要该依赖项的提供商,那么Angular会尝试在父组件中查找那样的提供商。

如果找到了提供商,它会实例化并使用它,但如果没有,它会询问父组件的父组件是否有它需要的提供商,以此类推。

这个过程会重复到应用程序的根组件为止。

如果在此过程中没有找到提供商,你知道会发生什么的:对,我们得到我们的老朋友“No provider found”信息。

这个在组件树中一直向上查找正确的提供商的过程,就是依赖解析,因为它遵循我们组件树的分层式结构,我们说Angular依赖系统是分层式的。

我们也需要知道为何这个特性是有用的。

分层式依赖注入的好处是什么?

Angular典型地被用来构建大型应用程序,在某些情况下可能会相当大。

管理这个复杂度的一个方法就是把应用程序分解为许多封装好的小模块,这些模块本身又分解为定义良好的组件树。

页面中这些不同的部分需要特定的服务还有其他的依赖项来工作,这些依赖项也许会或者不会想要与应用程序中其他部分共享。

我们可以想象页面中一个完全隔离的部分,与应用程序的其他部分相比,它以一种完全独立的方式工作,具有一系列服务的本地副本和它需要的其他依赖项。

我们想要确保这些依赖项保持私有,并且无法被应用程序的其他地方所接触到,这样来避免BUG和其他维护的问题。

在应用程序中我们的独立的部分使用的一些服务,可能与其他部分共享,或者与组件树中更深一层的父组件分享,同时其余依赖项是私有的。

分层式依赖注入系统允许我们实现这个!

利用分层式依赖注入,我们可以隔离应用程序的各个部分,给它们不与应用程序中其他部分共享的私有的依赖项,我们可以让父组件仅与子组件共享某些依赖项,但不与组件树中其他部分共享,以此类推。

分层式依赖注入系统允许我们以更模块化的方式构建我们的系统,允许我们仅在需要的时候在应用程序的不同部分之间共享依赖项。

这个最初的解释是一个很好的起点,但是要真正理解这一切是如何工作的,我们需要一个完整的示例。

通过示例理解组件分层式依赖注入

比如,让我们用我们的CoursesService类。如果我们尝试在Angular的组件树中到处注入这个类的话,会发生什么呢?

会发生啥?我们会得到这个服务的多个实例吗?或者只有一个实例?它是如何工作的呢?

为了帮助我们理解这些,让我们给CoursesService的每个实例一个唯一识别符,那样我们可以更好的理解发生了什么:

let counter = 0;

@Injectable()
export class CoursesService() {

  constructor(private http: HttpClient) {
      counter++;
      this.id = counter;
  }
...
}

17.ts

现在我们来创建一个简单的组件层,把CoureseService注入到多个地方,然后看看会发生什么!

让我们来创建一个根应用程序组件,在这个组件的模板中使用一个子组件course-card

下面是根组件app.component.html模板:

18.html

我们可以看到,这个组件内部在ngFor循环中使用了course-card组件。

下面是这个组件类文件app.component.ts

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css'],
    providers: [
      CoursesService
    ]
})
export class AppComponent {

    constructor(private coursesService: CoursesService) {
      console.log(`App component service Id = ${coursesService.id}`);
    }
...
}

19.ts

注意我们把CoursesService添加到根组件的提供商中,我们还把获得的这个服务的实例的唯一识别符打印到控制台中。

最后,下面是course-card组件的样子,这是course-card.component.ts文件:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css'],
    providers: [
      CoursesService
    ]
})
export class CourseCardComponent {

    constructor(private coursesService: CoursesService) {
      console.log(`course card service Id = ${coursesService.id}`);
    }
...
}

20.ts

注意这里我们也把CoursesService加入到了这个组件的提供商的列表中,并打印这个服务实例的id到控制台。

如果现在启动应用程序,你觉得会发生什么呢?

你认为有多少个CoursesService的实例会被创建呢?哪些服务被注入到哪一个course-card组件中呢?

下面是控制台的输出:

App component service Id = 1
course card service Id = 2
course card service Id = 3
course card service Id = 4
course card service Id = 5
course card service Id = 6
course card service Id = 7
course card service Id = 8
course card service Id = 9
course card service Id = 10
course card service Id = 11

所以这里发生了啥?让我们拆解发生的内容。

那么看起来应用程序的根组件app.component.ts是第一个创建了服务实例的,因此它有Id = 1。

当尝试获取CoursesService依赖项的时候,根组件首选查看它自己的提供商列表,然后它找到匹配的提供商并使用了它。

但是在course-card组件的内部发生了什么呢?

与程序的根组件不同,course-card组件有10个实例被创建了,并显示在了屏幕上。

每个course-card的实例需要一个CoursesService,所以它尝试通过查找自身的提供商列表来实例化服务。

每个course-card组件实例在它的私有提供商列表中找到了匹配的提供商,然后用它创建了一个新的CoursesService实例,注入到了它的依赖项中。

这表示每个course-card实例创建了它们各自独立的CoursesService实例,不需要向父组件索要CoursesService的实例。

有10个course-card实例,每个都拥有自己的私有的CoursesService实例,这就解释了上面的日志!

注意,这些私有的CoursesService实例跟它们的course-card实例的组件生命周期关联起来了。

所以如果course-card的组件实例被销毁了,它们相应的CoursesService实例也会被垃圾回收。

不过大多数时候CoursesService是一个无状态的类(本例的计数器除外),因为没有必要创建这么多实例。

我们更想要的是只有一个CoursesService实例,由根组件创建,并且与它的所有的子组件共享。

我们可以通过从CourseCardComponent的提供商列表中移除提供商CoursesService

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css'],
    providers: []
})
export class CourseCardComponent {

    constructor(private coursesService: CoursesService) {
      console.log(`course card service Id = ${coursesService.id}`);
    }
...
}

21.ts

注意这个组件的空的提供商列表,我们甚至也可以把providers属性一并移除了。

如果我们这么做,然后再跑一次我们的程序,下面是我们得到的:

App component service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1

正如我们所看到的,我们的程序现在正如预期,只有一个CoursesService的实例了。

那么现在我们已经搞懂了只在组件树的情况下分层式依赖注入是如何工作的了 - Angular先在组件中查找提供商的匹配,然后扫描它所有的父组件。

那模块呢?模块也可以拥有它们自己的提供商,对吧?

通过示例理解模块分层式依赖注入

让我们把这些组件先这样放着,表示:

  • 应用的根组件有一个CoursesService提供商
  • course-card组件没有属于自己的私有的提供商

注意,course-card组件是模块CoursesModule的一部分。

那么如果我们保持这些组件的提供商不变,在模块级别再添加两个提供商,会发生什么呢?

首先让我们在CoursesModule层级新增一个提供商:

@NgModule({
  imports: [
    CommonModule
  ],
  declarations: [
      CourseCardComponent
  ],
    exports: [
        CourseCardComponent
    ],
    providers: [
      CoursesService
    ]
})
export class CoursesModule { }

22.ts

这是一个特性模块,它是应用程序根模块的一部分。

现在让我们在根模块级别在同样新增一个提供商:

@NgModule({
    declarations: [
        AppComponent
    ],
    imports: [
        BrowserModule,
        HttpClientModule,
        CoursesModule
    ],
    providers: [
      CoursesService
    ],
    bootstrap: [AppComponent]
})
export class AppModule {}

24.ts

那么现在在应用程序中我们又有了两个额外的提供商,总共有三个:

  • 一个在根组件AppComponent级别
  • 一个在特性模块CoursesModuls级别
  • 一个在应用程序根模块AppModule级别

如果我们现在运行程序,你觉得会发生什么?

下面是控制台输出:

App component service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1

是完全相同的输出!

那这里发生什么了呢?

模块vs组件 依赖注入层次结构

实际上正在发生的是有两个独立的依赖注入层次结构:

  • 有一个组件级别的层次结构,遵循页面上的组件树结构
  • 不过还有一个模块级别的独立的注入层次结构

组件级别的层次结构优先于模块注入层次结构!

所以当Angular为了组件或者服务尝试查找一个依赖项的时候,如果可能的话,它会先尝试通过组件级别的提供商创建依赖项。

如果Angular追踪组件一直向上到达根组件,都没有找到匹配的提供商,仍然什么都没找到的话,只有这个时候Angular才会尝试在模块的层级结构的级别上查找匹配的提供商。

然后Angular从当前模块的提供商们开始查找匹配的提供商。

如果没有找到,Angular会尝试到当前模块的父模块中查找,以此类推。直到应用程序的根模块。

由两个分开的注入层次结构组成的系统,允许我们在两个维度模块化我们的应用程序:

  • 通过使用组件注入层次结构,我们可以在组件树的特定部分提供某些依赖项,从而模块化应用程序
  • 不过我们可以同样通过使用模块注入层次结构,模块化和创建一个服务的多个独立版本,只在程序的某些模块中使用它们

现在我们已经熟悉了层级化依赖注入系统如何工作的主要概念,现在让我们学习一下如何更近一步配置它的依赖解析机制。

配置依赖注入解析机制

正如我们已经学习到的,Angular组件依赖注入解析机制总是从当前组件开始,然后向上扫描匹配的提供商一直到应用程序的根组件,它找不到匹配的依赖项就抛出一个错误。

但如果我们想稍微调整一下这个行为呢?

理解@Optional装饰器

举个例子,如果因为这个依赖项也许不需要,你可能有一个替代项来使用它,我们想阻止最后的错误被抛出呢?我们可以使用@Optional装饰器让依赖项变为可选择:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor(@Optional() private coursesService: CoursesService) {
      ...
    }
    ...
}

27.ts

如果没有找到这个依赖项的提供商的话,这会阻止错误被抛出。但是你需要确定你的组件检查了这个依赖项是否存在,如果不存在,则提供替代方案。

理解@SkipSelf操作符

你也可以稍微调整一下依赖解析机制开始寻找匹配提供商的地方。

举个例子,你可能在组件级别有一些服务的提供商,你想把它们提供给子组件使用,但是呢这个组件本身也需要这个服务的一个实例来工作,并且它需要从它的父组件而不是自身的提供商列表中来获取它。

补充说明一下,这肯定是非常罕见的情况!

但假如你曾经碰到这种情况了,你可以通过使用@SkipSelf装饰器跳过本地的组件提供商,然后从直接从父组件开始匹配,一直到达根组件:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css'],
    providers: [
       CoursesService
    ]
})
export class CourseCardComponent  {

    constructor(@SkipSelf() private coursesService: CoursesService) {
      ...
    }
    ...
}

28.ts

在我们的程序中,这会导致跳过本地的CoursesService提供商,意味着本地的那个提供商仅对子组件course-card可用。

如果运行我们的程序,下面是我们会得到的日志:

App component service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1
course card service Id = 1

正如我们所见,CourseCardComponent级别的提供商被跳过了,使用了CoursesModule级别的提供商。

理解@Self装饰器

除了配置从哪里我们开始依赖项的匹配过程以外,我们还可以稍微调整一下匹配过程在何处结束。

举个例子,如果我们希望组件只在它自身的提供商列表中查找某个依赖项,并跳过检查所有的父组件,我们可以使用@Self装饰器来实现这个:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css'],
    providers: [
       CoursesService
    ]
})
export class CourseCardComponent  {

    constructor(@Self() private coursesService: CoursesService) {
      ...
    }
    ...
}

29.ts

这会儿,会采取CoursesService的本地的提供商,在CoursesModule级别的父级实例都被跳过。

下面是这种情况下我们程序的日志:

App component service Id = 1
course card service Id = 2
course card service Id = 3
course card service Id = 4
course card service Id = 5
course card service Id = 6
course card service Id = 7
course card service Id = 8
course card service Id = 9
course card service Id = 10
course card service Id = 11

正如我们所见,我们再次掉进了这样的场景:组件的每个实例都拥有各自私有的服务实例了。

理解@Host装饰器

迄今为止我们一直在讨论组件,已经它们是如何与依赖注入系统交互的,但指令呢?

因为组件只不过是一种特殊的指令,我们迄今为止学到的关于组件的东西都可以应用到指令上。

不过有一个特殊的情况:假设这个组件有一个由自身提供商创建的私有的服务实例。

现在或许有一个指令应用到了那个组件上,指令在同一个模块中,它被设计为跟那个特别的组件紧密交互。

因为该指令与这个组件紧密耦合,它可能会访问与组件关联的一些私有的服务,而不是其他服务。

假设有一个简单的HighlightedDirective,它被用来在视觉上高亮它所应用的一个课程卡片。

假设设计这个指令,用来与CourseCardComponent紧密交互,它要访问组件中CoursesService的私有实例。

该指令可以用下面的方式来访问私有的组件内的服务:

@Directive({
    selector: '[highlighted]'
})
export class HighlightedDirective {

    constructor( @Host() private coursesService: CoursesService) {
        console.log('coursesService highlighted ' + coursesService.id);
    }
...
}

30.ts

使用@Host装饰器来配置Angular应该在何处停止查找依赖项,与@Self做的事情相似。

但在这个例子中,该装饰器仅在指令中使用,它表示Angular仅在指令的宿主组件的提供商列表中查找匹配的提供商,不会去其他地方查找。

现在让我们把该指令应用到我们应用程序中的每一个course-card组件中:

31.html

注意使用highlighted的属性,它为每个CourseCardComponent实例分配一个HighlightedDirective同伴实例。

如果现在我们运行应用程序,我们会得到下面的控制台输出:

App component service Id = 1
course card service Id = 2
coursesService highlighted 2
course card service Id = 3
coursesService highlighted 3
course card service Id = 4
coursesService highlighted 4
course card service Id = 5
coursesService highlighted 5
course card service Id = 6
coursesService highlighted 6
course card service Id = 7
coursesService highlighted 7
course card service Id = 8
coursesService highlighted 8
course card service Id = 9
coursesService highlighted 9
course card service Id = 10
coursesService highlighted 10
course card service Id = 11
coursesService highlighted 11

正如我们所看到的,每个HighlightedDirective的实例都可以访问该指令所应用的同伴CoursesService的私有实例。

至此,我们已经全面覆盖了我们能够配置Angular依赖解析机制的多种途径。

现在我们很好的理解了分层依赖注入是如何工作的,让我们来谈谈Angular依赖注入系统的另一个强大的特性。

什么是可树抖动(Tree-Shakeable)的提供商?

到现在为止我们使用的作为示例的提供商都缺少一个很重要的属性:它们是不可树抖动的。

那么这是什么意思呢?

一直以来,那些我们已经定义了的提供商都是通过使用providers属性显式地添加到组件或者模块的提供商列表中的。

但这种方法存在一个实际问题。

假设一个应用程序在某个模块中有一个依赖项,该模块又导入了其他模块,然后该模块提供了一个服务类。

现在假设这个被导入的服务类,实际上在任何地方都没有被用到,无所谓什么原因!

并且这确实是一个非常普遍的场景。

假如你在使用比如AngularFire或者其他大的拥有海量功能的第三方模块,这些模块包含了各种在应用程序中你想要或者不想要的服务。

你也许会用到一些服务,但很可能不会用到全部服务。

想法就是我们应该能够导入一个模块,但如果用不到某些服务的话,我们没必要在生产包中包含它们。

那么如何做到呢?

什么可树抖动?

当使用Angular CLI构建我们的生产包时,它会尽可能尝试“tree shake”那些没必要出现在包中的代码,为了尽量减轻包的大小。

CLI会尝试静态检查代码中的TypeScript依赖,然后确定某个依赖是否被使用到。

如果它发现某个依赖项没有被使用到,它会把那个依赖从包中移除,这样一来就减轻了包的大小。

但如果我们直接在模块或者组件的提供商属性中给这个模块添加未被使用的服务,我们需要首先使用TypeScript import直接导入那个模块或者服务。

这个TypeScript import将有效地防止摇树服务删除未使用的服务。

树抖动器看到这个服务通过TypeScript import被显式地导入了,于是它就错误地认为这个服务被使用着,然后它将不会从生产包中移除这个服务。

那么我们如何来解决这个问题呢?

我们需要可摇树的提供商。

通过一个示例理解可摇树的提供商

我们要做的是定义CoursesService作为模块CoursesModule的一部分。

在我们的应用程序中,它完全可能成为我们引入的一个第三方模块。

我们想以某种方式为CoursesService定义一个提供商,这种方式就是如果它被任何引用了CoursesModule的人使用了的话,将把它包含在最终的包中。

但是如果因为某些原因,应用程序引入了CoursesModule,但是最终都没有用到CoursesService,那么这个服务就不应该被包含到包中。

为了实现这个,第一件要做的事就是从CoursesModule的提供商列表中移除CoursesService

@NgModule({
  imports: [
    CommonModule
  ],
  declarations: [
      CourseCardComponent
  ],
    exports: [
        CourseCardComponent
    ]
})
export class CoursesModule { }

24.ts

但是现在我们不会得到“provider not found”的错误信息吗?

是的,因为已经没有定义了的提供商了。

下面是我们如何在CoursesModule级别为CoursesService定义一个提供商,而无需在courses.module.ts文件中引用它:

@Injectable({
  providedIn: CoursesModule
})
export class CoursesService() {

  constructor(private http: HttpClient) {

  }
...
}

25.ts

你可以看到,我们反转了依赖项的顺序,我们在CoureseService的内部引入了CoursesModule,并且使用@Injectable装饰器在服务类自身中定义了模块级别的提供商。

用这种方式,CoursesModule根本没有引入服务类,因此如果服务没有被用到,这个服务将按照预期从生产包中被摇树掉(移除)。

注意,@Injectable装饰器允许我们以各种方式定义提供商,不仅仅模块级别的提供商。

我们同样可以访问选项useClassuseValueuseExisting以及deps,就像在模块或者组件级别定义提供商时候一样。

使用providedin,我们不仅可以定义模块级别的提供商,通过让服务在根模块依赖注入处可用,还可以将服务提供给其他的模块

@Injectable({
  providedIn: "root"
})
export class CoursesService() {

  constructor(private http: HttpClient) {

  }
...
}

26.ts

你应该很熟悉这个最常见的句法了。

使用这个句法,CoursesService现在是应用程序范围的单例,意味着在整个应用程序中,只有该服务的一个实例,这在我们的例子中是有意义的,因为我们的服务是无状态的。

至此,本节就结束了,现在让我们快速总结一下这篇文章中所学到的关键要点。

总结

我们已经知道,Angular依赖注入系统的背后在做着很多事情。

依赖系统是非常灵活的,它有很多强大的配置项。

它最常见的用法就是简单地把一个类名丢进提供商列表中!

然而,当你尽量以模块化方式设计你的应用程序的时候,详细了解依赖注入系统的工作细节,将会非常有用!

依赖注入系统,因为它是分层的,而且因为它包含两个独立的注入层次(组件和模块),所以允许你以非常细粒度的方式定义哪些依赖在应用程序的哪个部分是可见的,哪些依赖是隐藏的。

这些特性非常有用,特别是当你创建了一个第三方模块,并且你想要发布的服务可能会在许多不同的应用程序中使用时,但如果你正在模块化一个大型应用程序时也是如此。

尽管所有这些功能强大的特性都是可用的,但大多数时候默认的配置机制都能提供帮助,而且非常容易使用。

我希望你会喜欢本文,如果你想学习Angular的其他强大的核心特性,我推荐你们看下课程Angular Core Deep Dive,该课程中详细涵盖了依赖注入以及好多其他的特性。

此外,如果你有什么问题或意见,请在下方的评论中告诉我,我会回复你的。

如果你正在开始学习Angular,看一下这个课程Angular for Beginners Course。

-- The End --