Angular 英雄之旅-從服務(wù)器獲取數(shù)據(jù)

2022-07-19 14:35 更新

從服務(wù)端獲取數(shù)據(jù)

在這節(jié)課中,你將借助 Angular 的 ?HttpClient ?來添加一些數(shù)據(jù)持久化特性。

  • ?HeroService ?通過 HTTP 請求獲取英雄數(shù)據(jù)
  • 用戶可以添加、編輯和刪除英雄,并通過 HTTP 來保存這些更改
  • 用戶可以根據(jù)名字搜索英雄

要查看本頁所講的范例程序,參閱現(xiàn)場演練 / 下載范例

啟用 HTTP 服務(wù)

?HttpClient ?是 Angular 通過 HTTP 與遠程服務(wù)器通訊的機制。

要讓 ?HttpClient ?在應(yīng)用中隨處可用,需要兩個步驟。首先,用導入語句把它添加到根模塊 ?AppModule ?中:

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

接下來,仍然在 ?AppModule ?中,把 ?HttpClientModule ?添加到 ?imports ?數(shù)組中:

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

模擬數(shù)據(jù)服務(wù)器

這個教學例子會與一個使用 內(nèi)存 Web API(In-memory Web API) 模擬出的遠程數(shù)據(jù)服務(wù)器通訊。

安裝完這個模塊之后,應(yīng)用將會通過 ?HttpClient ?來發(fā)起請求和接收響應(yīng),而不用在乎實際上是這個內(nèi)存 Web API 在攔截這些請求、操作一個內(nèi)存數(shù)據(jù)庫,并且給出仿真的響應(yīng)。

通過使用內(nèi)存 Web API,你不用架設(shè)服務(wù)器就可以學習 ?HttpClient ?了。

重要:
這個內(nèi)存 Web API 模塊與 Angular 中的 HTTP 模塊無關(guān)。
如果你只是在閱讀本教程來學習 ?HttpClient?,那么可以跳過這一步。 如果你正在隨著本教程敲代碼,那就留下來,并加上這個內(nèi)存 Web API。

用如下命令從 ?npm ?中安裝這個內(nèi)存 Web API 包(譯注:請使用 0.5+ 的版本,不要使用 0.4-)

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

在 ?AppModule ?中,導入 ?HttpClientInMemoryWebApiModule ?和 ?InMemoryDataService ?類,稍后你將創(chuàng)建它們。

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

在 ?HttpClientModule ?之后,將 ?HttpClientInMemoryWebApiModule ?添加到 ?AppModule ?的 ?imports ?數(shù)組中,并以 ?InMemoryDataService ?為參數(shù)對其進行配置。

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 ?類來初始化內(nèi)存數(shù)據(jù)庫。

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

ng generate service InMemoryData

將 ?in-memory-data.service.ts? 改為以下內(nèi)容:

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? 文件,現(xiàn)在后者可以安全的刪除了。

等服務(wù)器就緒后,你就可以拋棄這個內(nèi)存 Web API,應(yīng)用的請求將直接傳給服務(wù)器。

英雄與 HTTP

在 ?HeroService ?中,導入 ?HttpClient ?和 ?HttpHeaders?:

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

仍然在 ?HeroService ?中,把 ?HttpClient ?注入到構(gòu)造函數(shù)中一個名叫 ?http ?的私有屬性中。

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

注意保留對 ?MessageService ?的注入,但是因為你將頻繁調(diào)用它,因此請把它包裹進一個私有的 ?log ?方法中。

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

把服務(wù)器上英雄數(shù)據(jù)資源的訪問地址 heroesURL 定義為 :base/:collectionName 的形式。這里的 base 是要請求的資源,而 collectionName 是 in-memory-data-service.ts 中的英雄數(shù)據(jù)對象。

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

通過 HttpClient 獲取英雄

當前的 ?HeroService.getHeroes()? 使用 RxJS 的 ?of()? 函數(shù)來把模擬英雄數(shù)據(jù)返回為 ?Observable<Hero[]>? 格式。

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

把該方法轉(zhuǎn)換成使用 ?HttpClient ?的,代碼如下:

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

刷新瀏覽器后,英雄數(shù)據(jù)就會從模擬服務(wù)器被成功讀取。

你用 ?http.get()? 替換了 ?of()?,沒有做其它修改,但是應(yīng)用仍然在正常工作,這是因為這兩個函數(shù)都返回了 ?Observable<Hero[]>?。

HttpClient 的方法返回單個值

所有的 ?HttpClient ?方法都會返回某個值的 RxJS ?Observable?。

