iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
Modern Web

Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站系列 第 19

Day 19 Angular RxJS 進階 – Loading 狀態與 Debounce 搜尋

  • 分享至 

  • xImage
  •  

今日目標

  • 建立 全域 Loading 狀態管理(讓任何 API 呼叫都能顯示「載入中」動畫)
  • 認識並使用 RxJS 常見運算子
    • debounceTime(延遲輸入,避免連續觸發)
    • distinctUntilChanged(忽略相同輸入)
    • switchMap(取消舊請求,只保留最新)
  • 改良 Skills 的搜尋功能:加上 debounce 搜尋,模擬真實 API 查詢體驗

基礎概念(白話版)

  1. Loading 狀態
    • 當我們呼叫 API 時,畫面需要顯示「載入中…」,避免使用者誤以為壞掉。
    • 最佳實踐:用 Service 集中管理,不要每個 component 自己做一份。
  2. RxJS 運算子
    • debounceTime(ms):輸入結束後等指定毫秒才送出 → 避免打字中每個字都查一次。
    • distinctUntilChanged():如果輸入跟上次一樣,就不觸發。
    • switchMap():新請求來時會自動取消舊請求,只保留最新的。 → 適合搜尋。

實作 A:全域 Loading 狀態

1) 建立 LoadingService

ng g s services/loading

loading.service.ts

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

@Injectable({ providedIn: 'root' })
export class LoadingService {
  private loadingSubject = new BehaviorSubject<boolean>(false);
  loading$ = this.loadingSubject.asObservable();

  show() { this.loadingSubject.next(true); }
  hide() { this.loadingSubject.next(false); }
}


2) 在 API 服務中套用

修改 projects-data.service.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Project } from '../models/project.model';
import { Observable, of } from 'rxjs';
import { catchError, delay, finalize } from 'rxjs/operators';
import { LoadingService } from './loading.service';

@Injectable({ providedIn: 'root' })
export class ProjectsDataService {
  private readonly apiUrl = 'assets/projects.json';

  constructor(private http: HttpClient, private loading: LoadingService) {}

  getAll$(): Observable<Project[]> {
    this.loading.show();
    return this.http.get<Project[]>(this.apiUrl).pipe(
      delay(500), // 模擬延遲
      catchError(err => {
        console.error('載入失敗', err);
        return of([]);
      }),
      finalize(() => this.loading.hide()) // 無論成功/失敗都關閉 loading
    );
  }
}


3) 在 AppComponent 顯示 Loading

app.component.html

<div *ngIf="loading$ | async" class="loading-overlay">
  <div class="spinner">載入中...</div>
</div>

<app-header></app-header>
<router-outlet></router-outlet>
<app-footer></app-footer>

app.component.ts

import { Component } from '@angular/core';
import { LoadingService } from './services/loading.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent {
  loading$ = this.loading.loading$;
  constructor(private loading: LoadingService) {}
}

styles.scss(簡單樣式)

.loading-overlay {
  position: fixed;
  top: 0; left: 0; right: 0; bottom: 0;
  background: rgba(255, 255, 255, 0.6);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}
.spinner {
  padding: 20px;
  background: white;
  border: 2px solid #2563eb;
  border-radius: 8px;
}


實作 B:Skills 搜尋 + debounce

1) 修改 SkillsComponent

我們把輸入框事件串進 RxJS pipeline,而不是即時更新。

skills.component.ts

import { Component, OnInit } from '@angular/core';
import { UiStateService } from '../../services/ui-state.service';
import { SkillsDataService } from '../../services/skills-data.service';
import { Skill } from '../../models/skill.model';
import { Subject, combineLatest } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, map, startWith } from 'rxjs/operators';

@Component({
  selector: 'app-skills',
  templateUrl: './skills.component.html'
})
export class SkillsComponent implements OnInit {
  skills: Skill[] = [];
  private keywordInput$ = new Subject<string>();

  filtered$ = combineLatest([
    this.ui.skillCategory$,
    this.keywordInput$.pipe(
      debounceTime(300),        // 等 0.3 秒再觸發
      distinctUntilChanged(),   // 相同輸入忽略
      startWith('')             // 預設空字串
    )
  ]).pipe(
    map(([cat, kw]) =>
      this.skills.filter(s => {
        const byCat = cat === 'all' || s.category === cat;
        const byKw = !kw || s.name.toLowerCase().includes(kw.toLowerCase());
        return byCat && byKw;
      })
    )
  );

  constructor(private ui: UiStateService, private svc: SkillsDataService) {}

  ngOnInit() {
    this.skills = this.svc.getAll();
  }

  setFilter(cat: 'all' | 'frontend' | 'backend' | 'tools') {
    this.ui.setSkillCategory(cat);
  }

  onKeywordChange(kw: string) {
    this.keywordInput$.next(kw);
  }
}


2) 修改 Skills 模板

skills.component.html

<section class="container section">
  <h2>技能 Skillset</h2>

  <div class="filters">
    <button (click)="setFilter('all')">全部</button>
    <button (click)="setFilter('frontend')">前端</button>
    <button (click)="setFilter('backend')">後端</button>
    <button (click)="setFilter('tools')">工具</button>
  </div>

  <input type="text"
         placeholder="搜尋技能…"
         (input)="onKeywordChange($event.target.value)" />

  <ul>
    <li *ngFor="let s of filtered$ | async">{{ s.name }}</li>
  </ul>
</section>


成果

  • Projects API 呼叫時,全站會自動顯示「Loading」疊層。
  • Skills 搜尋輸入框現在有 debounce,不會每敲一字就重刷。
  • RxJS switchMapdebounceTimedistinctUntilChanged 實戰應用,為未來更複雜的 API 串接鋪路。

小心踩雷

  1. Loading 狀態沒在 finalize 關掉
    • ❌ 只在成功時 hide()
    • ✅ 用 finalize() 保證成功/失敗都會觸發
  2. debounceTime 放太長或太短
    • 太短:沒省到效能
    • 太長:使用者感覺 lag
    • 建議 300–500ms
  3. Subject 忘了初始值
    • 搜尋一開始要顯示全部 → 用 startWith('') 補上
  4. 多個訂閱忘記 unsubscribe
    • 建議盡量用 async pipetakeUntil,避免 memory leak

下一步(Day 20 預告)

我們將進行 Angular 版履歷網站的最後整理 & 部署 🎉:

  • 整理程式碼結構(modules、shared、core)
  • 簡單優化(Lazy loading routes、切小元件、SEO meta tag)
  • 部署到 GitHub Pages 或 Vercel
  • 宣告 Angular 部分完成,下一階段進入 React 🚀

上一篇
Day 18 Angular 狀態管理 – 用 Service + BehaviorSubject 打造小型 Store
下一篇
Day 20 Angular 版履歷網站完成與部署
系列文
Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言