阅读(1306) (1)

Angular 英雄之旅-从服务器获取数据

2022-07-12 17:38:53 更新

从服务端获取数据

在这节课中,你将借助 Angular 的 ​HttpClient ​来添加一些数据持久化特性。

  • HeroService ​通过 HTTP 请求获取英雄数据
  • 用户可以添加、编辑和删除英雄,并通过 HTTP 来保存这些更改
  • 用户可以根据名字搜索英雄

要查看本页所讲的范例程序,参阅现场演练 / 下载范例

启用 HTTP 服务

HttpClient ​是 Angular 通过 HTTP 与远程服务器通讯的机制。

要让 ​HttpClient ​在应用中随处可用,需要两个步骤。首先,用导入语句把它添加到根模块 ​AppModule ​中:

import { HttpClientModule } from '@angular/common/http';

接下来,仍然在 ​AppModule ​中,把 ​HttpClientModule ​添加到 ​imports ​数组中:

@NgModule({
  imports: [
    HttpClientModule,
  ],
})

模拟数据服务器

这个教学例子会与一个使用 内存 Web API(In-memory Web API) 模拟出的远程数据服务器通讯。

安装完这个模块之后,应用将会通过 ​HttpClient ​来发起请求和接收响应,而不用在乎实际上是这个内存 Web API 在拦截这些请求、操作一个内存数据库,并且给出仿真的响应。

通过使用内存 Web API,你不用架设服务器就可以学习 ​HttpClient ​了。

重要:
这个内存 Web API 模块与 Angular 中的 HTTP 模块无关。
如果你只是在阅读本教程来学习 ​HttpClient​,那么可以跳过这一步。 如果你正在随着本教程敲代码,那就留下来,并加上这个内存 Web API

用如下命令从 ​npm ​中安装这个内存 Web API 包(译注:请使用 0.5+ 的版本,不要使用 0.4-)

npm install angular-in-memory-web-api --save

在 ​AppModule ​中,导入 ​HttpClientInMemoryWebApiModule ​和 ​InMemoryDataService ​类,稍后你将创建它们。

import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service';

在 ​HttpClientModule ​之后,将 ​HttpClientInMemoryWebApiModule ​添加到 ​AppModule ​的 ​imports ​数组中,并以 ​InMemoryDataService ​为参数对其进行配置。

HttpClientModule,

// The HttpClientInMemoryWebApiModule module intercepts HTTP requests
// and returns simulated server responses.
// Remove it when a real server is ready to receive requests.
HttpClientInMemoryWebApiModule.forRoot(
  InMemoryDataService, { dataEncapsulation: false }
)

forRoot()​ 配置方法接收一个 ​InMemoryDataService ​类来初始化内存数据库。

使用以下命令生成类 ​src/app/in-memory-data.service.ts​:

ng generate service InMemoryData

将 ​in-memory-data.service.ts​ 改为以下内容:

import { Injectable } from '@angular/core';
import { InMemoryDbService } from 'angular-in-memory-web-api';
import { Hero } from './hero';

@Injectable({
  providedIn: 'root',
})
export class InMemoryDataService implements InMemoryDbService {
  createDb() {
    const heroes = [
      { id: 12, name: 'Dr. Nice' },
      { id: 13, name: 'Bombasto' },
      { id: 14, name: 'Celeritas' },
      { id: 15, name: 'Magneta' },
      { id: 16, name: 'RubberMan' },
      { id: 17, name: 'Dynama' },
      { id: 18, name: 'Dr. IQ' },
      { id: 19, name: 'Magma' },
      { id: 20, name: 'Tornado' }
    ];
    return {heroes};
  }

  // Overrides the genId method to ensure that a hero always has an id.
  // If the heroes array is empty,
  // the method below returns the initial number (11).
  // if the heroes array is not empty, the method below returns the highest
  // hero id + 1.
  genId(heroes: Hero[]): number {
    return heroes.length > 0 ? Math.max(...heroes.map(hero => hero.id)) + 1 : 11;
  }
}

in-memory-data.service.ts​ 文件已代替了 ​mock-heroes.ts​ 文件,现在后者可以安全的删除了。

等服务器就绪后,你就可以抛弃这个内存 Web API,应用的请求将直接传给服务器。

英雄与 HTTP

在 ​HeroService ​中,导入 ​HttpClient ​和 ​HttpHeaders​:

import { HttpClient, HttpHeaders } from '@angular/common/http';

仍然在 ​HeroService ​中,把 ​HttpClient ​注入到构造函数中一个名叫 ​http ​的私有属性中。

constructor(
  private http: HttpClient,
  private messageService: MessageService) { }

注意保留对 ​MessageService ​的注入,但是因为你将频繁调用它,因此请把它包裹进一个私有的 ​log ​方法中。

/** Log a HeroService message with the MessageService */
private log(message: string) {
  this.messageService.add(`HeroService: ${message}`);
}

把服务器上英雄数据资源的访问地址 heroesURL 定义为 :base/:collectionName 的形式。这里的 base 是要请求的资源,而 collectionName 是 in-memory-data-service.ts 中的英雄数据对象。

private heroesUrl = 'api/heroes';  // URL to web api

通过 HttpClient 获取英雄

当前的 ​HeroService.getHeroes()​ 使用 RxJS 的 ​of()​ 函数来把模拟英雄数据返回为 ​Observable<Hero[]>​ 格式。

getHeroes(): Observable<Hero[]> {
  const heroes = of(HEROES);
  return heroes;
}

