服务

随着《英雄指南》的成长,我们要添加更多需要访问英雄数据的组件。

为了不再把相同的代码复制一遍又一遍,我们要创建一个单一的可复用的数据服务,并且把它注入到需要它的那些组件中。 使用单独的服务可以保持组件精简,使其集中精力为视图提供支持,并且,借助模拟(Mock)服务,可以更容易的对组件进行单元测试。

由于数据服务总是异步的,因此我们最终会提供一个基于承诺(Promise)的数据服务。

当我们完成本章的内容是,本应用会变成这样:在线例子 / 可下载的例子

延续上一步教程

在继续《英雄指南》之前,先检查一下,是否已经有如下目录结构。如果没有,回上一章,看看错过了哪里。

angular-tour-of-heroes
src
app
app.component.ts
app.module.ts
hero.ts
hero-detail.component.ts
main.ts
index.html
styles.css
systemjs.config.js
tsconfig.json
node_modules ...
package.json

让应用代码保持转译和运行

在终端窗口中输入如下命令:

npm start

这个命令会在“监听”模式下运行TypeScript编译器,当代码变化时,它会自动重新编译。 同时,该命令还会在浏览器中启动该应用,并且当代码变化时刷新浏览器。

在后续构建《英雄指南》过程中,应用能持续运行,而不用中断服务来编译或刷新浏览器。

创建英雄服务

客户向我们描绘了本应用更大的目标:想要在不同的页面中用多种方式显示英雄。 现在我们已经能从列表中选择一个英雄了,但这还不够。 很快,我们将添加一个仪表盘来显示表现最好的英雄,并创建一个独立视图来编辑英雄的详情。 所有这些视图都需要英雄数据。

目前,AppComponent显示的是模拟数据。 不过,定义这些英雄并非组件的任务,否则我们没法与其它组件和视图共享这些英雄列表数据。 在这一章,我们将把获取英雄数据的任务重构为一个单独的服务,它将提供英雄数据,并把服务在所有需要英雄数据的组件间共享。

创建 HeroService

app目录下创建一个名叫hero.service.ts的文件。

我们遵循的文件命名约定是:服务名称的小写形式(基本名),加上.service后缀。 如果服务名称包含多个单词,我们就把基本名部分写成中线形式 (dash-case)。 例如,SpecialSuperHeroService服务应该被定义在special-super-hero.service.ts文件中。

我们把这个类命名为HeroService,并导出它,以供别人使用。

src/app/hero.service.ts (starting point)

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

@Injectable()
export class HeroService {
}

可注入的服务

注意,我们导入了 Angular 的Injectable函数,并作为@Injectable()装饰器使用这个函数。

不要忘了写圆括号!如果忘了写,就会导致一个很难诊断的错误。

当 TypeScript 看到@Injectable()装饰器时,就会记下本服务的元数据。 如果 Angular 需要往这个服务中注入其它依赖,就会使用这些元数据。

虽然此时HeroService还没有任何依赖,但我们还是得加上这个装饰器。 作为一项最佳实践,无论是出于提高统一性还是减少变更的目的, 都应该从一开始就加上@Injectable()装饰器。

获取英雄数据

添加一个名叫getHeros的桩方法。

src/app/hero.service.ts (getHeroes stub)

@Injectable()
export class HeroService {
  getHeroes(): void {} // stub
}

HeroService可以从任何地方获取Hero数据 —— Web服务、本地存储或模拟数据源。 从组件中移除数据访问逻辑意味着你可以随时更改这些实现方式,而不影响需要这些英雄数据的组件。

移动模拟的英雄数据

app.component.ts文件中剪切HEROS数组,把它粘贴到app目录下一个名叫mock-heroes.ts的文件中。 还要复制import {Hero}...语句,因为我们的英雄数组用到了Hero类。

src/app/mock-heroes.ts

  1. import { Hero } from './hero';
  2. export const HEROES: Hero[] = [
  3. {id: 11, name: 'Mr. Nice'},
  4. {id: 12, name: 'Narco'},
  5. {id: 13, name: 'Bombasto'},
  6. {id: 14, name: 'Celeritas'},
  7. {id: 15, name: 'Magneta'},
  8. {id: 16, name: 'RubberMan'},
  9. {id: 17, name: 'Dynama'},
  10. {id: 18, name: 'Dr IQ'},
  11. {id: 19, name: 'Magma'},
  12. {id: 20, name: 'Tornado'}
  13. ];

我们导出了HEROES常量,以便可以在其它地方导入它 — 例如HeroService服务。

在刚刚剪切出HEROES数组的app.component.ts文件中,添加一个尚未初始化的heroes属性:

src/app/app.component.ts (heroes property)

heroes: Hero[];

返回模拟的英雄数据

回到HeroService,我们导入HEROES常量,并在getHeroes方法中返回它。 我们的HeroService服务现在是这样的:

src/app/hero.service.ts

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

import { Hero } from './hero';
import { HEROES } from './mock-heroes';

@Injectable()
export class HeroService {
  getHeroes(): Hero[] {
    return HEROES;
  }
}

导入HeroService

我们可以在多个组件中使用 HeroService 服务了,先从 AppComponent 开始。

先导入HeroService,以便我们可以在代码中引用它。

src/app/app.component.ts (hero-service-import)

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

不要newHeroService

该如何在运行中获得一个具体的HeroService实例呢?

你可能想用new来创建HeroService的实例,就像这样:

heroService = new HeroService(); // don't do this

但这不是个好主意,有很多理由,例如:

注入 HeroService

你可以用两行代码代替用new时的一行:

添加构造函数:

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

constructor(private heroService: HeroService) { }

构造函数自己什么也不用做,它在参数中定义了一个私有的heroService属性,并把它标记为注入HeroService的靶点。

现在,当创建AppComponent实例时,Angular 知道需要先提供一个HeroService的实例。

更多依赖注入的信息,见依赖注入

注入器还不知道该如何创建HeroService。 如果现在运行我们的代码,Angular 就会失败,并报错:

EXCEPTION: No provider for HeroService! (AppComponent -> HeroService)
(异常:没有 HeroService 的提供商!(AppComponent -> HeroService))

我们还得注册一个HeroService提供商,来告诉注入器如何创建HeroService。 要做到这一点,我们在@Component组件的元数据底部添加providers数组属性如下:

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

providers: [HeroService]

providers数组告诉 Angular,当它创建新的AppComponent组件时,也要创建一个HeroService的新实例。 AppComponent会使用那个服务来获取英雄列表,在它组件树中的每一个子组件也同样如此。

AppComponent 中的 getHeroes()

该服务被存入了一个私有变量heroService中。

我们可以在同一行内调用此服务,并获得数据。

this.heroes = this.heroService.getHeroes();

在真实的世界中,我们并不需要把一行代码包装成一个专门的方法,但无论如何,我们在演示代码中先这么写:

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

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

ngOnInit 生命周期钩子

毫无疑问,AppComponent应该获取英雄数据并显示它。

你可能想在构造函数中调用getHeroes()方法,但构造函数不应该包含复杂的逻辑,特别是那些需要从服务器获取数据的逻辑更是如此。构造函数是为了简单的初始化工作而设计的,例如把构造函数的参数赋值给属性。

只要我们实现了 Angular 的 ngOnInit 生命周期钩子,Angular 就会主动调用这个钩子。 Angular提供了一些接口,用来介入组件生命周期的几个关键时间点:刚创建时、每次变化时,以及最终被销毁时。

每个接口都有唯一的一个方法。只要组件实现了这个方法,Angular 就会在合适的时机调用它。

更多生命周期钩子信息,见生命周期钩子

这是OnInit接口的基本轮廓(但不要拷贝到你自己的代码中):

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

export class AppComponent implements OnInit {
  ngOnInit(): void {
  }
}

往export语句中添加OnInit接口的实现:

export class AppComponent implements OnInit {}

我们写了一个带有初始化逻辑的ngOnInit方法,Angular会在适当的时候调用它。 在这个例子中,我们通过调用getHeroes()来完成初始化。

app/app.component.ts (ng-on-init)

  ngOnInit(): void {
    this.getHeroes();
  }

