iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Modern Web

Angular:踏上現代英雄之旅系列 第 26

Day 26|主題總結:重點速覽

  • 分享至 

  • xImage
  •  

哈囉,各位邦友們!
從 Day19 的 Reactive Forms 起步,走過了 Signals 與 RxJS 的整合,再到 Standalone 架構與 @defer
今天就來總結回顧 Day19~Day25 的內容,同時透過實作來再次加深印象。

一、回顧:

Day 19|Reactive Forms 重構

Day19 將 Heroes 頁面的新增與編輯改寫為 Reactive Forms,透過 FormBuilder.nonNullable() 建立 HeroFormGroup 並讓 signals 與表單狀態同
步,驗證、重置與畫面顯示一次搞定。

Day 20|客製化 Validators

Day20 建立共用錯誤訊息表、同步驗證(長度、pattern)與自訂保留字檢查,還加入非同步驗證避免重複名稱。
另外也將 pendingerror 狀態回饋給使用者。

Day 21|Signals 三件套

Day21 拆解 signalcomputedeffect 的分工,computed 提供派生資料,effect 則負責把 state 與表單、路由同步,透過實作徹底理解響應式運作流程。

Day 22|toSignal 整合 RxJS

Day22 把 HeroDetail 與 Heroes 搜尋的 Observable pipeline 收斂成 toSignal,統一管理 loading/error/value,減少手動訂閱與退訂,Template 渲染也更宣告式。

Day 23|resource() 與 rxResource()

Day23 介紹 resource()rxResource(),用宣告式 API 接手非同步資料、不必自己煩惱取消請求。

Day 24|從 NgModule 到 Standalone

Day24 回顧 Angular 架構演進,了解 bootstrapApplication()ApplicationConfigimportProvidersFrom() 等核心設計。

Day 25|@defer 延遲載入策略

Day25 聚焦 @defer 的觸發條件與區塊(@placeholder@loading@errorprefetch),示範如何延遲 Dashboard 次要內容提升 LCP,建立「需要時才載」的效能思維。

二、核心觀念整理

Reactive Forms

  • FormBuilder.nonNullable() 建立 FormGroupFormControl,避免 null 對型別的污染。
  • 非同步驗證搭配 debouncefinalize 控制 pendingerror,避免畫面卡住。

Signals × RxJS

  • 服務層以 signal 保存清單,再用 computed 建立 Map 快取;所有元件都讀同一份狀態。
  • effect 用來同步路由參數、表單初值與搜尋字串,行為集中且容易測試。
  • toSignal 讓既有 Observable pipeline 直接產生 signal,避免手動 .subscribe() 與退訂。
  • resource()rxResource() 搭配 AbortSignal,自帶 isLoading()error()reload() 等輔助方法。

Standalone與@defer

  • Standalone 透過 bootstrapApplication() 啟動,ApplicationConfig 集中提供路由、HTTP、SSR、Zoneless。
  • 使用 importProvidersFrom() 匯入第三方模組 (HttpClientInMemoryWebApiModule),維持提供者的純度。
  • @defer 針對非關鍵內容選擇 on viewporton interactionon idle 等觸發條件,延遲下載重資源。
  • @placeholder@loading@errorprefetch 參數控制顯示節奏,保持版面穩定與體驗一致。

三、擴充 Heroes 表單,新增 FormArray 管理多個技能並套用自訂驗證。

擴充資料結構與 API Payload
在 hero 模型與 in-memory API 新增 skills: string[] 欄位。

// src/app/hero.service.ts
export type Hero = { id: number; name: string; rank?: string; skills?: string[] };

create(hero: Pick<Hero, 'name' | 'rank' | 'skills'>) { /* ... */ }
update(id: number, changes: Partial<Hero>) { /* ... */ }

// src/app/in-memory-data.ts
const DEFAULT_HEROES: Hero[] = [
  { id: 11, name: 'Dr Nice', rank: 'B', skills: ['Healing', 'Support'] },
  // ...existing heroes...
];

在 HeroesComponent 使用 FormArray 管理技能
FormArray 管理多筆技能輸入,並建立避免空白與重複的驗證。

// src/app/heroes/heroes.component.ts
type HeroFormGroup = FormGroup<{
  name: FormControl<string>;
  rank: FormControl<HeroRank>;
  skills: FormArray<FormControl<string>>;
}>;

private buildSkillsForm(initial: readonly string[] = ['']) {
  return this.fb.nonNullable.array(
    (initial.length ? initial : ['']).map((skill) => this.createSkillControl(skill)),
    { validators: [skillsArrayValidator()] }
  );
}

private collectSkills(target: FormArray<FormControl<string>>) {
  return target.controls
    .map((control) => control.value.trim())
    .filter((skill) => skill.length > 0);
}

更新建立/編輯表單範本
在建立與編輯表單介面加入增刪技能欄位與對應樣式。

<!-- src/app/heroes/heroes.component.html -->
<fieldset formArrayName="skills" class="skills">
  <legend>Skills:</legend>
  @for (control of createSkills.controls; track control; let i = $index) {
    <div class="skills__item">
      <input type="text" [formControlName]="i" placeholder="輸入技能" />
      <button type="button" class="muted skills__remove" (click)="removeSkill(createSkills, i)">
        移除
      </button>
      @if (controlError(control); as err) { <small class="error">{{ err }}</small> }
    </div>
  }
  <button type="button" class="muted skills__add" (click)="addSkill(createSkills)">+ 新增技能</button>
  @if (controlError(createSkills); as err) { <small class="error">{{ err }}</small> }
</fieldset>

補齊樣式

// src/app/heroes/heroes.component.scss
.skills {
  display: grid;
  gap: 8px;
  width: 100%;
  padding: 12px;
  border: 1px dashed #d7deef;
  border-radius: 8px;
  background: #ffffff;
}

.skills__item {
  display: flex;
  align-items: center;
  gap: 8px;
}

四、將搜尋結果改寫成 rxResource() 狀態,支援 reload() 與快取上一次成功資料。

建立 rxResource 管理搜尋流程
rxResource 接手搜尋結果,並透過 signal 快取上一筆成功資料,在 loading/error 期間維持畫面可用。

// src/app/heroes/heroes.component.ts
// ...existing code...

private readonly searchTerms = new Subject<string>();
private readonly searchTerm = signal<string>('');
private readonly lastSuccessfulSearch = signal<{ term: string; heroes: Hero[] } | null>(null);

protected readonly searchResource = rxResource<Hero[], string>({
  params: () => this.searchTerm(),
  stream: ({ params }) => (params ? this.heroService.search$(params) : of<Hero[]>([])),
  defaultValue: [],
});

protected readonly searchState = computed<SearchState>(() => {
  const term = this.searchTerm();
  if (!term) {
    return { status: 'idle', term: '', heroes: [], message: null, error: null };
  }

  if (this.searchResource.isLoading()) {
    const cached = this.lastSuccessfulSearch();
    return {
      status: 'loading',
      term,
      heroes: cached && cached.term === term ? cached.heroes : [],
      message: '搜尋中...',
      error: null,
    };
  }

  const resourceError = this.searchResource.error();
  if (resourceError) {
    const cached = this.lastSuccessfulSearch();
    return {
      status: 'error',
      term,
      heroes: cached && cached.term === term ? cached.heroes : [],
      message: '查詢失敗,可稍後重試。',
      error:
        resourceError instanceof Error
          ? resourceError.message
          : String(resourceError ?? 'Unknown error'),
    };
  }

  const heroes = this.searchResource.value();
  return {
    status: 'success',
    term,
    heroes,
    message: heroes.length
      ? `命中 ${heroes.length} 位英雄`
      : '沒有符合條件的英雄,試著換個關鍵字。',
    error: null,
  };
});

constructor() {
  // ...existing code...

  this.searchTerms
    .pipe(
      map((term) => term.trim()),
      debounceTime(300),
      distinctUntilChanged(),
      tap(() => this.feedback.set(null)),
      takeUntilDestroyed(this.destroyRef)
    )
    .subscribe((term) => {
      if (term && term.length < 2) {
        this.searchTerm.set('');
        return;
      }
      this.searchTerm.set(term);
    });

  effect(() => {
    const term = this.searchTerm();
    if (!term || this.searchResource.isLoading() || this.searchResource.error()) {
      return;
    }
    this.lastSuccessfulSearch.set({ term, heroes: [...this.searchResource.value()] });
  });

  // ...existing code...
}

覆寫搜尋方法,支援相同關鍵字的 reload()
重複輸入相同關鍵字時改用 rxResource.reload(),也保留顯式重新整理的入口。

// src/app/heroes/heroes.component.ts
// ...existing code...

protected search(term: string) {
  const normalized = (term ?? '').trim();
  if (normalized && normalized.length >= 2 && normalized === this.searchTerm()) {
    this.searchResource.reload();
  }
  this.searchTerms.next(term);
}

protected reloadSearch() {
  if (!this.searchTerm()) {
    return;
  }
  this.searchResource.reload();
}

五、在 HeroDetail 新增「戰績分析」區塊,使用 @defer (on interaction) 搭配 prefetch on idle

新增戰績分析資料計算
建立 battleAnalysis computed,依英雄的 rankskillsid 推導出戰績摘要,供延遲載入區塊使用。

// src/app/hero-detail/hero-detail.ts
// ...existing code...

export class HeroDetail {
  // ...existing code...

