Angular Ivy - 多重渲染造成的錯誤排除紀錄(下)
接續前篇,問題解決是解決了,但是為什麼重複渲染會造成這樣的問題呢? 這邊先從底層開始說明:
-
Angular Ivy
Angular 目前使用的渲染引擎叫做 Ivy,Ivy 在編譯 template 時,會把它轉換成一個 template function,執行時操作一個叫做 LView 的陣列結構。
格子 0 → {{ item.author }} 這個 binding
格子 1 → | percent 這個 pipe
格子 2 → (click) 這個 event listener
格子 3 → <m-icon> 這個 child component
為方便說明,我們將格子換成Angular文件常用的名詞 slot
LView 裡的每個 slot 對應 template 裡的一個東西 : 可能是一個 binding、一個 pipe instance、一個 child component,或一個 event listener,而slot的編號在編譯時就定下來了。
*ngFor 的資料隔離 vs 藍圖共用
*ngFor 跑 22 次、建立 22 張卡片,每張卡片有自己獨立的 LView。
card 1 的資料存在自己的 LView 裡,不會跑到 card 2 去,這便是「隔離」
藍圖是什麼呢?想像我們要印 22 份考卷發給 22 個學生,每個學生填自己的答案,答案彼此不會互相影響(隔離)。但這 22 份考卷都是從同一個原稿印出來的。如果原稿在印刷前就排版錯誤,22 份考卷全部都會有同樣的錯誤(藍圖共用)。
這整個 <ng-container> 裡面的內容,Ivy 只編譯一次,產出一個 template function
<!-- 以下為簡化示意,完整版本見後段對照 -->
<ng-container *ngFor="let task of pendingTasks">
<div class="rq-card">
<div>{{ postMap.get(task.postId)?.author }}</div>
<div *ngIf="task.riskLevel === 'high'" class="risk-badge--high">...</div>
<m-icon [color]="riskColorsByLevel(task.riskLevel).fg"></m-icon>
</div>
</ng-container>
Ivy 編譯 template 時產出的 template function 就是那份原稿,slot 編號就是考卷上的題號。原稿的題號若有問題,22 張卡片也會全部繼承同樣的問題。
為什麼只有第一張正常
第一個View(卡片)建立時,LView 是全新、空的
「首次」初始化的錯誤容忍度比較高,所以第一張卡片正常顯示。
相關code如下:
{{ confidenceByLevel(item.riskLevel) | percent:'1.0-0' }}
| percent 負責把數字格式化成百分比,上面就是把 0.85變成 85% ; 它在 LView 裡需要一個專屬的slot來存放自己。
第一張卡片建立時
LView 是全新的,每個 slot 都是空位。接著Pipe 來了,找到自己應該坐的 slot,就算 slot 編號因為錯位稍微偏了,旁邊的 slot 也都是空的,所以直接坐進去也沒有衝突發生,卡片正常顯示。
就像電影院開場前你第一個進去,座位全空,就算坐錯排也不會有人找你麻煩。
Angular 在這邊做的是:第一張卡片建立時,把 pipe 放進那個 slot 存起來;之後每一張卡片建立時,直接去那個 slot 取用,不重新建立pipe。這樣比每張卡片都重新建立一個新的 pipe 更有效率。
所以從第二個 view 開始,Angular 預設那個slot裡「應該已經有」一個有效的 pipe reference,並不會做額外的驗證就直接取用。所以即便Slot內容有誤,由於Angular不會驗證,他就直接跳過 ; binding 消失,內容就不會正確顯示了。
簡單來說,
> 刻壞的印章蓋了 22 次
> 第一次墨水還足夠,字跡勉強看得出來
> 後面 21 次墨水不夠,印不出正確的款式
回到我們修改的內容
修改前的 template 裡有幾種容易干擾 slot 計數的 binding 類型同時存在:
> | percent pipe需要在每個 embedded view 裡佔用一個固定的 LView slot
> <av>和動態色條也增加了 slot 計數的複雜度
這個錯誤不是哪個功能直接造成,是各自計數累積到編譯器無法正確對齊 slot 的程度。
這樣的做法有效主要是因為移除了 <av>、*ngIf 條件是渲染和動態色條,把 binding 數量簡化/減少到 slot可以容許的範圍內。但這只是治標不治本,要是日後再加重清單渲染的負擔,錯誤就有可能再次發生。 從根本解決的方式是把卡片抽成獨立的 child component:
<!--
❌ 原始寫法:所有複雜度都在 *ngFor 的 template 裡
每次 iteration,這整份 template 都要被 Ivy 用同一份藍圖建立一次
slot 計數:以下僅標出關鍵的累積項目
-->
<ng-container *ngFor="let task of pendingTasks">
<div class="rq-card"
[attr.data-testid]="'review-task-' + task.id" <!-- slot + 1 -->
(click)="openTask(task.postId)"> <!-- slot + 1 -->
<div [style.background]="riskColorsByLevel(task.riskLevel).fg" <!-- slot + 1 -->
[style.opacity]="riskColorsByLevel(task.riskLevel).tint"> <!-- slot + 1 -->
</div>
<av [user]="postMap.get(task.postId)?.author" size="sm"></av> <!-- slot + 1 (child component) -->
<div>{{ postMap.get(task.postId)?.author }}</div> <!-- slot + 1 -->
<div *ngIf="task.riskLevel === 'high'" class="risk-badge--high"> <!-- slot + 1 (*ngIf) -->
{{ riskLabel(task.riskLevel) }}
</div>
<div *ngIf="task.riskLevel !== 'high'" class="risk-badge"> <!-- slot + 1 (*ngIf) -->
{{ riskLabel(task.riskLevel) }}
</div>
<div>"{{ summarizeContent(postMap.get(task.postId)?.content, 120) }}"</div> <!-- slot + 1 -->
<div>
{{ matchedKeywordsText(postMap.get(task.postId)?.matchedKeywords) }}
· {{ confidenceByLevel(task.riskLevel) | percent:'1.0-0' }} <!-- slot + 1 (pipe) -->
</div>
<div (click)="$event.stopPropagation()"> <!-- slot + 1 -->
<button
(click)="approve(task.postId)" <!-- slot + 1 -->
[disabled]="!session.canReviewPosts()"> <!-- slot + 1 -->
<m-icon [color]="riskColorsByLevel(task.riskLevel).fg"></m-icon> <!-- slot + 1 (dynamic input) -->
Approve
</button>
<button
(click)="reject(task.postId)" <!-- slot + 1 -->
[disabled]="!session.canReviewPosts()"> <!-- slot + 1 -->
<m-icon [color]="'#E0426B'"></m-icon>
Reject
</button>
</div>
</div>
</ng-container>
<!-- 父層 slot 總計:14+ -->
<!-- ─────────────────────────────────────────────── -->
<!--
✅ 改用 child component:父層的 *ngFor template 只剩 3 個 slot
所有複雜度移進 AppReviewCardComponent 自己的 template function
父層藍圖趨近於空白,slot 錯位的問題從結構上消失
-->
<app-review-card
*ngFor="let item of visibleTaskItems; trackBy: trackByTaskId"
[item]="item" <!-- slot + 1 (input binding) -->
(approve)="approve($event)" <!-- slot + 1 (event listener) -->
(reject)="reject($event)"> <!-- slot + 1 (event listener) -->
</app-review-card>
<!-- 父層 slot 總計:3 -->
結論
複雜的列表卡片適合抽成獨立 component。畫面渲染與操作邏輯本來就該切分,分開之後各自的 template 都能保持簡單,也從結構上排除了 LView slot 錯位的可能性。