把该方法转换成使用 ​HttpClient ​的,代码如下:

/** GET heroes from the server */
getHeroes(): Observable<Hero[]> {
  return this.http.get<Hero[]>(this.heroesUrl)
}

刷新浏览器后,英雄数据就会从模拟服务器被成功读取。

你用 ​http.get()​ 替换了 ​of()​,没有做其它修改,但是应用仍然在正常工作,这是因为这两个函数都返回了 ​Observable<Hero[]>​。

HttpClient 的方法返回单个值

所有的 ​HttpClient ​方法都会返回某个值的 RxJS ​Observable​。

HTTP 是一个请求/响应式协议。你发起请求,它返回单个的响应。

通常,​Observable 可以在一段时间内返回多个值。但来自 ​HttpClient ​的 ​Observable ​总是发出一个值,然后结束,再也不会发出其它值。

具体到这次 ​HttpClient.get()​ 调用,它返回一个 ​Observable<Hero[]>​,也就是“一个英雄数组的可观察对象”。在实践中,它也只会返回一个英雄数组。

HttpClient.get() 返回响应数据

HttpClient.get()​ 默认情况下把响应体当做无类型的 JSON 对象进行返回。如果指定了可选的模板类型 ​<Hero[]>​,就会给返回你一个类型化的对象。

服务器的数据 API 决定了 JSON 数据的具体形态。英雄之旅的数据 API 会把英雄数据作为一个数组进行返回。

其它 API 可能在返回对象中深埋着你想要的数据。你可能要借助 RxJS 的 ​map()​ 操作符对 ​Observable ​的结果进行处理,以便把这些数据挖掘出来。
虽然不打算在此展开讨论,不过你可以到范例源码中的 ​getHeroNo404()​ 方法中找到一个使用 ​map()​ 操作符的例子。

错误处理

凡事皆会出错,特别是当你从远端服务器获取数据的时候。​HeroService.getHeroes()​ 方法应该捕获错误,并做适当的处理。

要捕获错误,你就要使用 RxJS 的 ​catchError()​ 操作符来建立对 Observable 结果的处理管道(pipe)。

从 ​rxjs/operators​ 中导入 ​catchError ​符号,以及你稍后将会用到的其它操作符。

import { catchError, map, tap } from 'rxjs/operators';

现在,使用 ​pipe()​ 方法来扩展 ​Observable ​的结果,并给它一个 ​catchError()​ 操作符。

getHeroes(): Observable<Hero[]> {
  return this.http.get<Hero[]>(this.heroesUrl)
    .pipe(
      catchError(this.handleError<Hero[]>('getHeroes', []))
    );
}

catchError()​ 操作符会拦截失败的 ​Observable​。它把错误对象传给错误处理器错误处理器会处理这个错误。

下面的 ​handleError()​ 方法会报告这个错误,并返回一个无害的结果(安全值),以便应用能正常工作。

handleError

下面这个 ​handleError()​ 将会在很多 ​HeroService ​的方法之间共享,所以要把它通用化,以支持这些彼此不同的需求。

它不再直接处理这些错误,而是返回给 ​catchError ​返回一个错误处理函数。还要用操作名和出错时要返回的安全值来对这个错误处理函数进行配置。

/**
 * Handle Http operation that failed.
 * Let the app continue.
 *
 * @param operation - name of the operation that failed
 * @param result - optional value to return as the observable result
 */
private handleError<T>(operation = 'operation', result?: T) {
  return (error: any): Observable<T> => {

    // TODO: send the error to remote logging infrastructure
    console.error(error); // log to console instead

    // TODO: better job of transforming error for user consumption
    this.log(`${operation} failed: ${error.message}`);

    // Let the app keep running by returning an empty result.
    return of(result as T);
  };
}

在控制台中汇报了这个错误之后,这个处理器会汇报一个用户友好的消息,并给应用返回一个安全值,让应用继续工作。

因为每个服务方法都会返回不同类型的 ​Observable ​结果,因此 ​handleError()​ 也需要一个类型参数,以便它返回一个此类型的安全值,正如应用所期望的那样。

窥探 Observable

HeroService ​的方法将会窥探 ​Observable ​的数据流,并通过 ​log()​ 方法往页面底部发送一条消息。

它们可以使用 RxJS 的 ​tap()​ 操作符来实现,该操作符会查看 Observable 中的值,使用那些值做一些事情,并且把它们传出来。这种 ​tap()​ 回调不会改变这些值本身。

下面是 ​getHeroes()​ 的最终版本,它使用 ​tap()​ 来记录各种操作。

/** GET heroes from the server */
getHeroes(): Observable<Hero[]> {
  return this.http.get<Hero[]>(this.heroesUrl)
    .pipe(
      tap(_ => this.log('fetched heroes')),
      catchError(this.handleError<Hero[]>('getHeroes', []))
    );
}

通过 id 获取英雄

大多数的 Web API 都支持以 ​:baseURL/:id​ 的形式根据 id 进行获取。

这里的 ​baseURL ​就是在上面 英雄列表与 HTTP 部分定义过的 ​heroesURL​(​api/heroes​)。而 ​id ​则是你要获取的英雄的编号,比如,​api/heroes/11​。

把 ​HeroService.getHero()​ 方法改成这样,以发起该请求:

/** GET hero by id. Will 404 if id not found */
getHero(id: number): Observable<Hero> {
  const url = `${this.heroesUrl}/${id}`;
  return this.http.get<Hero>(url).pipe(
    tap(_ => this.log(`fetched hero id=${id}`)),
    catchError(this.handleError<Hero>(`getHero id=${id}`))
  );
}

这里和 ​getHeroes()​ 相比有三个显著的差异:

  • getHero()​ 使用想获取的英雄的 id 构造了一个请求 URL
  • 服务器应该使用单个英雄作为回应,而不是一个英雄数组
  • 所以,​getHero()​ 会返回 ​Observable<Hero>​(“一个可观察的单个英雄对象”),而不是一个可观察的英雄对象数组

修改英雄

英雄详情视图中编辑英雄的名字。随着输入,英雄的名字也跟着在页面顶部的标题区更新了。但是当你点击“后退”按钮时,这些修改都丢失了。

如果你希望保留这些修改,就要把它们写回到服务器。

在英雄详情模板的底部添加一个保存按钮,它绑定了一个 ​click ​事件,事件绑定会调用组件中一个名叫 ​save()​ 的新方法。

<button type="button" (click)="save()">save</button>

在 ​HeroDetail ​组件类中,添加如下的 ​save()​ 方法,它使用英雄服务中的 ​updateHero()​ 方法来保存对英雄名字的修改,然后导航回前一个视图。

save(): void {
  if (this.hero) {
    this.heroService.updateHero(this.hero)
      .subscribe(() => this.goBack());
  }
}

添加 HeroService.updateHero()

updateHero()​ 的总体结构和 ​getHeroes()​ 很相似,但它会使用 ​http.put()​ 来把修改后的英雄保存到服务器上。把下列代码添加进 ​HeroService​。

/** PUT: update the hero on the server */
updateHero(hero: Hero): Observable<any> {
  return this.http.put(this.heroesUrl, hero, this.httpOptions).pipe(
    tap(_ => this.log(`updated hero id=${hero.id}`)),
    catchError(this.handleError<any>('updateHero'))
  );
}

HttpClient.put()​ 方法接受三个参数:

  • URL 地址
  • 要修改的数据(这里就是修改后的英雄)
  • 选项

URL 没变。英雄 Web API 通过英雄对象的 ​id ​就可以知道要修改哪个英雄。

英雄 Web API 期待在保存时的请求中有一个特殊的头。这个头是在 ​HeroService ​的 ​httpOptions ​常量中定义的。

httpOptions = {
  headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};

刷新浏览器,修改英雄名,保存这些修改。在 ​HeroDetailComponent ​的 ​save()​ 方法中导航到前一个视图。现在,改名后的英雄已经显示在列表中了。

添加一个新英雄

要添加英雄,本应用中只需要英雄的名字。你可以使用一个和添加按钮成对的 ​<input>​ 元素。

把下列代码插入到 ​HeroesComponent ​模板中标题的紧后面:

<div>
  <label for="new-hero">Hero name: </label>
  <input id="new-hero" #heroName />

  <!-- (click) passes input value to add() and then clears the input -->
  <button type="button" class="add-button" (click)="add(heroName.value); heroName.value=''">
    Add hero
  </button>
</div>

当点击事件触发时,调用组件的点击处理器(​add()​),然后清空这个输入框,以便用来输入另一个名字。把下列代码添加到 ​HeroesComponent ​类:

add(name: string): void {
  name = name.trim();
  if (!name) { return; }
  this.heroService.addHero({ name } as Hero)
    .subscribe(hero => {
      this.heroes.push(hero);
    });
}

当指定的名字非空时,这个处理器会用这个名字创建一个类似于 ​Hero ​的对象(只缺少 ​id ​属性),并把它传给服务的 ​addHero()​ 方法。

当 ​addHero()​ 保存成功时,​subscribe()​ 的回调函数会收到这个新英雄,并把它追加到 ​heroes ​列表中以供显示。

往 ​HeroService ​类中添加 ​addHero()​ 方法。

/** POST: add a new hero to the server */
addHero(hero: Hero): Observable<Hero> {
  return this.http.post<Hero>(this.heroesUrl, hero, this.httpOptions).pipe(
    tap((newHero: Hero) => this.log(`added hero w/ id=${newHero.id}`)),
    catchError(this.handleError<Hero>('addHero'))
  );
}

addHero()​ 和 ​updateHero()​ 有两点不同。

  • 它调用 ​HttpClient.post()​ 而不是 ​put()​。
  • 它期待服务器为这个新的英雄生成一个 id,然后把它通过 ​Observable<Hero>​ 返回给调用者。

刷新浏览器,并添加一些英雄。

删除某个英雄

英雄列表中的每个英雄都有一个删除按钮。

把下列按钮(​button​)元素添加到 ​HeroesComponent ​的模板中,就在每个 ​<li>​ 元素中的英雄名字后方。

<button type="button" class="delete" title="delete hero"
  (click)="delete(hero)">x</button>

英雄列表的 HTML 应该是这样的:

<ul class="heroes">
  <li *ngFor="let hero of heroes">
    <a routerLink="/detail/{{hero.id}}">
      <span class="badge">{{hero.id}}</span> {{hero.name}}
    </a>
    <button type="button" class="delete" title="delete hero"
      (click)="delete(hero)">x</button>
  </li>
</ul>

要把删除按钮定位在每个英雄条目的最右边,就要往 ​heroes.component.css​ 中添加一些 CSS。你可以在下方的 最终代码 中找到这些 CSS。

把 ​delete()​ 处理器添加到组件中。

