Angular

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

Ghang 2026/06/05 09:00:00
62

 

 

接續前篇,問題解決是解決了,但是為什麼重複渲染會造成這樣的問題? 這邊先從底層開始說明: 

 
 

  1.   Angular Ivy 

  2.  

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 錯位的可能性。

 

 

 

Ghang