哈囉,各位邦友們!
昨天我們在 Heroes 頁加上 Loading 與 Empty 狀態,讓使用者能明確知道目前資料的狀態。
今天則是要來實作即時搜尋,會透過 RxJS 的 Subject
與 debounceTime
、distinctUntilChanged
、switchMap
等操作符,將輸入內容轉成 HTTP 的查詢流程。
HeroService
新增 search$()
,呼叫 in-memory API 並同步快取。HeroesComponent
建立 Subject
搜尋流,運用 RxJS Operators 去做節流。LoadingSpinner
共用元件。HeroService
具備 getAll$()
、getById$()
等方法。一、HeroService:新增 search$()
串接查詢 API
把搜尋邏輯放進服務,並沿用快取機制,讓常用英雄在其他頁面也能即時取得。
// src/app/hero.service.ts
import { HttpClient, HttpParams } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { delay, of, tap } from 'rxjs';
export type Hero = { id: number; name: string; rank?: string };
@Injectable({
providedIn: 'root',
})
export class HeroService {
private readonly http = inject(HttpClient);
private readonly baseUrl = 'api/heroes';
private readonly cache = new Map<number, Hero>();
getAll$() {
return this.http.get<Hero[]>(this.baseUrl).pipe(
delay(2000),
tap((heroes) => {
this.cache.clear();
for (const hero of heroes) {
this.cache.set(hero.id, hero);
}
})
);
}
// ...getById$ / create$ / update$ / delete$ 與原本相同...
search$(term: string) {
const keyword = term.trim();
if (!keyword) {
return of<Hero[]>([]);
}
const params = new HttpParams().set('name', keyword);
return this.http.get<Hero[]>(this.baseUrl, { params }).pipe(
tap((heroes) => {
for (const hero of heroes) {
this.cache.set(hero.id, hero);
}
})
);
}
}
重點:
HttpParams
組出 ?name=keyword
查詢,在 in-memory web API 會以 name
欄位進行前綴比對。of([])
,避免多餘請求,並且能讓前端快速清空結果。tap()
同步快取,之後若從 detail 頁訪問同一位英雄便能直接讀取快取,省下一次 API 查詢。二、HeroesComponent:建立 Subject
與 RxJS 資料流
我們將鍵盤輸入交給 Subject
,再透過 Operators 進行 trim。
// src/app/heroes/heroes.component.ts
import { Component, DestroyRef, effect, inject, signal } from '@angular/core';
import { HeroService, Hero } from '../hero.service';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterModule } from '@angular/router';
import { HeroBadge } from '../hero-badge/hero-badge';
import {
EMPTY,
Subject,
catchError,
debounceTime,
distinctUntilChanged,
finalize,
map,
of,
startWith,
switchMap,
tap,
} from 'rxjs';
import { LoadingSpinner } from '../ui/loading-spinner/loading-spinner';
@Component({
selector: 'app-heroes',
imports: [HeroBadge, FormsModule, RouterModule, LoadingSpinner],
templateUrl: './heroes.component.html',
styleUrl: './heroes.component.scss',
})
export class HeroesComponent {
// ...既有狀態 (list / selection / CRUD signals)...
protected readonly heroes = signal<Hero[]>([]);
protected readonly selectedHero = signal<Hero | null>(null);
protected readonly heroesLoading = signal(true);
protected readonly heroesError = signal<string | null>(null);
protected readonly editName = signal('');
protected readonly saving = signal(false);
protected readonly saveError = signal<string | null>(null);
protected readonly newHeroName = signal('');
protected readonly creating = signal(false);
protected readonly createError = signal<string | null>(null);
protected readonly deletingId = signal<number | null>(null);
protected readonly deleteError = signal<string | null>(null);
protected readonly feedback = signal<string | null>(null);
private readonly heroService = inject(HeroService);
private readonly destroyRef = inject(DestroyRef);
private readonly searchTerms = new Subject<string>();
protected readonly searchKeyword = signal('');
protected readonly searchResults = signal<Hero[]>([]);
protected readonly searching = signal(false);
protected readonly searchError = signal<string | null>(null);
constructor() {
// 既有:載入英雄清單並寫入狀態
this.heroService
.getAll$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (list) => {
this.heroes.set(list);
this.heroesLoading.set(false);
},
error: (err) => {
this.heroesError.set(String(err ?? 'Unknown error'));
this.heroesLoading.set(false);
},
});
effect(() => {
const current = this.selectedHero();
this.editName.set(current?.name ?? '');
this.saveError.set(null);
});
this.searchTerms
.pipe(
map((term) => term.trim()),
startWith(''),
debounceTime(300),
distinctUntilChanged(),
tap((term) => {
this.searchKeyword.set(term);
this.searchError.set(null);
this.searchResults.set([]);
}),
switchMap((term) => {
if (!term) {
this.searching.set(false);
return of<Hero[]>([]);
}
this.searching.set(true);
return this.heroService.search$(term).pipe(
catchError((err) => {
this.searchError.set(String(err ?? 'Unknown error'));
return of<Hero[]>([]);
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((heroes) => {
this.searchResults.set(heroes);
this.searching.set(false);
});
}
// ...既有 CRUD 方法 (onSelect / saveSelected / addHero / removeHero)...
search(term: string) {
this.searchTerms.next(term);
}
}
說明:
startWith('')
讓流程自動初始化,畫面開啟時搜尋結果為空陣列。debounceTime(300)
避免每個鍵盤事件都打 API,distinctUntilChanged()
能忽略連續相同輸入。switchMap()
可自動取消前一次尚未完成的 HTTP 呼叫,確保最新輸入永遠優先。catchError
將錯誤轉成空結果,並寫入 searchError
讓畫面顯示錯誤訊息。三、範本:顯示搜尋輸入、結果與狀態
在列表上方加入搜尋區,包含輸入框、載入指示、錯誤提示與搜尋結果清單。
<!-- src/app/heroes/heroes.component.html -->
<section class="search" role="search">
<label for="hero-search">Search heroes</label>
<input
id="hero-search"
type="search"
placeholder="輸入關鍵字 (至少 1 個字)"
autocomplete="off"
(input)="search($any($event.target).value)" />
@if (searching()) {
<app-loading-spinner label="Searching..."></app-loading-spinner>
} @else if (searchKeyword()) {
<p class="muted">搜尋「{{ searchKeyword() }}」</p>
}
@if (searchError(); as err) {
<p class="error">Search failed: {{ err }}</p>
}
@if (searchKeyword() && !searching()) {
<ul class="results">
@for (hero of searchResults(); track hero.id) {
<li>
<button type="button" (click)="onSelect(hero)">
{{ hero.name }}
@if (hero.rank) { <span class="rank">[{{ hero.rank }}]</span> }
</button>
<a [routerLink]="['/detail', hero.id]" (click)="$event.stopPropagation()">View</a>
</li>
} @empty {
<li class="muted">找不到符合「{{ searchKeyword() }}」的英雄。</li>
}
</ul>
}
</section>
<!-- 既有的 @if (heroesLoading()) ... 保持不變 -->
重點:
@if (searchKeyword())
控制結果區塊,空字串時自動收起清單。onSelect()
邏輯,同步更新選取區與 routerLink
詳細頁。四、新增樣式:搜尋區域與搜尋結果
/* src/app/heroes/heroes.component.scss */
.search {
margin-bottom: 24px;
padding: 16px;
border: 1px solid #e2e6f0;
border-radius: 8px;
background: #ffffff;
display: grid;
gap: 12px;
}
.search label {
font-weight: 600;
color: #344054;
}
.search input {
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #ccd5e4;
font-size: 1rem;
}
.search .results {
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
}
.search .results li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #eef1f9;
border-radius: 8px;
background: #fafbff;
}
.search .results li button {
flex: 1;
text-align: left;
border: none;
background: transparent;
color: #1b365d;
font-weight: 600;
cursor: pointer;
}
.search .results li button:hover {
text-decoration: underline;
}
.search .results li a {
font-size: 0.9rem;
color: #4a6078;
}
.search .results li .rank {
margin-left: 4px;
font-size: 0.85rem;
}
驗收清單:
Searching...
Spinner。nar
)會在 300ms 後列出結果,點擊可立即在右側選取區看到對應英雄資訊。常見錯誤與排查:
HttpParams
加入 get
呼叫,或是 in-memory heroes
集合是否存在對應名稱。switchMap
的空字串分支回傳 of([])
並把 searching
設為 false
。今日小結:
我們把鍵盤輸入轉成事件流,並透過 RxJS Operators,實作出即時搜尋功能。
這些技巧同樣適用於表單的其他情境,是前端掌握畫面互動與資料處理的一環。
參考資料:
debounceTime
:distinctUntilChanged
:switchMap
: