哈囉,各位邦友們!
從 Day19 的 Reactive Forms 起步,走過了 Signals 與 RxJS 的整合,再到 Standalone 架構與 @defer
。
今天就來總結回顧 Day19~Day25 的內容,同時透過實作來再次加深印象。
Day19 將 Heroes 頁面的新增與編輯改寫為 Reactive Forms,透過 FormBuilder.nonNullable()
建立 HeroFormGroup
並讓 signals 與表單狀態同
步,驗證、重置與畫面顯示一次搞定。
Day20 建立共用錯誤訊息表、同步驗證(長度、pattern)與自訂保留字檢查,還加入非同步驗證避免重複名稱。
另外也將 pending
、error
狀態回饋給使用者。
Day21 拆解 signal
、computed
、effect
的分工,computed
提供派生資料,effect
則負責把 state 與表單、路由同步,透過實作徹底理解響應式運作流程。
Day22 把 HeroDetail 與 Heroes 搜尋的 Observable pipeline 收斂成 toSignal
,統一管理 loading/error/value,減少手動訂閱與退訂,Template 渲染也更宣告式。
Day23 介紹 resource()
與 rxResource()
,用宣告式 API 接手非同步資料、不必自己煩惱取消請求。
Day24 回顧 Angular 架構演進,了解 bootstrapApplication()
、ApplicationConfig
、importProvidersFrom()
等核心設計。
@defer
延遲載入策略Day25 聚焦 @defer
的觸發條件與區塊(@placeholder
、@loading
、@error
、prefetch
),示範如何延遲 Dashboard 次要內容提升 LCP,建立「需要時才載」的效能思維。
FormBuilder.nonNullable()
建立 FormGroup
與 FormControl
,避免 null
對型別的污染。debounce
與 finalize
控制 pending
、error
,避免畫面卡住。signal
保存清單,再用 computed
建立 Map
快取;所有元件都讀同一份狀態。effect
用來同步路由參數、表單初值與搜尋字串,行為集中且容易測試。toSignal
讓既有 Observable pipeline 直接產生 signal,避免手動 .subscribe()
與退訂。resource()
/rxResource()
搭配 AbortSignal
,自帶 isLoading()
、error()
、reload()
等輔助方法。bootstrapApplication()
啟動,ApplicationConfig
集中提供路由、HTTP、SSR、Zoneless。importProvidersFrom()
匯入第三方模組 (HttpClientInMemoryWebApiModule
),維持提供者的純度。@defer
針對非關鍵內容選擇 on viewport
、on interaction
、on idle
等觸發條件,延遲下載重資源。@placeholder
、@loading
、@error
、prefetch
參數控制顯示節奏,保持版面穩定與體驗一致。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();
}
@defer (on interaction)
搭配 prefetch on idle
。新增戰績分析資料計算
建立 battleAnalysis
computed
,依英雄的 rank
、skills
與 id
推導出戰績摘要,供延遲載入區塊使用。
// 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 查詢參數與搜尋/篩選狀態會保持同步:重新整理、分享連結或手動修改參數,都能還原對應的列表視圖,而且不會造成無限循環或冗餘導航。
驗收清單:
常見錯誤與排查:
FormArray
驗證未觸發:記得在增刪技能時呼叫 markAsTouched()
與 updateValueAndValidity()
。skillsArrayValidator()
內以小寫比較是否正確。create()
/update()
payload 需確保 skills
不為空陣列才送出。reloadSearch()
強制重新查詢。今日小結:
今天一開始擴充了 Heroes 表單,接著將搜尋結果改寫成 rxResource()
狀態,使其支援 reload()
與快取上一次成功資料後,在 HeroDetail 新增了「戰績分析」區塊,最後透過effect
同步 URL 查詢參數與列表篩選,確保重新整理後狀態保持一致,透過這些實作,又再一次複習了Day19~Day25的內容。
參考資料:
resource()
/rxResource()
:@defer
: