依赖注入

依赖注入是一个用来管理代码依赖的强大模式。在这本“烹饪宝典”中,我们会讨论Angular依赖注入的许多特性。

目录

Contents

要获取本“烹饪宝典”的代码,参见live example / downloadable example

应用程序全局依赖

在应用程序根组件AppComponent中注册那些被应用程序全局使用的依赖提供商。

在下面的例子中,通过@Component元数据的providers数组导入和注册了几个服务(LoggerService, UserContextUserService)。

src/app/app.component.ts (excerpt)

import { LoggerService }      from './logger.service';
import { UserContextService } from './user-context.service';
import { UserService }        from './user.service';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  providers: [ LoggerService, UserContextService, UserService ]
})
export class AppComponent {
/* . . . */
}

所有这些服务都是用类实现的。服务类能充当自己的提供商,这就是为什么只要把它们列在providers数组里就算注册成功了。

提供商是用来新建或者交付服务的。 Angular拿到“类提供商”之后,会通过new操作来新建服务实例。 从依赖注入一章可以学到关于提供商的更多知识。

现在我们已经注册了这些服务,这样Angular就能在应用程序的任何地方,把它们注入到任何组件和服务的构造函数里。

src/app/hero-bios.component.ts (component constructor injection)

constructor(logger: LoggerService) {
  logger.logInfo('Creating HeroBiosComponent');
}

src/app/user-context.service.ts (service constructor injection)

constructor(private userService: UserService, private loggerService: LoggerService) {
}

外部模块配置

我们通常会在NgModule中注册提供商,而不是在应用程序根组件中。

如果你希望这个服务在应用中到处都可以被注入,或者必须在应用启动前注册一个全局服务,那就这么做。

下面的例子是第二种情况,它为组件路由器配置了一个非默认的地址策略(location strategy),并把它加入到AppModuleproviders数组中。

src/app/app.module.ts (providers)

providers: [
  { provide: LocationStrategy, useClass: HashLocationStrategy }
]

@Injectable和嵌套服务依赖

这些被注入服务的消费者不需要知道如何创建这个服务,它也不应该在乎。新建和缓存这个服务是依赖注入器的工作。

有时候一个服务依赖其它服务...而其它服务可能依赖另外的更多服务。按正确的顺序解析这些嵌套依赖也是框架的工作。 在每一步,依赖的使用者只要在它的构造函数里简单声明它需要什么,框架就会完成所有剩下的事情。

在下列例子中,我们往AppComponent里注入的LoggerServiceUserContext

src/app/app.component.ts

constructor(logger: LoggerService, public userContext: UserContextService) {
  userContext.loadUser(this.userId);
  logger.logInfo('AppComponent initialized');
}

UserContext有两个依赖LoggerService(再一次)和负责获取特定用户信息的UserService

user-context.service.ts (injection)

@Injectable()
export class UserContextService {
  constructor(private userService: UserService, private loggerService: LoggerService) {
  }
}

当Angular新建AppComponent时,依赖注入框架先创建一个LoggerService的实例,然后创建UserContextService实例。 UserContextService需要框架已经创建好的LoggerService实例和尚未创建的UserService实例。 UserService没有其它依赖,所以依赖注入框架可以直接new一个实例。

依赖注入最帅的地方在于,AppComponent的作者不需要在乎这一切。作者只是在(LoggerServiceUserContextService的)构造函数里面简单的声明一下,框架就完成了剩下的工作。

一旦所有依赖都准备好了,AppComponent就会显示用户信息:

Logged In User

@Injectable()

@Injectable()

注意在UserContextService类里面的@Injectable()装饰器。

user-context.service.ts (@Injectable)

@Injectable()
export class UserContextService {
}

该装饰器让Angular有能力识别这两个依赖 LoggerServiceUserService的类型。

严格来说,这个@Injectable()装饰器只在一个服务类有自己的依赖的时候,才是不可缺少的。 LoggerService不依赖任何东西,所以该日志服务在没有@Injectable()的时候应该也能工作,生成的代码也更少一些。

但是在给它添加依赖的那一瞬间,该服务就会停止工作,要想修复它,就必须要添加@Injectable()。 为了保持一致性和防止将来的麻烦,推荐从一开始就加上@Injectable()

虽然推荐在所有服务中使用@Injectable(),但你也不需要一定要这么做。一些开发者就更喜欢在真正需要的地方才添加,这也是一个合理的策略。

AppComponent类有两个依赖,但它没有@Injectable()。 它不需要@Injectable(),这是因为组件类有@Component装饰器。 在用TypeScript的Angular应用里,有一个单独的装饰器 — 任何装饰器 — 来标识依赖的类型就够了。

把服务作用域限制到一个组件支树

所有被注入的服务依赖都是单例的,也就是说,在任意一个依赖注入器("injector")中,每个服务只有唯一的实例。

但是Angular应用程序有多个依赖注入器,组织成一个与组件树平行的树状结构。所以,可以在任何组件级别提供(和建立)特定的服务。如果在多个组件中注入,服务就会被新建出多个实例,分别提供给不同的组件。

默认情况下,一个组件中注入的服务依赖,会在该组件的所有子组件中可见,而且Angular会把同样的服务实例注入到需要该服务的子组件中。

所以,在根部的AppComponent提供的依赖单例就能被注入到应用程序中任何地方任何组件。

但这不一定总是想要的。有时候我们想要把服务的有效性限制到应用程序的一个特定区域。

通过在组件树的子级根组件中提供服务,可以把一个被注入服务的作用域局限在应用程序结构中的某个分支中。 这个例子中展示了为子组件和根组件AppComponent提供服务的相似之处,它们的语法是相同的。 这里通过列入providers数组,在HeroesBaseComponent中提供了HeroService

src/app/sorted-heroes.component.ts (HeroesBaseComponent excerpt)

  1. @Component({
  2. selector: 'unsorted-heroes',
  3. template: `<div *ngFor="let hero of heroes">{{hero.name}}</div>`,
  4. providers: [HeroService]
  5. })
  6. export class HeroesBaseComponent implements OnInit {
  7. constructor(private heroService: HeroService) { }
  8. }

当Angular新建HeroBaseComponent的时候,它会同时新建一个HeroService实例,该实例只在该组件及其子组件(如果有)中可见。

也可以在应用程序别处的不同的组件里提供HeroService。这样就会导致在不同注入器中存在该服务的不同实例。

这个例子中,局部化的HeroService单例,遍布整份范例代码,包括HeroBiosComponentHeroOfTheMonthComponentHeroBaseComponent。 这些组件每个都有自己的HeroService实例,用来管理独立的英雄库。

休息一下!

对一些Angular开发者来说,这么多依赖注入知识可能已经是它们需要知道的全部了。不是每个人都需要更复杂的用法。

多个服务实例(sandboxing)

同一个级别的组件树里,有时需要一个服务的多个实例。

一个用来保存其伴生组件的实例状态的服务就是个好例子。 每个组件都需要该服务的单独实例。 每个服务有自己的工作状态,与其它组件的服务和状态隔离。我们称作沙盒化,因为每个服务和组件实例都在自己的沙盒里运行。

想象一下,一个HeroBiosComponent组件显示三个HeroBioComponent的实例。

ap/hero-bios.component.ts

  1. @Component({
  2. selector: 'hero-bios',
  3. template: `
  4. <hero-bio [heroId]="1"></hero-bio>
  5. <hero-bio [heroId]="2"></hero-bio>
  6. <hero-bio [heroId]="3"></hero-bio>`,
  7. providers: [HeroService]
  8. })
  9. export class HeroBiosComponent {
  10. }

每个HeroBioComponent都能编辑一个英雄的生平。HeroBioComponent依赖HeroCacheService服务来对该英雄进行读取、缓存和执行其它持久化操作。

src/app/hero-cache.service.ts

  1. @Injectable()
  2. export class HeroCacheService {
  3. hero: Hero;
  4. constructor(private heroService: HeroService) {}
  5. fetchCachedHero(id: number) {
  6. if (!this.hero) {
  7. this.hero = this.heroService.getHeroById(id);
  8. }
  9. return this.hero;
  10. }
  11. }

很明显,这三个HeroBioComponent实例不能共享一样的HeroCacheService。要不然它们会相互冲突,争相把自己的英雄放在缓存里面。

通过在自己的元数据(metadata)providers数组里面列出HeroCacheService, 每个HeroBioComponent就能拥有自己独立的HeroCacheService实例。

src/app/hero-bio.component.ts

  1. @Component({
  2. selector: 'hero-bio',
  3. template: `
  4. <h4>{{hero.name}}</h4>
  5. <ng-content></ng-content>
  6. <textarea cols="25" [(ngModel)]="hero.description"></textarea>`,
  7. providers: [HeroCacheService]
  8. })
  9. export class HeroBioComponent implements OnInit {
  10. @Input() heroId: number;
  11. constructor(private heroCache: HeroCacheService) { }
  12. ngOnInit() { this.heroCache.fetchCachedHero(this.heroId); }
  13. get hero() { return this.heroCache.hero; }
  14. }

父组件HeroBiosComponent把一个值绑定到heroIdngOnInit把该id传递到服务,然后服务获取和缓存英雄。hero属性的getter从服务里面获取缓存的英雄,并在模板里显示它绑定到属性值。

在线例子 / 可下载的例子中找到这个例子,确认三个HeroBioComponent实例拥有自己独立的英雄数据缓存。

Bios

使用@Optional()@Host()装饰器来限定依赖查找方式

我们知道,依赖可以被注入到任何组件级别。

当组件申请一个依赖时,Angular从该组件本身的注入器开始,沿着依赖注入器的树往上找,直到找到第一个符合要求的提供商。如果Angular不能在这个过程中找到合适的依赖,它就会抛出一个错误。

大部分时候,我们确实想要这个行为。 但是有时候,需要限制这个(依赖)查找逻辑,且/或提供一个缺失的依赖。 单独或联合使用@Host@Optional限定型装饰器,就可以修改Angular的查找行为。

当Angular找不到依赖时,@Optional装饰器会告诉Angular继续执行。Angular把此注入参数设置为null(而不用默认的抛出错误的行为)。

@Host装饰器将把往上搜索的行为截止在宿主组件

宿主组件通常是申请这个依赖的组件。但当这个组件被投影(projected)进一个父组件后,这个父组件就变成了宿主。 下一个例子会演示第二种情况。

示范

HeroBiosAndContactsComponent前面见过的HeroBiosComponent的修改版。

src/app/hero-bios.component.ts (HeroBiosAndContactsComponent)

  1. @Component({
  2. selector: 'hero-bios-and-contacts',
  3. template: `
  4. <hero-bio [heroId]="1"> <hero-contact></hero-contact> </hero-bio>
  5. <hero-bio [heroId]="2"> <hero-contact></hero-contact> </hero-bio>
  6. <hero-bio [heroId]="3"> <hero-contact></hero-contact> </hero-bio>`,
  7. providers: [HeroService]
  8. })
  9. export class HeroBiosAndContactsComponent {
  10. constructor(logger: LoggerService) {
  11. logger.logInfo('Creating HeroBiosAndContactsComponent');
  12. }
  13. }

注意看模板:

template: `
  <hero-bio [heroId]="1"> <hero-contact></hero-contact> </hero-bio>
  <hero-bio [heroId]="2"> <hero-contact></hero-contact> </hero-bio>
  <hero-bio [heroId]="3"> <hero-contact></hero-contact> </hero-bio>`,

我们在<hero-bio>标签中插入了一个新的<hero-contact>元素。Angular就会把相应的HeroContactComponent投影(transclude)进HeroBioComponent的视图里, 将它放在HeroBioComponent模板的<ng-content>标签槽里。

src/app/hero-bio.component.ts (template)

template: `
  <h4>{{hero.name}}</h4>
  <ng-content></ng-content>
  <textarea cols="25" [(ngModel)]="hero.description"></textarea>`,

HeroContactComponent获得的英雄电话号码,被投影到上面的英雄描述里,就像这样:

bio and contact

下面的HeroContactComponent,示范了限定型装饰器(@Optional和@Host):

src/app/hero-contact.component.ts

  1. @Component({
  2. selector: 'hero-contact',
  3. template: `
  4. <div>Phone #: {{phoneNumber}}
  5. <span *ngIf="hasLogger">!!!</span></div>`
  6. })
  7. export class HeroContactComponent {
  8. hasLogger = false;
  9. constructor(
  10. @Host() // limit to the host component's instance of the HeroCacheService
  11. private heroCache: HeroCacheService,
  12. @Host() // limit search for logger; hides the application-wide logger
  13. @Optional() // ok if the logger doesn't exist
  14. private loggerService: LoggerService
  15. ) {
  16. if (loggerService) {
  17. this.hasLogger = true;
  18. loggerService.logInfo('HeroContactComponent can log!');
  19. }
  20. }
  21. get phoneNumber() { return this.heroCache.hero.phone; }
  22. }

注意看构造函数的参数:

src/app/hero-contact.component.ts

@Host() // limit to the host component's instance of the HeroCacheService
private heroCache: HeroCacheService,

@Host()     // limit search for logger; hides the application-wide logger
@Optional() // ok if the logger doesn't exist
private loggerService: LoggerService

@Host()函数是heroCache属性的装饰器,确保从其父组件HeroBioComponent得到一个缓存服务。如果该父组件不存在这个服务,Angular就会抛出错误,即使组件树里的再上级有某个组件拥有这个服务,Angular也会抛出错误。

另一个@Host()函数是属性loggerService的装饰器,我们知道在应用程序中,只有一个LoggerService实例,也就是在AppComponent级提供的服务。 该宿主HeroBioComponent没有自己的LoggerService提供商。

如果没有同时使用@Optional()装饰器的话,Angular就会抛出错误。多亏了@Optional(),Angular把loggerService设置为null,并继续执行组件而不会抛出错误。

下面是HeroBiosAndContactsComponent的执行结果:

Bios with contact into

如果注释掉@Host()装饰器,Angular就会沿着注入器树往上走,直到在AppComponent中找到该日志服务。日志服务的逻辑加入进来,更新了英雄的显示信息,这表明确实找到了日志服务。

Without @Host

另一方面,如果恢复@Host()装饰器,注释掉@Optional,应用程序就会运行失败,因为它在宿主组件级别找不到需要的日志服务。
EXCEPTION: No provider for LoggerService! (HeroContactComponent -> LoggerService)

注入组件的DOM元素

偶尔,可能需要访问一个组件对应的DOM元素。尽量避免这样做,但还是有很多视觉效果和第三方工具(比如jQuery)需要访问DOM。

为了说明这一点,我们在属性型指令HighlightDirective的基础上,编写了一个简化版本。

src/app/highlight.directive.ts

  1. import { Directive, ElementRef, HostListener, Input } from '@angular/core';
  2. @Directive({
  3. selector: '[myHighlight]'
  4. })
  5. export class HighlightDirective {
  6. @Input('myHighlight') highlightColor: string;
  7. private el: HTMLElement;
  8. constructor(el: ElementRef) {
  9. this.el = el.nativeElement;
  10. }
  11. @HostListener('mouseenter') onMouseEnter() {
  12. this.highlight(this.highlightColor || 'cyan');
  13. }
  14. @HostListener('mouseleave') onMouseLeave() {
  15. this.highlight(null);
  16. }
  17. private highlight(color: string) {
  18. this.el.style.backgroundColor = color;
  19. }
  20. }