delete(hero: Hero): void {
  this.heroes = this.heroes.filter(h => h !== hero);
  this.heroService.deleteHero(hero.id).subscribe();
}

虽然这个组件把删除英雄的逻辑委托给了 ​HeroService​,但仍保留了更新它自己的英雄列表的职责。组件的 ​delete()​ 方法会在 ​HeroService ​对服务器的操作成功之前,先从列表中移除要删除的英雄

组件与 ​heroService.deleteHero()​ 返回的 ​Observable ​还完全没有关联。必须订阅它。

如果你忘了调用 ​subscribe()​,本服务将不会把这个删除请求发送给服务器。作为一条通用的规则,​Observable ​在有人订阅之前什么都不会做
你可以暂时删除 ​subscribe()​ 来确认这一点。点击“Dashboard”,然后点击“Heroes”,就又看到完整的英雄列表了。

接下来,把 ​deleteHero()​ 方法添加到 ​HeroService ​中,代码如下。

/** DELETE: delete the hero from the server */
deleteHero(id: number): Observable<Hero> {
  const url = `${this.heroesUrl}/${id}`;

  return this.http.delete<Hero>(url, this.httpOptions).pipe(
    tap(_ => this.log(`deleted hero id=${id}`)),
    catchError(this.handleError<Hero>('deleteHero'))
  );
}

注意以下关键点:

  • deleteHero()​ 调用了 ​HttpClient.delete()
  • URL 就是英雄的资源 URL 加上要删除的英雄的 ​id
  • 你不用像 ​put()​ 和 ​post()​ 中那样发送任何数据
  • 你仍要发送 ​httpOptions

刷新浏览器,并试一下这个新的删除功能。

根据名字搜索

在最后一次练习中,你要学到把 ​Observable ​的操作符串在一起,让你能将相似 HTTP 请求的数量最小化,并节省网络带宽。

你将往仪表盘中加入英雄搜索特性。当用户在搜索框中输入名字时,你会不断发送根据名字过滤英雄的 HTTP 请求。你的目标是仅仅发出尽可能少的必要请求。

HeroService.searchHeroes()

先把 ​searchHeroes()​ 方法添加到 ​HeroService ​中。

/* GET heroes whose name contains search term */
searchHeroes(term: string): Observable<Hero[]> {
  if (!term.trim()) {
    // if not search term, return empty hero array.
    return of([]);
  }
  return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(
    tap(x => x.length ?
       this.log(`found heroes matching "${term}"`) :
       this.log(`no heroes matching "${term}"`)),
    catchError(this.handleError<Hero[]>('searchHeroes', []))
  );
}

如果没有搜索词,该方法立即返回一个空数组。剩下的部分和 ​getHeroes()​ 很像。唯一的不同点是 URL,它包含了一个由搜索词组成的查询字符串。

为仪表盘添加搜索功能

打开 ​DashboardComponent ​的模板并且把用于搜索英雄的元素 ​<app-hero-search>​ 添加到代码的底部。

<h2>Top Heroes</h2>
<div class="heroes-menu">
  <a *ngFor="let hero of heroes"
      routerLink="/detail/{{hero.id}}">
      {{hero.name}}
  </a>
</div>

<app-hero-search></app-hero-search>

这个模板看起来很像 ​HeroesComponent ​模板中的 ​*ngFor​ 复写器。

为此,下一步就是添加一个组件,它的选择器要能匹配 ​<app-hero-search>​。

创建 HeroSearchComponent

使用 CLI 创建一个 ​HeroSearchComponent​。

ng generate component hero-search

CLI 生成了 ​HeroSearchComponent ​的三个文件,并把该组件添加到了 ​AppModule ​的声明中。

把生成的 ​HeroSearchComponent ​的模板改成一个 ​<input>​ 和一个匹配到的搜索结果的列表。代码如下。

<div id="search-component">
  <label for="search-box">Hero Search</label>
  <input #searchBox id="search-box" (input)="search(searchBox.value)" />

  <ul class="search-result">
    <li *ngFor="let hero of heroes$ | async" >
      <a routerLink="/detail/{{hero.id}}">
        {{hero.name}}
      </a>
    </li>
  </ul>
</div>

从下面的 最终代码 中把私有 CSS 样式添加到 ​hero-search.component.css​ 中。

当用户在搜索框中输入时,一个 ​input ​事件绑定会调用该组件的 ​search()​ 方法,并传入新的搜索框的值。

AsyncPipe

*ngFor​ 会重复渲染这些英雄对象。注意,​*ngFor​ 在一个名叫 ​heroes$​ 的列表上迭代,而不是 ​heroes​。​$​ 是一个约定,表示 ​heroes$​ 是一个 ​Observable ​而不是数组。

<li *ngFor="let hero of heroes$ | async" >

由于 ​*ngFor​ 不能直接使用 ​Observable​,所以要使用一个管道字符(​|​),后面紧跟着一个 ​async​。这表示 Angular 的 ​AsyncPipe ​管道,它会自动订阅 ​Observable​,这样你就不用在组件类中这么做了。

修正 HeroSearchComponent 类

修改所生成的 ​HeroSearchComponent ​类及其元数据,代码如下。

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

import { Observable, Subject } from 'rxjs';

import {
   debounceTime, distinctUntilChanged, switchMap
 } from 'rxjs/operators';

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

@Component({
  selector: 'app-hero-search',
  templateUrl: './hero-search.component.html',
  styleUrls: [ './hero-search.component.css' ]
})
export class HeroSearchComponent implements OnInit {
  heroes$!: Observable<Hero[]>;
  private searchTerms = new Subject<string>();

  constructor(private heroService: HeroService) {}

  // Push a search term into the observable stream.
  search(term: string): void {
    this.searchTerms.next(term);
  }

  ngOnInit(): void {
    this.heroes$ = this.searchTerms.pipe(
      // wait 300ms after each keystroke before considering the term
      debounceTime(300),

      // ignore new term if same as previous term
      distinctUntilChanged(),

      // switch to new search observable each time the term changes
      switchMap((term: string) => this.heroService.searchHeroes(term)),
    );
  }
}

注意,​heroes$​ 声明为一个 ​Observable

heroes$!: Observable<Hero[]>;

你将会在 ​ngOnInit()​ 中设置它,在此之前,先仔细看看 ​searchTerms ​的定义。

RxJS Subject 类型的 searchTerms

searchTerms ​属性是 RxJS 的 ​Subject ​类型。

private searchTerms = new Subject<string>();

// Push a search term into the observable stream.
search(term: string): void {
  this.searchTerms.next(term);
}

Subject ​既是可观察对象的数据源,本身也是 ​Observable​。你可以像订阅任何 ​Observable ​一样订阅 ​Subject​。

你还可以通过调用它的 ​next(value)​ 方法往 ​Observable ​中推送一些值,就像 ​search()​ 方法中一样。

文本框的 ​input ​事件的事件绑定会调用 ​search()​ 方法。

<input #searchBox id="search-box" (input)="search(searchBox.value)" />

每当用户在文本框中输入时,这个事件绑定就会使用文本框的值(搜索词)调用 search() 函数。searchTerms 变成了一个能发出搜索词的稳定的流。

串联 RxJS 操作符

如果每当用户按键后就直接调用 ​searchHeroes()​ 将导致创建海量的 HTTP 请求,浪费服务器资源并干扰数据调度计划。

应该怎么做呢?​ngOnInit()​ 往 ​searchTerms ​这个可观察对象的处理管道中加入了一系列 RxJS 操作符,用以缩减对 ​searchHeroes()​ 的调用次数,并最终返回一个可及时给出英雄搜索结果的可观察对象(每次都是 ​Hero[]​)。

代码如下:

this.heroes$ = this.searchTerms.pipe(
  // wait 300ms after each keystroke before considering the term
  debounceTime(300),

  // ignore new term if same as previous term
  distinctUntilChanged(),

  // switch to new search observable each time the term changes
  switchMap((term: string) => this.heroService.searchHeroes(term)),
);

各个操作符的工作方式如下:

  • 在传出最终字符串之前,​debounceTime(300)​ 将会等待,直到新增字符串的事件暂停了 300 毫秒。你实际发起请求的间隔永远不会小于 300ms。
  • distinctUntilChanged()​ 会确保只在过滤条件变化时才发送请求。
  • switchMap()​ 会为每个从 ​debounce()​ 和 ​distinctUntilChanged()​ 中通过的搜索词调用搜索服务。它会取消并丢弃以前的搜索可观察对象,只保留最近的。
借助 switchMap 操作符,每个有效的按键事件都会触发一次 ​HttpClient.get()​ 方法调用。即使在每个请求之间都有至少 300ms 的间隔,仍然可能会同时存在多个尚未返回的 HTTP 请求。
switchMap()​ 会记住原始的请求顺序,只会返回最近一次 HTTP 方法调用的结果。以前的那些请求都会被取消和舍弃。
注意:
取消前一个 ​searchHeroes()​ 可观察对象并不会中止尚未完成的 HTTP 请求。那些不想要的结果只会在它们抵达应用代码之前被舍弃。

记住,组件类中并没有订阅 ​heroes$​ 这个可观察对象,而是由模板中的 ​AsyncPipe ​完成的。

试试看

再次运行本应用。在这个 仪表盘 中,在搜索框中输入一些文字。如果你输入的字符匹配上了任何现有英雄的名字,你将会看到如下效果。


查看最终代码

本文讨论过的代码文件如下(都位于 ​src/app/​ 文件夹中)。

HeroService, InMemoryDataService, AppModule

  • hero.service.ts
  • import { Injectable } from '@angular/core';
    import { HttpClient, HttpHeaders } from '@angular/common/http';
    
    import { Observable, of } from 'rxjs';
    import { catchError, map, tap } from 'rxjs/operators';
    
    import { Hero } from './hero';
    import { MessageService } from './message.service';
    
    
    @Injectable({ providedIn: 'root' })
    export class HeroService {
    
      private heroesUrl = 'api/heroes';  // URL to web api
    
      httpOptions = {
        headers: new HttpHeaders({ 'Content-Type': 'application/json' })
      };
    
      constructor(
        private http: HttpClient,
        private messageService: MessageService) { }
    
      /** GET heroes from the server */
      getHeroes(): Observable<Hero[]> {
        return this.http.get<Hero[]>(this.heroesUrl)
          .pipe(
            tap(_ => this.log('fetched heroes')),
            catchError(this.handleError<Hero[]>('getHeroes', []))
          );
      }
    
      /** GET hero by id. Return `undefined` when id not found */
      getHeroNo404<Data>(id: number): Observable<Hero> {
        const url = `${this.heroesUrl}/?id=${id}`;
        return this.http.get<Hero[]>(url)
          .pipe(
            map(heroes => heroes[0]), // returns a {0|1} element array
            tap(h => {
              const outcome = h ? 'fetched' : 'did not find';
              this.log(`${outcome} hero id=${id}`);
            }),
            catchError(this.handleError<Hero>(`getHero id=${id}`))
          );
      }
    
      /** GET hero by id. Will 404 if id not found */
      getHero(id: number): Observable<Hero> {
        const url = `${this.heroesUrl}/${id}`;
        return this.http.get<Hero>(url).pipe(
          tap(_ => this.log(`fetched hero id=${id}`)),
          catchError(this.handleError<Hero>(`getHero id=${id}`))
        );
      }
    
      /* GET heroes whose name contains search term */
      searchHeroes(term: string): Observable<Hero[]> {
        if (!term.trim()) {
          // if not search term, return empty hero array.
          return of([]);
        }
        return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(
          tap(x => x.length ?
             this.log(`found heroes matching "${term}"`) :
             this.log(`no heroes matching "${term}"`)),
          catchError(this.handleError<Hero[]>('searchHeroes', []))
        );
      }
    
      //////// Save methods //////////
    
      /** POST: add a new hero to the server */
      addHero(hero: Hero): Observable<Hero> {
        return this.http.post<Hero>(this.heroesUrl, hero, this.httpOptions).pipe(
          tap((newHero: Hero) => this.log(`added hero w/ id=${newHero.id}`)),
          catchError(this.handleError<Hero>('addHero'))
        );
      }
    
      /** DELETE: delete the hero from the server */
      deleteHero(id: number): Observable<Hero> {
        const url = `${this.heroesUrl}/${id}`;
    
        return this.http.delete<Hero>(url, this.httpOptions).pipe(
          tap(_ => this.log(`deleted hero id=${id}`)),
          catchError(this.handleError<Hero>('deleteHero'))
        );
      }
    
      /** PUT: update the hero on the server */
      updateHero(hero: Hero): Observable<any> {
        return this.http.put(this.heroesUrl, hero, this.httpOptions).pipe(
          tap(_ => this.log(`updated hero id=${hero.id}`)),
          catchError(this.handleError<any>('updateHero'))
        );
      }
    
      /**
       * Handle Http operation that failed.
       * Let the app continue.
       *
       * @param operation - name of the operation that failed
       * @param result - optional value to return as the observable result
       */
      private handleError<T>(operation = 'operation', result?: T) {
        return (error: any): Observable<T> => {
    
          // TODO: send the error to remote logging infrastructure
          console.error(error); // log to console instead
    
          // TODO: better job of transforming error for user consumption
          this.log(`${operation} failed: ${error.message}`);
    
          // Let the app keep running by returning an empty result.
          return of(result as T);
        };
      }
    
      /** Log a HeroService message with the MessageService */
      private log(message: string) {
        this.messageService.add(`HeroService: ${message}`);
      }
    }
  • in-memory-data.service.ts
  • import { Injectable } from '@angular/core';
    import { InMemoryDbService } from 'angular-in-memory-web-api';
    import { Hero } from './hero';
    
    @Injectable({
      providedIn: 'root',
    })
    export class InMemoryDataService implements InMemoryDbService {
      createDb() {
        const heroes = [
          { id: 12, name: 'Dr. Nice' },
          { id: 13, name: 'Bombasto' },
          { id: 14, name: 'Celeritas' },
          { id: 15, name: 'Magneta' },
          { id: 16, name: 'RubberMan' },
          { id: 17, name: 'Dynama' },
          { id: 18, name: 'Dr. IQ' },
          { id: 19, name: 'Magma' },
          { id: 20, name: 'Tornado' }
        ];
        return {heroes};
      }
    
      // Overrides the genId method to ensure that a hero always has an id.
      // If the heroes array is empty,
      // the method below returns the initial number (11).
      // if the heroes array is not empty, the method below returns the highest
      // hero id + 1.
      genId(heroes: Hero[]): number {
        return heroes.length > 0 ? Math.max(...heroes.map(hero => hero.id)) + 1 : 11;
      }
    }
  • app.module.ts
  • import { NgModule } from '@angular/core';
    import { BrowserModule } from '@angular/platform-browser';
    import { FormsModule } from '@angular/forms';
    import { HttpClientModule } from '@angular/common/http';
    
    import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
    import { InMemoryDataService } from './in-memory-data.service';
    
    import { AppRoutingModule } from './app-routing.module';
    
    import { AppComponent } from './app.component';
    import { DashboardComponent } from './dashboard/dashboard.component';
    import { HeroDetailComponent } from './hero-detail/hero-detail.component';
    import { HeroesComponent } from './heroes/heroes.component';
    import { HeroSearchComponent } from './hero-search/hero-search.component';
    import { MessagesComponent } from './messages/messages.component';
    
    @NgModule({
      imports: [
        BrowserModule,
        FormsModule,
        AppRoutingModule,
        HttpClientModule,
    
        // The HttpClientInMemoryWebApiModule module intercepts HTTP requests
        // and returns simulated server responses.
        // Remove it when a real server is ready to receive requests.
        HttpClientInMemoryWebApiModule.forRoot(
          InMemoryDataService, { dataEncapsulation: false }
        )
      ],
      declarations: [
        AppComponent,
        DashboardComponent,
        HeroesComponent,
        HeroDetailComponent,
        MessagesComponent,
        HeroSearchComponent
      ],
      bootstrap: [ AppComponent ]
    })
    export class AppModule { }