HTTP 是一個請求/響應(yīng)式協(xié)議。你發(fā)起請求,它返回單個的響應(yīng)。

通常,?Observable ?可以在一段時間內(nèi)返回多個值。但來自 ?HttpClient ?的 ?Observable ?總是發(fā)出一個值,然后結(jié)束,再也不會發(fā)出其它值。

具體到這次 ?HttpClient.get()? 調(diào)用,它返回一個 ?Observable<Hero[]>?,也就是“一個英雄數(shù)組的可觀察對象”。在實踐中,它也只會返回一個英雄數(shù)組。

HttpClient.get() 返回響應(yīng)數(shù)據(jù)

?HttpClient.get()? 默認情況下把響應(yīng)體當做無類型的 JSON 對象進行返回。如果指定了可選的模板類型 ?<Hero[]>?,就會給返回你一個類型化的對象。

服務(wù)器的數(shù)據(jù) API 決定了 JSON 數(shù)據(jù)的具體形態(tài)。英雄之旅的數(shù)據(jù) API 會把英雄數(shù)據(jù)作為一個數(shù)組進行返回。

其它 API 可能在返回對象中深埋著你想要的數(shù)據(jù)。你可能要借助 RxJS 的 ?map()? 操作符對 ?Observable ?的結(jié)果進行處理,以便把這些數(shù)據(jù)挖掘出來。
雖然不打算在此展開討論,不過你可以到范例源碼中的 ?getHeroNo404()? 方法中找到一個使用 ?map()? 操作符的例子。

錯誤處理

凡事皆會出錯,特別是當你從遠端服務(wù)器獲取數(shù)據(jù)的時候。?HeroService.getHeroes()? 方法應(yīng)該捕獲錯誤,并做適當?shù)奶幚怼?/p>

要捕獲錯誤,你就要使用 RxJS 的 ?catchError()? 操作符來建立對 Observable 結(jié)果的處理管道(pipe)。

從 ?rxjs/operators? 中導入 ?catchError ?符號,以及你稍后將會用到的其它操作符。

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

現(xiàn)在,使用 ?pipe()? 方法來擴展 ?Observable ?的結(jié)果,并給它一個 ?catchError()? 操作符。

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

?catchError()? 操作符會攔截失敗的 ?Observable?。它把錯誤對象傳給錯誤處理器,錯誤處理器會處理這個錯誤。

下面的 ?handleError()? 方法會報告這個錯誤,并返回一個無害的結(jié)果(安全值),以便應(yīng)用能正常工作。

handleError

下面這個 ?handleError()? 將會在很多 ?HeroService ?的方法之間共享,所以要把它通用化,以支持這些彼此不同的需求。

它不再直接處理這些錯誤,而是返回給 ?catchError ?返回一個錯誤處理函數(shù)。還要用操作名和出錯時要返回的安全值來對這個錯誤處理函數(shù)進行配置。

/**
 * 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);
  };
}

在控制臺中匯報了這個錯誤之后,這個處理器會匯報一個用戶友好的消息,并給應(yīng)用返回一個安全值,讓應(yīng)用繼續(xù)工作。

因為每個服務(wù)方法都會返回不同類型的 ?Observable ?結(jié)果,因此 ?handleError()? 也需要一個類型參數(shù),以便它返回一個此類型的安全值,正如應(yīng)用所期望的那樣。

窺探 Observable

?HeroService ?的方法將會窺探 ?Observable ?的數(shù)據(jù)流,并通過 ?log()? 方法往頁面底部發(fā)送一條消息。

它們可以使用 RxJS 的 ?tap()? 操作符來實現(xiàn),該操作符會查看 Observable 中的值,使用那些值做一些事情,并且把它們傳出來。這種 ?tap()? 回調(diào)不會改變這些值本身。

下面是 ?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 獲取英雄

大多數(shù)的 Web API 都支持以 ?:baseURL/:id? 的形式根據(jù) id 進行獲取。

這里的 ?baseURL ?就是在上面 英雄列表與 HTTP 部分定義過的 ?heroesURL?(?api/heroes?)。而 ?id ?則是你要獲取的英雄的編號,比如,?api/heroes/11?。

把 ?HeroService.getHero()? 方法改成這樣,以發(fā)起該請求:

/** 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 構(gòu)造了一個請求 URL
  • 服務(wù)器應(yīng)該使用單個英雄作為回應(yīng),而不是一個英雄數(shù)組
  • 所以,?getHero()? 會返回 ?Observable<Hero>?(“一個可觀察的單個英雄對象”),而不是一個可觀察的英雄對象數(shù)組

修改英雄

英雄詳情視圖中編輯英雄的名字。隨著輸入,英雄的名字也跟著在頁面頂部的標題區(qū)更新了。但是當你點擊“后退”按鈕時,這些修改都丟失了。

如果你希望保留這些修改,就要把它們寫回到服務(wù)器。

在英雄詳情模板的底部添加一個保存按鈕,它綁定了一個 ?click ?事件,事件綁定會調(diào)用組件中一個名叫 ?save()? 的新方法。

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

在 ?HeroDetail ?組件類中,添加如下的 ?save()? 方法,它使用英雄服務(wù)中的 ?updateHero()? 方法來保存對英雄名字的修改,然后導航回前一個視圖。

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

添加 HeroService.updateHero()

?updateHero()? 的總體結(jié)構(gòu)和 ?getHeroes()? 很相似,但它會使用 ?http.put()? 來把修改后的英雄保存到服務(wù)器上。把下列代碼添加進 ?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()? 方法接受三個參數(shù):

  • URL 地址
  • 要修改的數(shù)據(jù)(這里就是修改后的英雄)
  • 選項

URL 沒變。英雄 Web API 通過英雄對象的 ?id ?就可以知道要修改哪個英雄。

英雄 Web API 期待在保存時的請求中有一個特殊的頭。這個頭是在 ?HeroService ?的 ?httpOptions ?常量中定義的。

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

刷新瀏覽器,修改英雄名,保存這些修改。在 ?HeroDetailComponent ?的 ?save()? 方法中導航到前一個視圖?,F(xiàn)在,改名后的英雄已經(jīng)顯示在列表中了。

添加一個新英雄

要添加英雄,本應(yīng)用中只需要英雄的名字。你可以使用一個和添加按鈕成對的 ?<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>

當點擊事件觸發(fā)時,調(diào)用組件的點擊處理器(?add()?),然后清空這個輸入框,以便用來輸入另一個名字。把下列代碼添加到 ?HeroesComponent ?類:

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

當指定的名字非空時,這個處理器會用這個名字創(chuàng)建一個類似于 ?Hero ?的對象(只缺少 ?id ?屬性),并把它傳給服務(wù)的 ?addHero()? 方法。

當 ?addHero()? 保存成功時,?subscribe()? 的回調(diào)函數(shù)會收到這個新英雄,并把它追加到 ?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()? 有兩點不同。

  • 它調(diào)用 ?HttpClient.post()? 而不是 ?put()?。
  • 它期待服務(wù)器為這個新的英雄生成一個 id,然后把它通過 ?Observable<Hero>? 返回給調(diào)用者。

刷新瀏覽器,并添加一些英雄。

刪除某個英雄

英雄列表中的每個英雄都有一個刪除按鈕。

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

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

英雄列表的 HTML 應(yīng)該是這樣的:

<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 ?對服務(wù)器的操作成功之前,先從列表中移除要刪除的英雄。

組件與 ?heroService.deleteHero()? 返回的 ?Observable ?還完全沒有關(guān)聯(lián)。必須訂閱它。

如果你忘了調(diào)用 ?subscribe()?,本服務(wù)將不會把這個刪除請求發(fā)送給服務(wù)器。作為一條通用的規(guī)則,?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'))
  );
}

注意以下關(guān)鍵點:

  • ?deleteHero()? 調(diào)用了 ?HttpClient.delete() ?
  • URL 就是英雄的資源 URL 加上要刪除的英雄的 ?id ?
  • 你不用像 ?put()? 和 ?post()? 中那樣發(fā)送任何數(shù)據(jù)
  • 你仍要發(fā)送 ?httpOptions ?

刷新瀏覽器,并試一下這個新的刪除功能。

根據(jù)名字搜索

在最后一次練習中,你要學到把 ?Observable ?的操作符串在一起,讓你能將相似 HTTP 請求的數(shù)量最小化,并節(jié)省網(wǎng)絡(luò)帶寬。

你將往儀表盤中加入英雄搜索特性。當用戶在搜索框中輸入名字時,你會不斷發(fā)送根據(jù)名字過濾英雄的 HTTP 請求。你的目標是僅僅發(fā)出盡可能少的必要請求。

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', []))
  );
}

如果沒有搜索詞,該方法立即返回一個空數(shù)組。剩下的部分和 ?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>?。

創(chuàng)建 HeroSearchComponent

使用 CLI 創(chuàng)建一個 ?HeroSearchComponent?。

ng generate component hero-search

CLI 生成了 ?HeroSearchComponent ?的三個文件,并把該組件添加到了 ?AppModule ?的聲明中。

把生成的 ?HeroSearchComponent ?的模板改成一個 ?<input>? 和一個匹配到的搜索結(jié)果的列表。代碼如下。

<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 ?事件綁定會調(diào)用該組件的 ?search()? 方法,并傳入新的搜索框的值。

AsyncPipe

?*ngFor? 會重復渲染這些英雄對象。注意,?*ngFor? 在一個名叫 ?heroes$? 的列表上迭代,而不是 ?heroes?。?$? 是一個約定,表示 ?heroes$? 是一個 ?Observable ?而不是數(shù)組。

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

由于 ?*ngFor? 不能直接使用 ?Observable?,所以要使用一個管道字符(?|?),后面緊跟著一個 ?async?。這表示 Angular 的 ?AsyncPipe ?管道,它會自動訂閱 ?Observable?,這樣你就不用在組件類中這么做了。

修正 HeroSearchComponent 類

修改所生成的 ?HeroSearchComponent ?類及其元數(shù)據(jù),代碼如下。

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()? 中設(shè)置它,在此之前,先仔細看看 ?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 ?既是可觀察對象的數(shù)據(jù)源,本身也是 ?Observable?。你可以像訂閱任何 ?Observable ?一樣訂閱 ?Subject?。

你還可以通過調(diào)用它的 ?next(value)? 方法往 ?Observable ?中推送一些值,就像 ?search()? 方法中一樣。

文本框的 ?input ?事件的事件綁定會調(diào)用 ?search()? 方法。

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

每當用戶在文本框中輸入時,這個事件綁定就會使用文本框的值(搜索詞)調(diào)用 search() 函數(shù)。searchTerms 變成了一個能發(fā)出搜索詞的穩(wěn)定的流。

串聯(lián) RxJS 操作符

如果每當用戶按鍵后就直接調(diào)用 ?searchHeroes()? 將導致創(chuàng)建海量的 HTTP 請求,浪費服務(wù)器資源并干擾數(shù)據(jù)調(diào)度計劃。

應(yīng)該怎么做呢??ngOnInit()? 往 ?searchTerms ?這個可觀察對象的處理管道中加入了一系列 RxJS 操作符,用以縮減對 ?searchHeroes()? 的調(diào)用次數(shù),并最終返回一個可及時給出英雄搜索結(jié)果的可觀察對象(每次都是 ?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 毫秒。你實際發(fā)起請求的間隔永遠不會小于 300ms。
  • ?distinctUntilChanged()? 會確保只在過濾條件變化時才發(fā)送請求。
  • ?switchMap()? 會為每個從 ?debounce()? 和 ?distinctUntilChanged()? 中通過的搜索詞調(diào)用搜索服務(wù)。它會取消并丟棄以前的搜索可觀察對象,只保留最近的。
借助 switchMap 操作符,每個有效的按鍵事件都會觸發(fā)一次 ?HttpClient.get()? 方法調(diào)用。即使在每個請求之間都有至少 300ms 的間隔,仍然可能會同時存在多個尚未返回的 HTTP 請求。
?switchMap()? 會記住原始的請求順序,只會返回最近一次 HTTP 方法調(diào)用的結(jié)果。以前的那些請求都會被取消和舍棄。
注意:
取消前一個 ?searchHeroes()? 可觀察對象并不會中止尚未完成的 HTTP 請求。那些不想要的結(jié)果只會在它們抵達應(yīng)用代碼之前被舍棄。

記住,組件類中并沒有訂閱 ?heroes$? 這個可觀察對象,而是由模板中的 ?AsyncPipe ?完成的。

試試看

再次運行本應(yīng)用。在這個 儀表盤 中,在搜索框中輸入一些文字。如果你輸入的字符匹配上了任何現(xiàn)有英雄的名字,你將會看到如下效果。


查看最終代碼

本文討論過的代碼文件如下(都位于 ?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;
    }

小結(jié)

旅程即將結(jié)束,不過你已經(jīng)收獲頗豐。

  • 你添加了在應(yīng)用程序中使用 HTTP 的必備依賴
  • 你重構(gòu)了 ?HeroService?,以通過 web API 來加載英雄數(shù)據(jù)
  • 你擴展了 ?HeroService ?來支持 ?post()?、?put()? 和 ?delete()? 方法
  • 你修改了組件,以允許用戶添加、編輯和刪除英雄
  • 你配置了一個內(nèi)存 Web API
  • 你學會了如何使用“可觀察對象”


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號