当用户把鼠标移到DOM元素上时,指令将该元素的背景设置为一个高亮颜色。

Angular把构造函数参数el设置为注入的ElementRef,该ElementRef代表了宿主的DOM元素, 它的nativeElement属性把该DOM元素暴露给了指令。

下面的代码把指令的myHighlight属性(Attribute)填加到两个<div>标签里,一个没有赋值,一个赋值了颜色。

src/app/app.component.html (highlight)

<div id="highlight"  class="di-component"  myHighlight>
  <h3>Hero Bios and Contacts</h3>
  <div myHighlight="yellow">
    <hero-bios-and-contacts></hero-bios-and-contacts>
  </div>
</div>

下图显示了鼠标移到<hero-bios-and-contacts>标签的效果:

Highlighted bios

使用提供商来定义依赖

在这个部分,我们将演示如何编写提供商来提供被依赖的服务。

我们给依赖注入器提供令牌来获取服务。

我们通常在构造函数里面,为参数指定类型,让Angular来处理依赖注入。该参数类型就是依赖注入器所需的令牌。 Angular把该令牌传给注入器,然后把得到的结果赋给参数。下面是一个典型的例子:

src/app/hero-bios.component.ts (组件构造器注入)

constructor(logger: LoggerService) {
  logger.logInfo('Creating HeroBiosComponent');
}

Angular向注入器请求与LoggerService对应的服务,并将返回值赋给logger参数。

注入器从哪得到的依赖? 它可能在自己内部容器里已经有该依赖了。 如果它没有,也能在提供商的帮助下新建一个。 提供商就是一个用于交付服务的配方,它被关联到一个令牌。

如果注入器无法根据令牌在自己内部找到对应的提供商,它便将请求移交给它的父级注入器,这个过程不断重复,直到没有更多注入器为止。 如果没找到,注入器就抛出一个错误...除非这个请求是可选的

新建的注入器中没有提供商。 Angular会使用一些自带的提供商来初始化这些注入器。我们必须自行注册属于自己的提供商,通常用组件或者指令元数据中的providers数组进行注册。

src/app/app.component.ts (提供商)

providers: [ LoggerService, UserContextService, UserService ]

定义提供商

简单的类提供商是最典型的例子。只要在providers数值里面提到该类就可以了。

src/app/hero-bios.component.ts (类提供商)

providers: [HeroService]

注册类提供商之所以这么简单,是因为最常见的可注入服务就是一个类的实例。 但是,并不是所有的依赖都只要创建一个类的新实例就可以交付了。我们还需要其它的交付方式,这意味着我们也需要其它方式来指定提供商。

HeroOfTheMonthComponent例子示范了一些替代方案,展示了为什么需要它们。 它看起来很简单:一些属性和一个日志输出。

Hero of the month

这段代码的背后有很多值得深入思考的地方。

hero-of-the-month.component.ts

  1. import { Component, Inject } from '@angular/core';
  2. import { DateLoggerService } from './date-logger.service';
  3. import { Hero } from './hero';
  4. import { HeroService } from './hero.service';
  5. import { LoggerService } from './logger.service';
  6. import { MinimalLogger } from './minimal-logger.service';
  7. import { RUNNERS_UP,
  8. runnersUpFactory } from './runners-up';
  9. @Component({
  10. selector: 'hero-of-the-month',
  11. templateUrl: './hero-of-the-month.component.html',
  12. providers: [
  13. { provide: Hero, useValue: someHero },
  14. { provide: TITLE, useValue: 'Hero of the Month' },
  15. { provide: HeroService, useClass: HeroService },
  16. { provide: LoggerService, useClass: DateLoggerService },
  17. { provide: MinimalLogger, useExisting: LoggerService },
  18. { provide: RUNNERS_UP, useFactory: runnersUpFactory(2), deps: [Hero, HeroService] }
  19. ]
  20. })
  21. export class HeroOfTheMonthComponent {
  22. logs: string[] = [];
  23. constructor(
  24. logger: MinimalLogger,
  25. public heroOfTheMonth: Hero,
  26. @Inject(RUNNERS_UP) public runnersUp: string,
  27. @Inject(TITLE) public title: string)
  28. {
  29. this.logs = logger.logs;
  30. logger.logInfo('starting up');
  31. }
  32. }

provide对象

provide对象需要一个令牌和一个定义对象。该令牌通常是一个类,但并非一定是

定义对象有一个主属性(即userValue),用来标识该提供商会如何新建和返回依赖。

useValue - *值-提供商

把一个*固定的值,也就是该提供商可以将其作为依赖对象返回的值,赋给userValue属性。

使用该技巧来进行运行期常量设置,比如网站的基础地址和功能标志等。 我们通常在单元测试中使用值-提供商,用一个假的或模仿的(服务)来取代一个生产环境的服务。

HeroOfTheMonthComponent例子有两个值-提供商。 第一个提供了一个Hero类的实例;第二个指定了一个字符串资源:

    { provide: Hero,          useValue:    someHero },
    { provide: TITLE,         useValue:   'Hero of the Month' },

Hero提供商的令牌是一个类,这很合理,因为它提供的结果是一个Hero实例,并且被注入该英雄的消费者也需要知道它类型信息。

TITLE 提供商的令牌不是一个类。它是一个特别类型的提供商查询键,名叫InjectionToken. 你可以把InjectionToken用作任何类型的提供商的令牌,但是它在依赖是简单类型(比如字符串、数字、函数)时会特别有帮助。

一个值-提供商的值必须要立即定义。不能事后再定义它的值。很显然,标题字符串是立刻可用的。 该例中的someHero变量是以前在下面这个文件中定义的:

const someHero = new Hero(42, 'Magma', 'Had a great month!', '555-555-5555');

其它提供商只在需要注入它们的时候才创建并惰性加载它们的值。

useClass - 类-提供商

userClass提供商创建并返回一个指定类的新实例。

使用该技术来为公共或默认类提供备选实现。该替代品能实现一个不同的策略,比如拓展默认类或者在测试的时候假冒真实类。

请看下面HeroOfTheMonthComponent里的两个例子:

{ provide: HeroService,   useClass:    HeroService },
{ provide: LoggerService, useClass:    DateLoggerService },

第一个提供商是展开了语法糖的,是一个典型情况的展开。一般来说,被新建的类(HeroService)同时也是该提供商的注入令牌。 这里用完整形态来编写它,来反衬我们更喜欢的缩写形式。

第二个提供商使用DateLoggerService来满足LoggerService。该LoggerServiceAppComponent级别已经被注册。当这个组件要求LoggerService的时候,它得到的却是DateLoggerService服务。

这个组件及其子组件会得到DateLoggerService实例。这个组件树之外的组件得到的仍是LoggerService实例。

DateLoggerServiceLoggerService继承;它把当前的日期/时间附加到每条信息上。

src/app/date-logger.service.ts

@Injectable()
export class DateLoggerService extends LoggerService
{
  logInfo(msg: any)  { super.logInfo(stamp(msg)); }
  logDebug(msg: any) { super.logInfo(stamp(msg)); }
  logError(msg: any) { super.logError(stamp(msg)); }
}

function stamp(msg: any) { return msg + ' at ' + new Date(); }

useExisting - 别名-提供商

使用useExisting,提供商可以把一个令牌映射到另一个令牌上。实际上,第一个令牌是第二个令牌所对应的服务的一个别名,创造了访问同一个服务对象的两种方法

{ provide: MinimalLogger, useExisting: LoggerService },

通过使用别名接口来把一个API变窄,是一个很重要的该技巧的使用例子。我们在这里就是为了这个目的使用的别名。

想象一下如果LoggerService有个很大的API接口(虽然它其实只有三个方法,一个属性),通过使用MinimalLogger类-接口别名,就能成功的把这个API接口缩小到只暴露两个成员:

src/app/minimal-logger.service.ts

// Class used as a "narrowing" interface that exposes a minimal logger
// Other members of the actual implementation are invisible
export abstract class MinimalLogger {
  logs: string[];
  logInfo: (msg: string) => void;
}

现在,在一个简化版的HeroOfTheMonthComponent中使用它。

src/app/hero-of-the-month.component.ts (minimal version)

@Component({
  selector: 'hero-of-the-month',
  templateUrl: './hero-of-the-month.component.html',
  // Todo: move this aliasing, `useExisting` provider to the AppModule
  providers: [{ provide: MinimalLogger, useExisting: LoggerService }]
})
export class HeroOfTheMonthComponent {
  logs: string[] = [];
  constructor(logger: MinimalLogger) {
    logger.logInfo('starting up');
  }
}

HeroOfTheMonthComponent构造函数的logger参数是一个MinimalLogger类型,支持TypeScript的编辑器里,只能看到它的两个成员logslogInfo

MinimalLogger受限API

实际上,Angular确实想把logger参数设置为注入器里LoggerService的完整版本。只是在之前的提供商注册里使用了useClass, 所以该完整版本被DateLoggerService取代了。

在下面的图片中,显示了日志日期,可以确认这一点:

DateLoggerService entry

useFactory - 工厂-提供商

useFactory 提供商通过调用工厂函数来新建一个依赖对象,如下例所示。

{ provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }

使用这项技术,可以用包含了一些依赖服务和本地状态输入的工厂函数来建立一个依赖对象

依赖对象不一定是一个类实例。它可以是任何东西。在这个例子里,依赖对象是一个字符串,代表了本月英雄比赛的亚军的名字。

本地状态是数字2,该组件应该显示的亚军的个数。我们立刻用2来执行runnersUpFactory

runnersUpFactory自身不是提供商工厂函数。真正的提供商工厂函数是runnersUpFactory返回的函数。

runners-up.ts (excerpt)

export function runnersUpFactory(take: number) {
  return (winner: Hero, heroService: HeroService): string => {
    /* ... */
  };
};

这个返回的函数需要一个Hero和一个HeroService参数。