HeroesComponent

  • heroes/heroes.component.html
  • <h2>My Heroes</h2>
    
    <div>
      <label for="new-hero">Hero name: </label>
      <input id="new-hero" #heroName />
    
      <!-- (click) passes input value to add() and then clears the input -->
      <button type="button" class="add-button" (click)="add(heroName.value); heroName.value=''">
        Add hero
      </button>
    </div>
    
    <ul class="heroes">
      <li *ngFor="let hero of heroes">
        <a routerLink="/detail/{{hero.id}}">
          <span class="badge">{{hero.id}}</span> {{hero.name}}
        </a>
        <button type="button" class="delete" title="delete hero"
          (click)="delete(hero)">x</button>
      </li>
    </ul>
  • heroes/heroes.component.ts
  • import { Component, OnInit } from '@angular/core';
    
    import { Hero } from '../hero';
    import { HeroService } from '../hero.service';
    
    @Component({
      selector: 'app-heroes',
      templateUrl: './heroes.component.html',
      styleUrls: ['./heroes.component.css']
    })
    export class HeroesComponent implements OnInit {
      heroes: Hero[] = [];
    
      constructor(private heroService: HeroService) { }
    
      ngOnInit(): void {
        this.getHeroes();
      }
    
      getHeroes(): void {
        this.heroService.getHeroes()
        .subscribe(heroes => this.heroes = heroes);
      }
    
      add(name: string): void {
        name = name.trim();
        if (!name) { return; }
        this.heroService.addHero({ name } as Hero)
          .subscribe(hero => {
            this.heroes.push(hero);
          });
      }
    
      delete(hero: Hero): void {
        this.heroes = this.heroes.filter(h => h !== hero);
        this.heroService.deleteHero(hero.id).subscribe();
      }
    
    }
  • heroes/heroes.component.css
  • /* HeroesComponent's private CSS styles */
    .heroes {
      margin: 0 0 2em 0;
      list-style-type: none;
      padding: 0;
      width: 15em;
    }
    
    input {
      display: block;
      width: 100%;
      padding: .5rem;
      margin: 1rem 0;
      box-sizing: border-box;
    }
    
    .heroes li {
      position: relative;
      cursor: pointer;
    }
    
    .heroes li:hover {
      left: .1em;
    }
    
    .heroes a {
      color: #333;
      text-decoration: none;
      background-color: #EEE;
      margin: .5em;
      padding: .3em 0;
      height: 1.6em;
      border-radius: 4px;
      display: block;
      width: 100%;
    }
    
    .heroes a:hover {
      color: #2c3a41;
      background-color: #e6e6e6;
    }
    
    .heroes a:active {
      background-color: #525252;
      color: #fafafa;
    }
    
    .heroes .badge {
      display: inline-block;
      font-size: small;
      color: white;
      padding: 0.8em 0.7em 0 0.7em;
      background-color: #405061;
      line-height: 1em;
      position: relative;
      left: -1px;
      top: -4px;
      height: 1.8em;
      min-width: 16px;
      text-align: right;
      margin-right: .8em;
      border-radius: 4px 0 0 4px;
    }
    
    .add-button {
     padding: .5rem 1.5rem;
     font-size: 1rem;
     margin-bottom: 2rem;
    }
    
    .add-button:hover {
      color: white;
      background-color: #42545C;
    }
    
    button.delete {
      position: absolute;
      left: 210px;
      top: 5px;
      background-color: white;
      color:  #525252;
      font-size: 1.1rem;
      margin: 0;
      padding: 1px 10px 3px 10px;
    }
    
    button.delete:hover {
      background-color: #525252;
      color: white;
    }

HeroDetailComponent

  • hero-detail/hero-detail.component.html
  • <div *ngIf="hero">
      <h2>{{hero.name | uppercase}} Details</h2>
      <div><span>id: </span>{{hero.id}}</div>
      <div>
        <label for="hero-name">Hero name: </label>
        <input id="hero-name" [(ngModel)]="hero.name" placeholder="Hero name"/>
      </div>
      <button type="button" (click)="goBack()">go back</button>
      <button type="button" (click)="save()">save</button>
    </div>
  • hero-detail/hero-detail.component.ts
  • import { Component, OnInit } from '@angular/core';
    import { ActivatedRoute } from '@angular/router';
    import { Location } from '@angular/common';
    
    import { Hero } from '../hero';
    import { HeroService } from '../hero.service';
    
    @Component({
      selector: 'app-hero-detail',
      templateUrl: './hero-detail.component.html',
      styleUrls: [ './hero-detail.component.css' ]
    })
    export class HeroDetailComponent implements OnInit {
      hero: Hero | undefined;
    
      constructor(
        private route: ActivatedRoute,
        private heroService: HeroService,
        private location: Location
      ) {}
    
      ngOnInit(): void {
        this.getHero();
      }
    
      getHero(): void {
        const id = parseInt(this.route.snapshot.paramMap.get('id')!, 10);
        this.heroService.getHero(id)
          .subscribe(hero => this.hero = hero);
      }
    
      goBack(): void {
        this.location.back();
      }
    
      save(): void {
        if (this.hero) {
          this.heroService.updateHero(this.hero)
            .subscribe(() => this.goBack());
        }
      }
    }
  • hero-detail/hero-detail.component.css
  • /* HeroDetailComponent's private CSS styles */
    label {
      color: #435960;
      font-weight: bold;
    }
    input {
      font-size: 1em;
      padding: .5rem;
    }
    button {
      margin-top: 20px;
      margin-right: .5rem;
      background-color: #eee;
      padding: 1rem;
      border-radius: 4px;
      font-size: 1rem;
    }
    button:hover {
      background-color: #cfd8dc;
    }
    button:disabled {
      background-color: #eee;
      color: #ccc;
      cursor: auto;
    }

DashboardComponent

  • dashboard/dashboard.component.html
  • <h2>Top Heroes</h2>
    <div class="heroes-menu">
      <a *ngFor="let hero of heroes"
          routerLink="/detail/{{hero.id}}">
          {{hero.name}}
      </a>
    </div>
    
    <app-hero-search></app-hero-search>
  • dashboard/dashboard.component.ts
  • import { Component, OnInit } from '@angular/core';
    import { Hero } from '../hero';
    import { HeroService } from '../hero.service';
    
    @Component({
      selector: 'app-dashboard',
      templateUrl: './dashboard.component.html',
      styleUrls: [ './dashboard.component.css' ]
    })
    export class DashboardComponent implements OnInit {
      heroes: Hero[] = [];
    
      constructor(private heroService: HeroService) { }
    
      ngOnInit(): void {
        this.getHeroes();
      }
    
      getHeroes(): void {
        this.heroService.getHeroes()
          .subscribe(heroes => this.heroes = heroes.slice(1, 5));
      }
    }
  • dashboard/dashboard.component.css
  • /* DashboardComponent's private CSS styles */
    
    h2 {
      text-align: center;
    }
    
    .heroes-menu {
      padding: 0;
      margin: auto;
      max-width: 1000px;
    
      /* flexbox */
      display: -webkit-box;
      display: -moz-box;
      display: -ms-flexbox;
      display: -webkit-flex;
      display: flex;
      flex-direction: row;
      flex-wrap: wrap;
      justify-content: space-around;
      align-content: flex-start;
      align-items: flex-start;
    }
    
    a {
      background-color: #3f525c;
      border-radius: 2px;
      padding: 1rem;
      font-size: 1.2rem;
      text-decoration: none;
      display: inline-block;
      color: #fff;
      text-align: center;
      width: 100%;
      min-width: 70px;
      margin: .5rem auto;
      box-sizing: border-box;
    
      /* flexbox */
      order: 0;
      flex: 0 1 auto;
      align-self: auto;
    }
    
    @media (min-width: 600px) {
      a {
        width: 18%;
        box-sizing: content-box;
      }
    }
    
    a:hover {
      background-color: black;
    }

HeroSearchComponent

  • hero-search/hero-search.component.html
  • <div id="search-component">
      <label for="search-box">Hero Search</label>
      <input #searchBox id="search-box" (input)="search(searchBox.value)" />
    
      <ul class="search-result">
        <li *ngFor="let hero of heroes$ | async" >
          <a routerLink="/detail/{{hero.id}}">
            {{hero.name}}
          </a>
        </li>
      </ul>
    </div>
  • hero-search/hero-search.component.ts
  • import { Component, OnInit } from '@angular/core';
    
    import { Observable, Subject } from 'rxjs';
    
    import {
       debounceTime, distinctUntilChanged, switchMap
     } from 'rxjs/operators';
    
    import { Hero } from '../hero';
    import { HeroService } from '../hero.service';
    
    @Component({
      selector: 'app-hero-search',
      templateUrl: './hero-search.component.html',
      styleUrls: [ './hero-search.component.css' ]
    })
    export class HeroSearchComponent implements OnInit {
      heroes$!: Observable<Hero[]>;
      private searchTerms = new Subject<string>();
    
      constructor(private heroService: HeroService) {}
    
      // Push a search term into the observable stream.
      search(term: string): void {
        this.searchTerms.next(term);
      }
    
      ngOnInit(): void {
        this.heroes$ = this.searchTerms.pipe(
          // wait 300ms after each keystroke before considering the term
          debounceTime(300),
    
          // ignore new term if same as previous term
          distinctUntilChanged(),
    
          // switch to new search observable each time the term changes
          switchMap((term: string) => this.heroService.searchHeroes(term)),
        );
      }
    }
  • hero-search/hero-search.component.css
  • /* HeroSearch private styles */
    
    label {
      display: block;
      font-weight: bold;
      font-size: 1.2rem;
      margin-top: 1rem;
      margin-bottom: .5rem;
    
    }
    input {
      padding: .5rem;
      width: 100%;
      max-width: 600px;
      box-sizing: border-box;
      display: block;
    }
    
    input:focus {
      outline: #336699 auto 1px;
    }
    
    li {
      list-style-type: none;
    }
    .search-result li a {
      border-bottom: 1px solid gray;
      border-left: 1px solid gray;
      border-right: 1px solid gray;
      display: inline-block;
      width: 100%;
      max-width: 600px;
      padding: .5rem;
      box-sizing: border-box;
      text-decoration: none;
      color: black;
    }
    
    .search-result li a:hover {
      background-color: #435A60;
      color: white;
    }
    
    ul.search-result {
      margin-top: 0;
      padding-left: 0;
    }

小结

旅程即将结束,不过你已经收获颇丰。

  • 你添加了在应用程序中使用 HTTP 的必备依赖
  • 你重构了 ​HeroService​,以通过 web API 来加载英雄数据
  • 你扩展了 ​HeroService ​来支持 ​post()​、​put()​ 和 ​delete()​ 方法
  • 你修改了组件,以允许用户添加、编辑和删除英雄
  • 你配置了一个内存 Web API
  • 你学会了如何使用“可观察对象”