我们的应用将会像期望的那样运行,显示英雄列表,并且在我们点击英雄的名字时,显示英雄的详情。

异步服务与承诺

我们的HeroService立即返回一个模拟的英雄列表,它的getHeroes()函数签名是同步的。

this.heroes = this.heroService.getHeroes();

但最终,英雄的数据会从远端服务器获取。当使用远端服务器时,用户不会等待服务器的响应。换句话说,你没法在等待期间阻塞浏览器界面。

为了协调视图与响应,我们可以使用承诺(Promise),它是一种异步技术,它会改变getHeroes()方法的签名。

HeroService会生成一个承诺

承诺 就是 …… 好吧,它就是一个承诺,在有了结果时,它承诺会回调我们。 我们请求一个异步服务去做点什么,并且给它一个回调函数。 它会去做(在某个地方),一旦完成,它就会调用我们的回调函数,并通过参数把工作结果或者错误信息传给我们。

这里只是粗略说说,要了解更多 ES2015 Promise 的信息,见ES6概览中的承诺与异步编程

HeroServicegetHeroes方法改写为返回承诺的形式:

src/app/hero.service.ts (excerpt)

getHeroes(): Promise<Hero[]> {
  return Promise.resolve(HEROES);
}

我们继续使用模拟数据。我们通过返回一个 立即解决的承诺 的方式,模拟了一个超快、零延迟的超级服务器。

基于承诺的行动

修改HeroService之后,this.heroes会被赋值为一个Promise而不再是英雄数组。

src/app/app.component.ts (getHeroes - old)

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

我们得修改这个实现,把它变成基于承诺的,并在承诺的事情被解决时再行动。 一旦承诺的事情被成功解决(Resolve),我们就会显示英雄数据。

我们把回调函数作为参数传给承诺对象的then方法:

src/app/app.component.ts (getHeroes - revised)

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

回调中所用的 ES2015 箭头函数 比等价的函数表达式更加简洁,能优雅的处理 this 指针。

在回调函数中,我们把服务返回的英雄数组赋值给组件的heroes属性。

我们的程序仍在运行,仍在显示英雄列表,在选择英雄时,仍然会把它/她显示在详情页面中。

查看附录中的“慢!”,来了解在较差的网络连接中这个应用会是什么样的。

回顾本应用的结构

再检查下,经历了本章的所有重构之后,应该有了下列文件结构:

angular-tour-of-heroes
src
app
app.component.ts
app.module.ts
hero.ts
hero-detail.component.ts
hero.service.ts
mock-heroes.ts
main.ts
index.html
styles.css
systemjs.config.js
tsconfig.json
node_modules ...
package.json

下面是本章讨论过的代码文件:

  1. import { Injectable } from '@angular/core';
  2. import { Hero } from './hero';
  3. import { HEROES } from './mock-heroes';
  4. @Injectable()
  5. export class HeroService {
  6. getHeroes(): Promise<Hero[]> {
  7. return Promise.resolve(HEROES);
  8. }
  9. }

走过的路

来盘点一下我们完成了什么。

现在应用变成了这样:在线例子 / 可下载的例子

前方的路

通过使用共享组件和服务,我们的《英雄指南》更有复用性了。 我们还要创建一个仪表盘,要添加在视图间路由的菜单链接,还要在模板中格式化数据。 随着我们应用的进化,我们还会学到如何进行设计,让它更易于扩展和维护。

我们将在下一章学习 Angular 组件路由,以及在视图间导航的知识。

附件:慢

我们可以模拟慢速连接。导入Hero类,并且在HeroService中添加如下的getHeroesSlowly()方法:

app/hero.service.ts (getHeroesSlowly)

getHeroesSlowly(): Promise<Hero[]> {
  return new Promise(resolve => {
    // Simulate server latency with 2 second delay
    setTimeout(() => resolve(this.getHeroes()), 2000);
  });
}

getHeroes()一样,它也返回一个承诺。 但是,这个承诺会在提供模拟数据之前等待两秒钟。

回到AppComponent,用heroService.getHeroesSlowly()替换heroService.getHeroes(),并观察应用的行为。

下一步

路由