Angular通过使用deps数组中的两个令牌,来识别注入的值,用来提供这些参数。这两个deps值是供注入器使用的令牌,用来提供工厂函数的依赖。

一些内部工作后,这个函数返回名字字符串,Angular将其注入到HeroOfTheMonthComponent组件的runnersUp参数里。

该函数从HeroService获取英雄参赛者,从中取2个作为亚军,并把它们的名字拼接起来。请到在线例子 / 可下载的例子查看全部原代码。

备选提供商令牌:类-接口InjectionToken

Angular依赖注入当令牌是类的时候是最简单的,该类同时也是返回的依赖对象的类型(通常直接称之为服务)。

但令牌不一定都是类,就算它是一个类,它也不一定都返回类型相同的对象。这是下一节的主题。

class-interface

在前面的每月英雄的例子中,我们用了MinimalLogger类作为LoggerService 提供商的令牌。

{ provide: MinimalLogger, useExisting: LoggerService },

MinimalLogger是一个抽象类。

// Class used as a "narrowing" interface that exposes a minimal logger
// Other members of the actual implementation are invisible
export abstract class MinimalLogger {
  logs: string[];
  logInfo: (msg: string) => void;
}

我们通常从一个抽象类继承。但这个应用中并没有类会继承MinimalLogger

LoggerServiceDateLoggerService本可以MinimalLogger中继承。 它们也可以实现MinimalLogger,而不用单独定义接口。 但它们没有。 MinimalLogger在这里仅仅被用作一个 "依赖注入令牌"。

我们称这种用法的类叫做类-接口。它关键的好处是:提供了接口的强类型,能像正常类一样把它当做提供商令牌使用

类-接口应该定义允许它的消费者调用的成员。窄的接口有助于解耦该类的具体实现和它的消费者。

为什么MinimalLogger是一个类而不是一个TypeScript接口

不能把接口当做提供商的令牌,因为接口不是有效的JavaScript对象。 它们只存在在TypeScript的设计空间里。它们会在被编译为JavaScript之后消失。

一个提供商令牌必须是一个真实的JavaScript对象,比如:一个函数,一个对象,一个字符串,或一个类。

把类当做接口使用,可以为我们在一个JavaScript对象上提供类似于接口的特性。

当然,一个真实的类会占用内存。为了节省内存占用,该类应该没有具体的实现MinimalLogger会被转译成下面这段没有优化过的,尚未最小化的JavaScript:

var MinimalLogger = (function () {
  function MinimalLogger() {}
  return MinimalLogger;
}());
exports("MinimalLogger", MinimalLogger);

注意,只要不实现它,不管添加多少成员,它永远不会增长大小。

InjectionToken

依赖对象可以是一个简单的值,比如日期,数字和字符串,或者一个无形的对象,比如数组和函数。

这样的对象没有应用程序接口,所以不能用一个类来表示。更适合表示它们的是:唯一的和符号性的令牌,一个JavaScript对象,拥有一个友好的名字,但不会与其它的同名令牌发生冲突。

InjectionToken具有这些特征。在Hero of the Month例子中遇见它们两次,一个是title的值,一个是runnersUp 工厂提供商。

{ provide: TITLE,         useValue:   'Hero of the Month' },
{ provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }

这样创建TITLE令牌:

import { InjectionToken } from '@angular/core';

export const TITLE = new InjectionToken<string>('title');

带类型(可选)的参数,向开发人员和开发工具揭示了该依赖的类型。 令牌描述则通过另一种形式给开发人员提供帮助。

注入到派生类

当编写一个继承自另一个组件的组件时,要格外小心。如果基础组件有依赖注入,必须要在派生类中重新提供和重新注入它们,并将它们通过构造函数传给基类。

在这个生造的例子里,SortedHeroesComponent继承自HeroesBaseComponent,显示一个被排序的英雄列表。

Sorted Heroes

HeroesBaseComponent能自己独立运行。它在自己的实例里要求HeroService,用来得到英雄,并将它们按照数据库返回的顺序显示出来。

src/app/sorted-heroes.component.ts (HeroesBaseComponent)

  1. @Component({
  2. selector: 'unsorted-heroes',
  3. template: `<div *ngFor="let hero of heroes">{{hero.name}}</div>`,
  4. providers: [HeroService]
  5. })
  6. export class HeroesBaseComponent implements OnInit {
  7. constructor(private heroService: HeroService) { }
  8. heroes: Array<Hero>;
  9. ngOnInit() {
  10. this.heroes = this.heroService.getAllHeroes();
  11. this.afterGetHeroes();
  12. }
  13. // Post-process heroes in derived class override.
  14. protected afterGetHeroes() {}
  15. }

让构造函数保持简单。它们应该用来初始化变量。这个规则会帮助我们在测试环境中放心的构造组件,以免在构造它们时,无意做了一些非常戏剧化的动作(比如连接服务)。 这就是为什么我们要在ngOnInit里面调用HeroService,而不是在构造函数中。

用户希望看到英雄按字母顺序排序。与其修改原始的组件,不如派生它,新建SortedHeroesComponent,以便展示英雄之前进行排序。 SortedHeroesComponent让基类来获取英雄。

