lucienlugeek

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

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

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

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

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

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

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

我们将以一种非常实用并易于理解的方法来实现这一点,从头开始实现我们自己的provider和injecttion 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<CoursesService>("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和其他维护的问题。

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

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

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

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

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

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

(to be continued...)

相关文章: