根据名字搜索
在最后一次练习中,你要学到把 Observable
的操作符串在一起,让你能将相似 HTTP 请求的数量最小化,并节省网络带宽。
你将往仪表盘中加入英雄搜索特性。 当用户在搜索框中输入名字时,你会不断发送根据名字过滤英雄的 HTTP 请求。 你的目标是仅仅发出尽可能少的必要请求。
HeroService.searchHeroes
先把 searchHeroes
方法添加到 HeroService
中。
<code-example path="toh-pt6/src/app/hero.service.ts" region="searchHeroes" ng-version="5.2.0" style="clear: both; display: block; background-color: rgba(242, 242, 242, 0.2); border: 0.5px solid rgb(219, 219, 219); border-radius: 5px; color: rgb(51, 51, 51); margin: 16px auto; font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"><header class="ng-star-inserted" style="background-color: rgb(30, 136, 229); border-radius: 5px 5px 0px 0px; color: rgb(250, 250, 250); font-size: 16px; padding: 8px 16px;">src/app/hero.service.ts</header>
<aio-code class="headed-code"><pre class="prettyprint lang-" style="display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; -webkit-box-align: center; align-items: center; position: relative;">content_copy /* 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[]>(
api/heroes/?name=${term}).pipe( tap(_ => this.log(
found heroes matching "${term}")), catchError(this.handleError<Hero[]>('searchHeroes', [])) ); }
</pre></aio-code></code-example>
如果没有搜索词,该方法立即返回一个空数组。 剩下的部分和 getHeroes()
很像。 唯一的不同点是 URL,它包含了一个由搜索词组成的查询字符串。
为仪表盘添加搜索功能
打开 DashboardComponent
的模板并且把用于搜索英雄的元素 <app-hero-search>
添加到 DashboardComponent
模板的底部。
<code-example path="toh-pt6/src/app/dashboard/dashboard.component.html" linenums="false" ng-version="5.2.0" style="clear: both; display: block; background-color: rgba(242, 242, 242, 0.2); border: 0.5px solid rgb(219, 219, 219); border-radius: 5px; color: rgb(51, 51, 51); margin: 16px auto; font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"><header class="ng-star-inserted" style="background-color: rgb(30, 136, 229); border-radius: 5px 5px 0px 0px; color: rgb(250, 250, 250); font-size: 16px; padding: 8px 16px;">src/app/dashboard/dashboard.component.html</header>
<aio-code class="headed-code"><pre class="prettyprint lang-" style="display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; -webkit-box-align: center; align-items: center; position: relative;">content_copy <h3>Top Heroes</h3> <div class="grid grid-pad"> <[a](https://angular.cn/api/router/RouterLinkWithHref) *[ngFor](https://angular.cn/api/common/NgForOf)="let hero of heroes" class="col-1-4" [routerLink](https://angular.cn/api/router/RouterLink)="/detail/{{hero.id}}"> <div class="module hero"> <h4>{{hero.name}}</h4> </div> </[a](https://angular.cn/api/router/RouterLinkWithHref)> </div> <app-hero-search></app-hero-search>
</pre></aio-code></code-example>
这个模板看起来很像 HeroesComponent
模板中的 *[ngFor](https://angular.cn/api/common/NgForOf)
复写器。
很不幸,添加这个元素让本应用挂了。 Angular 找不到哪个组件的选择器能匹配上 <app-hero-search>
。
HeroSearchComponent
还不存在,这就解决。
创建 HeroSearchComponent
使用 CLI 创建一个 HeroSearchComponent
。
<code-example language="sh" class="code-shell" ng-version="5.2.0" style="clear: both; display: block; background-color: rgb(51, 51, 51); border: 0.5px solid rgb(219, 219, 219); border-radius: 5px; color: rgb(51, 51, 51); margin: 16px auto; font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"><aio-code class="simple-code"><pre class="prettyprint lang-sh" style="display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; -webkit-box-align: center; align-items: center; position: relative;">content_copy ng generate component hero-search
</pre></aio-code></code-example>
CLI 生成了 HeroSearchComponent
的三个文件,并把该组件添加到了 AppModule
的声明中。
把生成的 HeroSearchComponent
的模板改成一个输入框和一个匹配到的搜索结果的列表。代码如下:
<code-example path="toh-pt6/src/app/hero-search/hero-search.component.html" ng-version="5.2.0" style="clear: both; display: block; background-color: rgba(242, 242, 242, 0.2); border: 0.5px solid rgb(219, 219, 219); border-radius: 5px; color: rgb(51, 51, 51); margin: 16px auto; font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"><header class="ng-star-inserted" style="background-color: rgb(30, 136, 229); border-radius: 5px 5px 0px 0px; color: rgb(250, 250, 250); font-size: 16px; padding: 8px 16px;">src/app/hero-search/hero-search.component.html</header>
<aio-code class="headed-code"><pre class="prettyprint lang-" style="display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; -webkit-box-align: center; align-items: center; position: relative;">content_copy <div id="search-component"> <h4>Hero Search</h4> <input #searchBox id="search-box" (keyup)="search(searchBox.value)" /> <ul class="search-result"> <li *[ngFor](https://angular.cn/api/common/NgForOf)="let hero of heroes$ | [async](https://angular.cn/api/core/testing/async)" > <[a](https://angular.cn/api/router/RouterLinkWithHref) [routerLink](https://angular.cn/api/router/RouterLink)="/detail/{{hero.id}}"> {{hero.name}} </[a](https://angular.cn/api/router/RouterLinkWithHref)> </li> </ul> </div>
</pre></aio-code></code-example>
从下面的 最终代码 中把私有 CSS 样式添加到 hero-search.component.css
中。
当用户在搜索框中输入时,一个 keyup 事件绑定会调用该组件的 search()
方法,并传入新的搜索框的值。
AsyncPipe
如你所愿,*[ngFor](https://angular.cn/api/common/NgForOf)
重复渲染出了这些英雄。
仔细看,你会发现 *[ngFor](https://angular.cn/api/common/NgForOf)
是在一个名叫 heroes$
的列表上迭代,而不是 heroes
。
<code-example path="toh-pt6/src/app/hero-search/hero-search.component.html" region="async" ng-version="5.2.0" style="clear: both; display: block; background-color: rgba(242, 242, 242, 0.2); border: 0.5px solid rgb(219, 219, 219); border-radius: 5px; color: rgb(51, 51, 51); margin: 16px auto; font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"><aio-code class="simple-code"><pre class="prettyprint lang-" style="display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; -webkit-box-align: center; align-items: center; position: relative;">content_copy <li *[ngFor](https://angular.cn/api/common/NgForOf)="let hero of heroes$ | [async](https://angular.cn/api/core/testing/async)" >
</pre></aio-code></code-example>
$
是一个命名惯例,用来表明 heroes$
是一个 Observable
,而不是数组。
*[ngFor](https://angular.cn/api/common/NgForOf)
不能直接使用 Observable
。 不过,它后面还有一个管道字符(|
),后面紧跟着一个 [async](https://angular.cn/api/core/testing/async)
,它表示 Angular 的 [AsyncPipe](https://angular.cn/api/common/AsyncPipe)
。
[AsyncPipe](https://angular.cn/api/common/AsyncPipe)
会自动订阅到 Observable
,这样你就不用再在组件类中订阅了。
修正 HeroSearchComponent
类
修改所生成的 HeroSearchComponent
类及其元数据,代码如下:
<code-example path="toh-pt6/src/app/hero-search/hero-search.component.ts" ng-version="5.2.0" style="clear: both; display: block; background-color: rgba(242, 242, 242, 0.2); border: 0.5px solid rgb(219, 219, 219); border-radius: 5px; color: rgb(51, 51, 51); margin: 16px auto; font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"><header class="ng-star-inserted" style="background-color: rgb(30, 136, 229); border-radius: 5px 5px 0px 0px; color: rgb(250, 250, 250); font-size: 16px; padding: 8px 16px;">src/app/hero-search/hero-search.component.ts</header>
<aio-code class="headed-code"><pre class="prettyprint lang-" style="display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; -webkit-box-align: center; align-items: center; position: relative;">content_copy import { [Component](https://angular.cn/api/core/Component), [OnInit](https://angular.cn/api/core/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](https://angular.cn/api/core/Component)({ selector: 'app-hero-search', templateUrl: './hero-search.component.html', styleUrls: [ './hero-search.component.css' ] }) export class HeroSearchComponent implements [OnInit](https://angular.cn/api/core/OnInit) { heroes$: Observable<Hero[]>; private searchTerms = new Subject<string>(); constructor(private heroService: HeroService) {} // Push [a](https://angular.cn/api/router/RouterLinkWithHref) 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)), ); } }
</pre></aio-code></code-example>
注意,heroes$
声明为一个 Observable
<code-example path="toh-pt6/src/app/hero-search/hero-search.component.ts" region="heroes-stream" ng-version="5.2.0" style="clear: both; display: block; background-color: rgba(242, 242, 242, 0.2); border: 0.5px solid rgb(219, 219, 219); border-radius: 5px; color: rgb(51, 51, 51); margin: 16px auto; font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"><aio-code class="simple-code"><pre class="prettyprint lang-" style="display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; -webkit-box-align: center; align-items: center; position: relative;">content_copy heroes$: Observable<Hero[]>;
</pre></aio-code></code-example>
你将会在 ngOnInit()
中设置它,在此之前,先仔细看看 searchTerms
的定义。
RxJS Subject
类型的 searchTerms
searchTerms
属性声明成了 RxJS 的 Subject
类型。
<code-example path="toh-pt6/src/app/hero-search/hero-search.component.ts" region="searchTerms" ng-version="5.2.0" style="clear: both; display: block; background-color: rgba(242, 242, 242, 0.2); border: 0.5px solid rgb(219, 219, 219); border-radius: 5px; color: rgb(51, 51, 51); margin: 16px auto; font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"><aio-code class="simple-code"><pre class="prettyprint lang-" style="display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; -webkit-box-align: center; align-items: center; position: relative;">content_copy private searchTerms = new Subject<string>(); // Push [a](https://angular.cn/api/router/RouterLinkWithHref) search term into the observable stream. search(term: string): void { this.searchTerms.next(term); }
</pre></aio-code></code-example>
Subject
既是可观察对象的数据源,本身也是 Observable
。 你可以像订阅任何 Observable
一样订阅 Subject
。
你还可以通过调用它的 next(value)
方法往 Observable
中推送一些值,就像 search()
方法中一样。
search()
是通过对文本框的 keystroke
事件的事件绑定来调用的。
<code-example path="toh-pt6/src/app/hero-search/hero-search.component.html" region="input" ng-version="5.2.0" style="clear: both; display: block; background-color: rgba(242, 242, 242, 0.2); border: 0.5px solid rgb(219, 219, 219); border-radius: 5px; color: rgb(51, 51, 51); margin: 16px auto; font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"><aio-code class="simple-code"><pre class="prettyprint lang-" style="display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; -webkit-box-align: center; align-items: center; position: relative;">content_copy <input #searchBox id="search-box" (keyup)="search(searchBox.value)" />
</pre></aio-code></code-example>
每当用户在文本框中输入时,这个事件绑定就会使用文本框的值(搜索词)调用 search()
函数。searchTerms
变成了一个能发出搜索词的稳定的流。
串联 RxJS 操作符
如果每当用户击键后就直接调用 searchHeroes()
将导致创建海量的 HTTP 请求,浪费服务器资源并消耗大量网络流量。
应该怎么做呢?ngOnInit()
往 searchTerms
这个可观察对象的处理管道中加入了一系列 RxJS 操作符,用以缩减对 searchHeroes()
的调用次数,并最终返回一个可及时给出英雄搜索结果的可观察对象(每次都是 Hero[]
)。
代码如下:
<code-example path="toh-pt6/src/app/hero-search/hero-search.component.ts" region="search" ng-version="5.2.0" style="clear: both; display: block; background-color: rgba(242, 242, 242, 0.2); border: 0.5px solid rgb(219, 219, 219); border-radius: 5px; color: rgb(51, 51, 51); margin: 16px auto; font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"><aio-code class="simple-code"><pre class="prettyprint lang-" style="display: flex; min-height: 32px; margin: 16px 24px; white-space: pre-wrap; -webkit-box-align: center; align-items: center; position: relative;">content_copy 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)), );
</pre></aio-code></code-example>
-
在传出最终字符串之前,
debounceTime(300)
将会等待,直到新增字符串的事件暂停了 300 毫秒。 你实际发起请求的间隔永远不会小于 300ms。 -
distinctUntilChanged
会确保只在过滤条件变化时才发送请求。 -
switchMap()
会为每个从debounce
和distinctUntilChanged
中通过的搜索词调用搜索服务。 它会取消并丢弃以前的搜索可观察对象,只保留最近的。
借助 switchMap 操作符, 每个有效的击键事件都会触发一次 [HttpClient](https://angular.cn/api/common/http/HttpClient).get()
方法调用。 即使在每个请求之间都有至少 300ms 的间隔,仍然可能会同时存在多个尚未返回的 HTTP 请求。
switchMap()
会记住原始的请求顺序,只会返回最近一次 HTTP 方法调用的结果。 以前的那些请求都会被取消和舍弃。
注意,取消前一个 searchHeroes()
可观察对象并不会中止尚未完成的 HTTP 请求。 那些不想要的结果只会在它们抵达应用代码之前被舍弃。
记住,组件类中并没有订阅 heroes$
这个可观察对象,而是由模板中的 AsyncPipe
完成的。
试试看
再次运行本应用。在这个 仪表盘 中,在搜索框中输入一些文字。如果你输入的字符匹配上了任何现有英雄的名字,你将会看到如下效果:
<figure style="background: rgb(255, 255, 255); padding: 20px; display: inline-block; box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 5px 0px; margin: 0px 0px 14px; border-radius: 4px; color: rgba(0, 0, 0, 0.87); font-family: Roboto, "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;"> Hero Search Component</figure>
网友评论