可惜,Angular不能直接在基类里直接注入HeroService。必须在这个组件里再次提供HeroService,然后通过构造函数传给基类。

src/app/sorted-heroes.component.ts (SortedHeroesComponent)

  1. @Component({
  2. selector: 'sorted-heroes',
  3. template: `<div *ngFor="let hero of heroes">{{hero.name}}</div>`,
  4. providers: [HeroService]
  5. })
  6. export class SortedHeroesComponent extends HeroesBaseComponent {
  7. constructor(heroService: HeroService) {
  8. super(heroService);
  9. }
  10. protected afterGetHeroes() {
  11. this.heroes = this.heroes.sort((h1, h2) => {
  12. return h1.name < h2.name ? -1 :
  13. (h1.name > h2.name ? 1 : 0);
  14. });
  15. }
  16. }

现在,请注意afterGetHeroes()方法。 我们第一反应是在SortedHeroesComponent组件里面建一个ngOnInit方法来做排序。但是Angular会先调用派生类的ngOnInit,后调用基类的ngOnInit, 所以可能在英雄到达之前就开始排序。这就产生了一个讨厌的错误。

覆盖基类的afterGetHeroes()方法可以解决这个问题。

分析上面的这些复杂性是为了强调避免使用组件继承这一点。

通过注入来找到一个父组件

应用程序组件经常需要共享信息。我们喜欢更加松耦合的技术,比如数据绑定和服务共享。 但有时候组件确实需要拥有另一个组件的引用,用来访问该组件的属性值或者调用它的方法。

在Angular里,获取一个组件的引用比较复杂。虽然Angular应用程序是一个组件树,但它没有公开的API来在该树中巡查和穿梭。

有一个API可以获取子级的引用(请看API参考手册中的Query, QueryList, ViewChildren,和ContentChildren)。

但没有公开的API来获取父组件的引用。但是因为每个组件的实例都被加到了依赖注入器的容器中,可以使用Angular依赖注入来找到父组件。

本章节描述了这项技术。

#known-parent

找到已知类型的父组件

我们使用标准的类注入来获取已知类型的父组件。

在下面的例子中,父组件AlexComponent有几个子组件,包括CathyComponent:

parent-finder.component.ts (AlexComponent v.1)

@Component({
  selector: 'alex',
  template: `
    <div class="a">
      <h3>{{name}}</h3>
      <cathy></cathy>
      <craig></craig>
      <carol></carol>
    </div>`,
})
export class AlexComponent extends Base
{
  name= 'Alex';
}

