Angular Ivy - 多重渲染造成的錯誤排除紀錄(上)
背景說明
Vibe Coding 的時候碰到一個bug,共分成兩篇,這篇著重在查找過程:
原本只是單純的list功能在沒有規劃的情況下讓LLM去長,最後測試發現了問題
嘗試了好幾種方式才找到原因,過程還蠻值得分享的。
先說結論:
1. AI時代永遠要先做好規劃,設計變更就直接翻掉,如果覺得會痛就代表版控沒有切好。
小小地做、小小地切,不要再幻想有one shot prompt了!
2. Vibe Coding 表面上看起來很輕鬆,實際上還是得嚴肅點
AI的產出還是要檢查,不要幻想給一句「看起來怪怪的」就覺得能修好除非你token無限多
以下為正式內容:
這個元件是一個 Review Queue
每筆 ReviewTask 對應一篇 Post。
Template 會對牌卡及內容逐筆進行渲染,如前面所述,原本只有單純的文字內容和卡片
後面增加了色條、作者、內文摘要、關鍵字還有操作按鈕…..反正就是很貪心的想到什麼都叫他做
實際丟假資料檢查的時候發現清單只有顯示「第一張牌卡」
原始code如下
<ng-container *ngFor="let task of pendingTasks">
<div class="rq-card" [attr.data-testid]="'review-task-' + task.id" (click)="openTask(task.postId)">
<div style="height:3px"
[style.background]="riskColorsByLevel(task.riskLevel).fg"
[style.opacity]="riskColorsByLevel(task.riskLevel).tint">
</div>
<div style="padding:14px">
<av [user]="postMap.get(task.postId)?.author" size="sm"></av>
<div>{{ postMap.get(task.postId)?.author }}</div>
<div>{{ formatRelativeTime(task.createdAt) }}</div>
<div *ngIf="task.riskLevel === 'high'" class="risk-badge risk-badge--high">
{{ riskLabel(task.riskLevel) }}
</div>
<div *ngIf="task.riskLevel !== 'high'" class="risk-badge">
{{ riskLabel(task.riskLevel) }}
</div>
<div>"{{ summarizeContent(postMap.get(task.postId)?.content, 120) }}"</div>
<div>
{{ matchedKeywordsText(postMap.get(task.postId)?.matchedKeywords) }}
· {{ confidenceByLevel(task.riskLevel) | percent:'1.0-0' }}
</div>
<div style="display:flex;gap:8px" (click)="$event.stopPropagation()">
<button (click)="approve(task.postId)" [disabled]="!session.canReviewPosts()">
<m-icon name="check" [size]="16" [color]="riskColorsByLevel(task.riskLevel).fg"></m-icon>
Approve
</button>
<button aria-label="Flag"><m-icon name="flag" [size]="18"></m-icon></button>
<button (click)="reject(task.postId)" [disabled]="!session.canReviewPosts()">
<m-icon name="close" [size]="16" [color]="'#E0426B'"></m-icon>
Reject
</button>
</div>
</div>
</div>
</ng-container>
先確認資料是否正確
1. 後端資料正常
/review-tasks 回傳完整佇列
/posts 對應正確,每筆 task.postId 都能正確對應到post。
2. Component 狀態也正常
加入 debug hook 印出陣列長度與幾個欄位值,確認 binding 前資料完整。
3. 清單長度正確
陣列內容有值,但渲染從第二個 instance 起就開始遺失資料。
初步判斷問題出現在渲染的過程。
各種嘗試
三個方向都挺合理的,但都沒有解決問題:
Attempt 1
把清單的組裝邏輯移出 template
原本 template 裡直接呼叫 postMap.get(task.postId) 做 inline join,
把這個邏輯移進 buildVisibleTasks() / syncVisibleTasks(),讓 template 拿到的已是處理完畢的 ReviewCardItem[]
原始Code如下
以Author 這段為例:template 在渲染每張卡片時,直接在 HTML 裡查找資料
<div>{{ postMap.get(task.postId)?.author }}</div>
<div>"{{ summarizeContent(postMap.get(task.postId)?.content, 120) }}"</div>
postMap.get(task.postId):「給我這個 task 對應的 post」
Template 在渲染時才去查,查完才能顯示,讓資料的組合工作發生在 template 裡面。
buildVisibleTasks() 把這個查找工作提前做完,產出一個已經組合好的陣列:
// 在 component 的 TypeScript 裡先把資料準備好
private buildVisibleTasks(): ReviewCardItem[] {
const postMap = new Map(this.data.posts().map(post => [post.id, post]));
return this.pendingTasks.map((task) => {
const post = postMap.get(task.postId);
if (!post) return null;
return {
id: task.id,
author: post.author, // 已經查好了
content: post.content, // 已經查好了
riskLevel: post.riskLevel,
// ...
};
}).filter(item => item !== null);
}
Template 拿到的就是一個乾淨的陣列,可以直接拿來用:
<div>{{ item.author }}</div>
<div>"{{ summarizeContent(item.content, 120) }}"</div>
這段原本只是顯示單純的資料,所以在最快取得demo的原則下很合理。
只是隨需求增加,就應該要回歸功能切分 : 讓component 負責邏輯、Template 負責顯示
不過這仍然沒有解決問題
因為這個改動解決的是「資料在哪裡組合」的狀況
但 bug 的根源是「template 結構本身在重複渲染時不穩定」。資料不管在哪裡組合,到了渲染的時候 template 還是壞的。
換句話說,把食材準備得更漂亮,但烤箱壞掉,食材再完整也烤不出東西。
Attempt2
加上 trackBy
trackByTaskId() 讓 Angular 在 change detection 時以 id 識別每個 view instance,避免重複誤用。
trackBy 在做什麼?
*ngFor 每次收到新的陣列時,Angular 要決定哪些卡片需要重建。
預設做法是把整批 DOM 刪掉重新建立,不管陣列裡的資料有沒有真正變過,是相對比較沒有效率的做法。
加了 trackBy 之後,Angular 會用 id 比對新舊陣列:id 相同的卡片直接保留,只有新增、刪除、或 id 不同的卡片才重建。
trackByTaskId(_: number, task: ReviewCardItem): number {
return task.id; // 用 id 當辨識依據
}
加上後渲染結果不變,因為問題根源不在 identity reuse
trackBy 解決的是「哪些卡片要重建」的問題,執掌資料更新之後的行為。
但這個 bug 發生在更早的階段,
頁面首次載入、每張卡片第一次被建立的當下,建出來就已經是空的了
根本還沒到「要不要重建」的問題,卡片從來就沒有建對過。
trackBy 是 Angular 官方建議的標配寫法,幾乎所有 *ngFor 都應該加。
這邊沒有出現,最可能的原因是當初的 prompt 目標只是讓清單「顯示」出來
LLM 達成了這個目標,而trackBy 對「讓清單顯示出來」這件事沒有直接貢獻,所以沒有被觸發。
Attempt3
把迴圈變數從 task 改成 item (請參考上面的code)
排除與外層 scope 產生命名衝突的可能性,渲染結果不變。
改變數名稱是另一個排查方向,task 有可能和外層 scope 的某個變數撞名,導致 template 誤用。
改成 item 之後確認兩者沒有關聯,排除重複命名的可能性。
> 資料來源沒問題
> 調整架構/語法後正常
> 沒有重複命名的問題
只能出絕招了!!
(源自少林足球)
Template重構
原本的卡片 template 包含多層 binding:
風險色條、作者行、時間戳、內文摘要、關鍵字 badge、帶有 stopPropagation 的操作列、條件式 disabled binding........
單次渲染正常,Angular 為後續每一個 *ngFor iteration 重複建立卡片時,binding 就開始消失。
第一張卡片可以正常解析
從第二個起,大部分Angular 應該填入的內容 (作者名、摘要、時間戳)在到達 DOM 之前就已遺失
Angular渲染後續卡片時binding便失效了,可過程中沒有任何exception可以參考
Console 沒有輸出任何錯誤、DevTools 只顯示空 attribute,診斷方式只能從畫面上找 (只能通靈)
實作
1. 簡化重複卡片的 markup
2. 清單只保留可以識別卡片的最少資訊
3. 把 <av> *ngIf badge icon color 換到「詳細檢視」的渲染路徑,減少原本清單template的負擔
為方便理解,把備注寫在修改後的版本裡面
<!--
❌ BEFORE: loops over raw pendingTasks, no trackBy
✅ AFTER: loops over pre-processed visibleTaskItems, with trackBy: trackByTaskId
-->
<ng-container *ngFor="let task of pendingTasks">
<div class="rq-card" [attr.data-testid]="'review-task-' + task.id" (click)="openTask(task.postId)">
<div style="height:3px"
[style.background]="riskColorsByLevel(task.riskLevel).fg"
<!-- ❌ REMOVED: extra dynamic binding on same element, contributes to slot count -->
[style.opacity]="riskColorsByLevel(task.riskLevel).tint">
</div>
<div style="padding:14px">
<!-- ❌ REMOVED: <av> is a child component with its own lifecycle -->
<!-- moved to detail view -->
<av [user]="postMap.get(task.postId)?.author" size="sm"></av>
<!-- ❌ BEFORE: inline lookup inside template -->
<!-- ✅ AFTER: {{ item.author }} reads from pre-processed object -->
<div>{{ postMap.get(task.postId)?.author }}</div>
<div>{{ formatRelativeTime(task.createdAt) }}</div>
<!-- ❌ REMOVED: *ngIf forces Angular to evaluate condition on every card -->
<!-- moved to detail view -->
<div *ngIf="task.riskLevel === 'high'" class="risk-badge risk-badge--high">
{{ riskLabel(task.riskLevel) }}
</div>
<div *ngIf="task.riskLevel !== 'high'" class="risk-badge">
{{ riskLabel(task.riskLevel) }}
</div>
<!-- ❌ BEFORE: inline lookup inside template -->
<!-- ✅ AFTER: {{ summarizeContent(item.content, 120) }} -->
<div>"{{ summarizeContent(postMap.get(task.postId)?.content, 120) }}"</div>
<!-- ❌ BEFORE: inline lookup inside template -->
<!-- ✅ AFTER: {{ matchedKeywordsText(item.matchedKeywords) }} -->
<!-- | percent pipe stays, it's unavoidable here -->
<div>
{{ matchedKeywordsText(postMap.get(task.postId)?.matchedKeywords) }}
· {{ confidenceByLevel(task.riskLevel) | percent:'1.0-0' }}
</div>
<div style="display:flex;gap:8px" (click)="$event.stopPropagation()">
<button (click)="approve(task.postId)" [disabled]="!session.canReviewPosts()">
<!-- ❌ REMOVED: dynamic [color] binding on child component -->
<!-- ✅ AFTER: color="#34A871" hardcoded, removes dynamic binding -->
<m-icon name="check" [size]="16" [color]="riskColorsByLevel(task.riskLevel).fg"></m-icon>
Approve
</button>
<button aria-label="Flag"><m-icon name="flag" [size]="18"></m-icon></button>
<button (click)="reject(task.postId)" [disabled]="!session.canReviewPosts()">
<!-- this one was already hardcoded, no change needed -->
<m-icon name="close" [size]="16" [color]="'#E0426B'"></m-icon>
Reject
</button>
</div>
</div>
</div>
</ng-container>
<av> 是一個 avatar 元件
本身是一個 child component,有自己的生命週期和 input binding,比單純顯示文字複雜。
*ngIf 是條件式渲染,Angular 在每次 change detection 增加 template 的運算量。
風險等級色條的邏輯 [color]="riskColorsByLevel(task.riskLevel).fg"
每次渲染都要呼叫 method 並取出物件的屬性,每次迭代的時候就會呼叫一次。
這三個單獨存在都沒問題
但加在已經有 pipe、巢狀 event handler 的 template 裡,累積的複雜度便會讓 Angular 在重複渲染時能無法穩定運作。
結果
問題解決是解決了,但是為什麼重複渲染會造成這樣的問題呢?
其實就是標題提到的Ivy LView渲染機制,只是再寫下去,這篇文章會變太長
預計下篇會同時說明LView 如何運作、又和這次的bug有什麼關係
