Angular Ivy

Angular Ivy - 多重渲染造成的錯誤排除紀錄(上)

Ghang 2026/06/04 09:00:00
15

 

背景說明

 

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有什麼關係

 

 

 

Ghang