在注入AlexComponent`进来后,Cathy报告它是否对Alex*有访问权:

parent-finder.component.ts (CathyComponent)

@Component({
  selector: 'cathy',
  template: `
  <div class="c">
    <h3>Cathy</h3>
    {{alex ? 'Found' : 'Did not find'}} Alex via the component class.<br>
  </div>`
})
export class CathyComponent {
  constructor( @Optional() public alex: AlexComponent ) { }
}

安全起见,我们添加了@Optional装饰器,但是在线例子 / 可下载的例子显示alex参数确实被设置了。

无法通过它的基类找到一个父级

如果知道具体的父组件类名怎么办?

一个可复用的组件可能是多个组件的子级。想象一个用来渲染金融工具头条新闻的组件。为了合理(咳咳)的商业理由,该新闻组件在实时变化的市场数据流过时,要频繁的直接调用其父级工具。

该应用程序可能有多于一打的金融工具组件。如果幸运,它们可能会从同一个基类派生,其API是NewsComponent组件所能理解的。

更好的方式是通过接口来寻找实现了它的组件。但这是不可能的,因为TypeScript的接口在编译成JavaScript以后就消失了,JavaScript不支持接口。我们没有东西可查。

这并不是好的设计。问题是一个组件是否能通过它父组件的基类来注入它的父组件呢

CraigComponent例子探究了这个问题。[往回看Alex]{#alex},我们看到Alex组件扩展(派生)自一个叫Base的类。

parent-finder.component.ts (Alex class signature)

export class AlexComponent extends Base

CraigComponent试图把Base注入到到它的alex构造函数参数,来报告是否成功。

parent-finder.component.ts (CraigComponent)

@Component({
  selector: 'craig',
  template: `
  <div class="c">
    <h3>Craig</h3>
    {{alex ? 'Found' : 'Did not find'}} Alex via the base class.
  </div>`
})
export class CraigComponent {
  constructor( @Optional() public alex: Base ) { }
}

可惜这样不行。在线例子 / 可下载的例子显示alex参数是null。 不能通过基类注入父组件

通过类-接口找到父组件

可以通过类-接口找到一个父组件。

该父组件必须通过提供一个与类-接口令牌同名的别名来与之合作。

请记住Angular总是从它自己的注入器添加一个组件实例;这就是为什么在之前可以Alex注入到Carol

我们编写一个别名提供商 &mdash;一个拥有useExisting定义的provide函数 — 它新建一个备选的方式来注入同一个组件实例,并把这个提供商添加到AlexComponent@Component元数据里的providers数组。

parent-finder.component.ts (AlexComponent providers)

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

Parent是该提供商的类-接口令牌。AlexComponent引用了自身,造成循环引用,使用forwardRef打破了该循环。

CarolAlex的第三个子组件,把父级注入到了自己的parent参数,和之前做的一样:

parent-finder.component.ts (CarolComponent class)

export class CarolComponent {
  name= 'Carol';
  constructor( @Optional() public parent: Parent ) { }
}

下面是Alex和其家庭的运行结果:

Alex in action

通过父级树找到父组件

想象组件树中的一个分支为:Alice -> Barry -> Carol

AliceBarry都实现了这个Parent类-接口

Barry是个问题。它需要访问它的父组件Alice,但同时它也是Carol的父组件。这个意味着它必须同时注入Parent类-接口来获取Alice,和提供一个Parent来满足Carol

下面是Barry的代码:

parent-finder.component.ts (BarryComponent)

const templateB = `
  <div class="b">
    <div>
      <h3>{{name}}</h3>
      <p>My parent is {{parent?.name}}</p>
    </div>
    <carol></carol>
    <chris></chris>
  </div>`;

@Component({
  selector:   'barry',
  template:   templateB,
  providers:  [{ provide: Parent, useExisting: forwardRef(() => BarryComponent) }]
})
export class BarryComponent implements Parent {
  name = 'Barry';
  constructor( @SkipSelf() @Optional() public parent: Parent ) { }
}

Barryproviders数组看起来很像Alex的那个. 如果准备一直像这样编写别名提供商的话,我们应该建立一个帮助函数

眼下,请注意Barry的构造函数:

constructor( @SkipSelf() @Optional() public parent: Parent ) { }

除额外添加了一个的@SkipSelf外,它和Carol的构造函数一样。

添加@SkipSelf主要是出于两个原因:

  1. 它告诉注入器从一个在自己上一级的组件开始搜索一个Parent依赖。

  2. 如果没写@SkipSelf装饰器的话,Angular就会抛出一个循环依赖错误。

    不能创建循环依赖实例!(BethComponent -> Parent -> BethComponent)

这里是AliceBarry和该家庭的操作演示:

Alice in action

Parent类-接口

我们以前学过类-接口是一个抽象类,被当成一个接口使用,而非基类。

我们的例子定义了一个Parent类-接口

parent-finder.component.ts (Parent class-interface)

export abstract class Parent { name: string; }

Parent类-接口定义了Name属性,它有类型声明,但是没有实现,该name是该父级的所有子组件们唯一能调用的属性。 这种“窄接口”有助于解耦子组件类和它的父组件。

一个能用做父级的组件应该实现类-接口,和下面的AliceComponent的做法一样:

parent-finder.component.ts (AliceComponent class signature)

export class AliceComponent implements Parent

这样做可以提升代码的清晰度,但严格来说并不是必须的。虽然AlexComponent有一个name属性(来自Base类的要求),但它的类签名并不需要提及Parent

parent-finder.component.ts (AlexComponent class signature)

export class AlexComponent extends Base

为了正确的代码风格,该AlexComponent应该实现Parent。在这个例子里它没有这样,只是为了演示在没有该接口的情况下,该代码仍会被正确编译并运行。

provideParent()助手函数

编写父组件相同的各种别名提供商很快就会变得啰嗦,在用*forwardRef的时候尤其绕口:

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

可以像这样把该逻辑抽取到一个助手函数里:

// Helper method to provide the current component instance in the name of a `parentType`.
const provideParent =
  (component: any) => {
    return { provide: Parent, useExisting: forwardRef(() => component) };
  };

现在就可以为组件添加一个更简单、直观的父级提供商了:

providers:  [ provideParent(AliceComponent) ]

我们可以做得更好。当前版本的助手函数只能为Parent类-接口提供别名。应用程序可能有很多类型的父组件,每个父组件有自己的类-接口令牌。

下面是一个修改版本,默认接受一个Parent,但同时接受一个可选的第二参数,可以用来指定一个不同的父级类-接口

// Helper method to provide the current component instance in the name of a `parentType`.
// The `parentType` defaults to `Parent` when omitting the second parameter.
const provideParent =
  (component: any, parentType?: any) => {
    return { provide: parentType || Parent, useExisting: forwardRef(() => component) };
  };

下面的代码演示了如何使它添加一个不同类型的父级:

providers:  [ provideParent(BethComponent, DifferentParent) ]

使用一个前向引用(forwardRef)来打破循环

在TypeScript里面,类声明的顺序是很重要的。如果一个类尚未定义,就不能引用它。

这通常不是一个问题,特别是当我们遵循一个文件一个类规则的时候。 但是有时候循环引用可能不能避免。当一个类A引用类B,同时'B'引用'A'的时候,我们就陷入困境了:它们中间的某一个必须要先定义。

Angular的forwardRef()函数建立一个间接地引用,Angular可以随后解析。

Parent Finder是一个充满了无法解决的循环引用的例子

当一个类需要引用自身的时候,我们面临同样的困境,就像在AlexComponentprovdiers数组中遇到的困境一样。 该providers数组是一个@Component装饰器函数的一个属性,它必须在类定义之前出现。

我们使用forwardRef来打破这种循环:

parent-finder.component.ts (AlexComponent providers)

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],