  readonly battleAnalysis = computed(() => {
    const detail = this.hero();
    if (!detail) {
      return null;
    }

    const skillsCount = detail.skills?.length ?? 0;
    const rankBoost = (() => {
      switch (detail.rank) {
        case 'S':
          return { win: 18, mvp: 22 };
        case 'A':
          return { win: 12, mvp: 14 };
        case 'B':
          return { win: 6, mvp: 8 };
        case 'C':
          return { win: 2, mvp: 4 };
        default:
          return { win: 0, mvp: 0 };
      }
    })();

    const missions = 32 + (detail.id % 7) * 5 + skillsCount * 3;
    const winRate = Math.min(98, 68 + rankBoost.win + skillsCount * 2);
    const mvpRate = Math.min(72, 18 + rankBoost.mvp + skillsCount * 4);
    const avgDuration = Math.max(9, 28 - rankBoost.win / 2 - skillsCount * 1.5);

    return {
      missions,
      winRate,
      mvpRate,
      avgDuration: Number(avgDuration.toFixed(1)),
      synergyScore: Math.min(100, Math.round(winRate * 0.6 + mvpRate * 0.4)),
    };
  });

  // ...existing code...
}

在模板加上延遲載入區塊
新增「戰績分析」段落,採 @defer (on interaction(trigger); prefetch on idle),互動時載入,閒置時預抓資料;占位內容使用按鈕觸發。

<!-- src/app/hero-detail/hero-detail.html -->
<!-- ...existing code... -->
  <section class="analysis" aria-live="polite">
    <header class="analysis__header">
      <h3>戰績分析</h3>
      <p class="muted">互動後載入精簡戰報。</p>
    </header>
    @defer (on interaction(trigger); prefetch on idle) {
      @if (battleAnalysis(); as stats) {
        <dl class="analysis__stats">
          <div class="analysis__stat">
            <dt>年度任務</dt>
            <dd>{{ stats.missions }} 場</dd>
          </div>
          <div class="analysis__stat">
            <dt>勝率</dt>
            <dd>{{ stats.winRate }}%</dd>
          </div>
          <div class="analysis__stat">
            <dt>MVP 率</dt>
            <dd>{{ stats.mvpRate }}%</dd>
          </div>
          <div class="analysis__stat">
            <dt>平均戰鬥時長</dt>
            <dd>{{ stats.avgDuration }} 分鐘</dd>
          </div>
          <div class="analysis__stat analysis__stat--highlight">
            <dt>協同作戰分數</dt>
            <dd>{{ stats.synergyScore }}</dd>
          </div>
        </dl>
        <p class="analysis__remark">
          {{ detail.name }} 擁有 {{ detail.skills?.length ?? 0 }} 項技能
          @if (detail.skills?.length) {
            ({{ detail.skills?.join('、') }})
          }
          ,綜合表現穩定。
        </p>
      } @else {
        <p class="muted">暫無戰績資料。</p>
      }
    } @placeholder {
      <button #trigger type="button" class="analysis__trigger">查看戰績分析</button>
    }
  </section>
<!-- ...existing code... -->

補上樣式支援戰績卡片
設計背景梯度、統計格線與互動按鈕樣式,與 HeroDetail 既有風格一致。

// src/app/hero-detail/hero-detail.scss
.analysis {
  margin-top: 24px;
  padding: 16px 20px;
  border-radius: 12px;
  background: linear-gradient(135deg, #f4f7ff 0%, #eef2ff 100%);
  box-shadow: 0 6px 18px rgba(32, 56, 117, 0.1);
}

.analysis__header {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 12px;
  margin-bottom: 16px;
}

.analysis__trigger {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 12px;
  border-radius: 999px;
  border: 1px solid #9aa6d8;
  background: #fff;
  color: #394165;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s ease, color 0.2s ease;
}

.analysis__stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 12px;
  margin: 0;
  padding: 0;
}

.analysis__stat {
  padding: 12px;
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.8);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}

.analysis__stat--highlight {
  background: rgba(76, 110, 245, 0.12);
  border: 1px solid rgba(76, 110, 245, 0.3);
}

.analysis__remark {
  margin-top: 18px;
  font-size: 0.95rem;
  color: #3a425f;
}

六、以 effect 同步 URL 查詢參數與列表篩選,確保重新整理後狀態保持一致

建立路由注入與查詢狀態快取

// src/app/heroes/heroes.component.ts
// ...existing code...
import { ActivatedRoute, ParamMap, Router, RouterModule } from '@angular/router';

const VALID_RANK_QUERY_VALUES = new Set(['ALL', 'S', 'A', 'B', 'C']);
type FilterQueryState = { rank: string | null; search: string | null };

export class HeroesComponent {
  private readonly router = inject(Router);
  private readonly route = inject(ActivatedRoute);
  private lastSyncedQuery: FilterQueryState = { rank: null, search: null };
  private syncingFromQuery = false;
  // ...existing code...
}

constructor 讀取/推送查詢參數

// src/app/heroes/heroes.component.ts
constructor() {
  // ...existing code...

  const initialParams = this.route.snapshot.queryParamMap;
  this.applyQueryParams(initialParams);

  this.route.queryParamMap
    .pipe(takeUntilDestroyed(this.destroyRef))
    .subscribe((params) => this.applyQueryParams(params));

  effect(() => {
    if (this.syncingFromQuery) {
      return;
    }
    const nextState = this.currentQueryState();
    if (this.queryStatesEqual(this.lastSyncedQuery, nextState)) {
      return;
    }
    this.lastSyncedQuery = nextState;
    this.router.navigate([], {
      relativeTo: this.route,
      queryParams: {
        rank: nextState.rank,
        search: nextState.search,
      },
      replaceUrl: true,
    });
  });

  // ...existing code...
}

整理共用方法,避免雙向同步時重複觸發

// src/app/heroes/heroes.component.ts
private applyQueryParams(params: ParamMap) {
  this.syncingFromQuery = true;
  try {
    const rankValue = this.normalizeRankFromQuery(params.get('rank'));
    const searchValue = this.normalizeSearchFromQuery(params.get('search'));

    if (this.activeRank() !== rankValue) {
      this.activeRank.set(rankValue);
    }
    if (this.searchTerm() !== searchValue) {
      this.searchTerm.set(searchValue);
    }

    this.lastSyncedQuery = {
      rank: this.formatRankForQuery(rankValue),
      search: this.formatSearchForQuery(searchValue),
    };
  } finally {
    this.syncingFromQuery = false;
  }
}

private currentQueryState(): FilterQueryState {
  return {
    rank: this.formatRankForQuery(this.activeRank()),
    search: this.formatSearchForQuery(this.searchTerm()),
  };
}

private normalizeRankFromQuery(raw: string | null): string {
  const normalized = (raw ?? '').trim().toUpperCase();
  if (!normalized) {
    return 'ALL';
  }
  return VALID_RANK_QUERY_VALUES.has(normalized) ? normalized : 'ALL';
}

private normalizeSearchFromQuery(raw: string | null): string {
  const normalized = (raw ?? '').trim();
  return normalized.length >= 2 ? normalized : '';
}

private formatRankForQuery(rank: string): string | null {
  return rank === 'ALL' ? null : rank;
}

private formatSearchForQuery(term: string): string | null {
  return term.length >= 2 ? term : null;
}

private queryStatesEqual(a: FilterQueryState, b: FilterQueryState): boolean {
  return a.rank === b.rank && a.search === b.search;
}

透過這組 effect 與 helper,URL 查詢參數與搜尋/篩選狀態會保持同步:重新整理、分享連結或手動修改參數,都能還原對應的列表視圖,而且不會造成無限循環或冗餘導航。

驗收清單:

  • 建立英雄時可新增多筆技能,任何欄位留空會提示「至少輸入一項技能」。
    https://ithelp.ithome.com.tw/upload/images/20251011/20159238hyHwlB8SzS.png
  • 編輯已存在英雄時,技能清單會同步帶入並可新增或移除。
    https://ithelp.ithome.com.tw/upload/images/20251011/20159238G7Qn9QzlGJ.png
  • HeroDetail 出現「查看戰績分析」按鈕,點擊後才渲染戰報。
    https://ithelp.ithome.com.tw/upload/images/20251011/20159238jIIBumDU9S.png
    https://ithelp.ithome.com.tw/upload/images/20251011/20159238z14RRdJiDm.png

常見錯誤與排查:

  • FormArray 驗證未觸發:記得在增刪技能時呼叫 markAsTouched()updateValueAndValidity()
  • 技能仍出現重複:確認 skillsArrayValidator() 內以小寫比較是否正確。
  • API 回傳缺少技能:create()update() payload 需確保 skills 不為空陣列才送出。
  • 搜尋無法觸發:輸入至少 2 個字,或使用 reloadSearch() 強制重新查詢。

今日小結:
今天一開始擴充了 Heroes 表單,接著將搜尋結果改寫成 rxResource() 狀態,使其支援 reload() 與快取上一次成功資料後,在 HeroDetail 新增了「戰績分析」區塊,最後透過effect 同步 URL 查詢參數與列表篩選,確保重新整理後狀態保持一致,透過這些實作,又再一次複習了Day19~Day25的內容。

參考資料:


上一篇
Day 25|延遲載入:@defer
下一篇
Day 27|部署上線:ng build 與 GitHub Pages
系列文
Angular:踏上現代英雄之旅28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言