iT邦幫忙

2026 iThome 鐵人賽

DAY 21
1
Modern Web

Angular 22 Signal 進化論系列 第 21 篇

Day 21:Angular Template 還適合呼叫 Method 嗎?從 Pipe、RxJS 到 computed

  • 分享至 

  • xImage
  •  

過去在寫 Angular 時,因為 Template 可以直接呼叫 Method,很容易在專案裡看到大量這樣的寫法。

這種方式很直覺,也很方便,但通常不建議在 Template 中大量使用 Method。重點不是「不能這樣寫」,而是要知道它為什麼可能被重複執行,以及過去通常會怎麼處理這類問題。

Signal 加入 Angular 之後,computed() 也提供了另一種做法。這篇會從 Template Method 開始,看看過去常見的 Pipe、RxJS 解法,以及現在可以怎麼用 computed() 處理。

Template 使用 Method

先看一個常見的例子。

假設畫面需要顯示購物車總金額:

export class CartComponent {
  products = [
    { name: 'Keyboard', price: 2000 },
    { name: 'Mouse', price: 1000 },
  ];

  getTotalPrice() {
    console.log('getTotalPrice');

    return this.products.reduce(
      (total, product) => total + product.price,
      0
    );
  }
}

接著直接在 Template 呼叫:

<p>總金額:{{ getTotalPrice() }}</p>

<button>加入購物車</button>

乍看之下沒有什麼問題,但如果打開 Console 觀察,就會發現 getTotalPrice() 不一定只有在商品資料改變時才執行。

Angular 在進行 Change Detection 時,需要重新確認 Template 中的表達式結果。只要這個 View 再次被檢查,放在 Template 裡的 Method 就可能重新執行。

即使這次操作和總金額沒有直接關係,例如點擊其他按鈕,getTotalPrice() 仍然可能再次被呼叫。

Template Method 為什麼會重複執行?

這種寫法主要會帶來兩個問題:

  • 排錯比較困難:我自己就曾經為了追同事留下來的舊程式碼花了不少時間。明明只是按下一個按鈕,某個 Method 卻在短時間內被呼叫很多次,Console 一瞬間出現大量 Log,很難第一時間判斷是哪個操作真正觸發了邏輯。
  • 產生不必要的計算:如果只是簡單的加總或字串處理,影響通常不大。但如果 Method 裡包含排序、過濾、大量陣列處理,甚至其他較昂貴的運算,反覆執行就可能累積成實際的效能成本。

所以問題並不是 Template 完全不能使用 Method,而是要注意這個 Method 是否適合在 Change Detection 時反覆執行。

過去的解決方式:使用 Pipe

在 Signal 出現之前,如果某段計算只是為了顯示在 Template 上,常見的做法之一是改成自訂 Pipe。

以前面的商品總金額為例,可以把原本的計算邏輯搬到 Pipe:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'totalPrice',
})
export class TotalPricePipe implements PipeTransform {
  transform(
    products: { name: string; price: number }[]
  ): number {
    return products.reduce(
      (total, product) => total + product.price,
      0
    );
  }
}

Template 就可以改成:

<p>總金額:{{ products | totalPrice }}</p>

以預設的 Pure Pipe 來說,它不會因為每次 Change Detection 都重新執行,而是在輸入值發生變化時才重新計算。

如果輸入的是 Array 或 Object,Pure Pipe 主要看 Reference 有沒有改變。像是直接用 push() 修改原本的陣列,雖然內容變了,但 Reference 沒變,因此 Pipe 不會重新執行;如果希望 Pipe 重新計算,就需要產生新的 Array 或 Object。

因此這類資料通常會改成產生新的 Array 或 Object,讓 Angular 能判斷輸入已經改變。

Pure Pipe 可以減少不必要的計算,但如果同一個 Pipe 在 Template 多個位置使用:

<p>總金額:{{ products | totalPrice }}</p>
<p>結帳金額:{{ products | totalPrice }}</p>
<p>目前金額:{{ products | totalPrice }}</p>

這幾個 Binding 還是會各自維護自己的 Pipe 執行結果,因此第一次顯示時,仍然會分別計算一次。

過去的另一種做法:使用 RxJS

如果同一份計算結果會在多個地方使用,過去也很常透過 RxJS 先算好,再提供給 Template。

例如先把商品資料放進 BehaviorSubject:

readonly products$ = new BehaviorSubject([
  { name: 'Keyboard', price: 2000 },
  { name: 'Mouse', price: 1000 },
]);

再透過 map() 計算總金額:

readonly totalPrice$ = this.products$.pipe(
  map(products =>
    products.reduce(
      (total, product) => total + product.price,
      0
    )
  ),
  shareReplay(1)
);

Template 就可以直接取得結果:

<p>總金額:{{ totalPrice$ | async }}</p>
<p>結帳金額:{{ totalPrice$ | async }}</p>
<p>目前金額:{{ totalPrice$ | async }}</p>

這裡雖然有三個 async Pipe,也就是三個訂閱,但 shareReplay(1) 會讓它們共用同一條 Observable 執行流程,並保留最近一次的結果。

因此 map() 裡的總金額計算,不需要因為 Template 出現三次就跟著執行三次。

這種方式可以把衍生資料集中在 Observable 裡,也不用另外維護 totalPrice 的更新時機。

不過如果只是根據目前狀態算出一個畫面要使用的值,為了這件事建立 Observable、map()、shareReplay(),最後再透過 async Pipe 取值,整體寫法就會稍微麻煩一些。

同一個結果顯示 3 次,實際計算幾次?

Signal 時代:使用 computed()

前面的 totalPrice 有一個很明顯的特性:

它不是一份需要自己維護的資料,而是根據 products 計算出來的結果。

當商品資料改成 Signal:

readonly products = signal([
  { name: 'Keyboard', price: 2000 },
  { name: 'Mouse', price: 1000 },
]);

就可以直接透過 computed() 建立總金額:

readonly totalPrice = computed(() => {
  console.log('重新計算 totalPrice');

  return this.products().reduce(
    (total, product) => total + product.price,
    0
  );
});

Template 不管使用幾次,都只是在讀取同一個 computed:

<p>總金額:{{ totalPrice() }}</p>
<p>結帳金額:{{ totalPrice() }}</p>
<p>目前金額:{{ totalPrice() }}</p>

這三次讀取並不代表裡面的 reduce() 會執行三次。

第一次讀取 totalPrice() 時,computed() 會進行計算並保留結果。只要 products 沒有改變,後續再次讀取都會直接取得前一次的結果。

如果更新商品:

this.products.update(products => [
  ...products,
  { name: 'Monitor', price: 5000 },
]);

原本的結果就會失效,等下一次讀取 totalPrice() 時才重新計算。

computed() 的快取生命週期

computed() 也會自動追蹤計算過程中讀取到的 Signal,所以不需要自己處理 totalPrice 應該在什麼時候更新。

對這類「根據現有狀態算出另一個值」的情境來說,computed() 會比 Template Method 更適合,也比單純為了衍生資料建立 RxJS 流程更精簡。

computed() 的 equal

computed() 預設會使用 Object.is() 比較新舊結果。

大部分情況不需要特別處理,但如果結果是 Array 或 Object,就可能遇到 Reference 不同、內容其實一樣的情況。

例如:

readonly productNames = computed(() =>
  this.products().map(product => product.name)
);

每次重新計算時,map() 都會建立新的 Array。即使內容和上一次完全相同,因為 Reference 不同,預設仍然會被視為新的結果。

如果有需要,可以透過 equal 自訂比較方式:

readonly productNames = computed(
  () =>
    this.products().map(
      product => product.name
    ),
  {
    equal: (previous, current) =>
      previous.length === current.length &&
      previous.every(
        (name, index) =>
          name === current[index]
      ),
  }
);

重新計算後,equal 會再比較新舊結果。如果內容相同,就不會把這次結果視為新的變化。

Reference 跟內容,是兩回事

一般情況不需要特別設定 equal。比較本身也需要成本,如果只是很簡單的資料,為了避免一次更新反而加入更複雜的比較,不一定比較划算。

因此先使用預設的 Object.is() 就可以,只有在結果經常產生新的 Array 或 Object,而且內容又常常沒有變化時,再考慮是否需要自訂 equal。

使用 debugName 協助除錯

文章一開始提到 Template Method 其中一個麻煩,就是執行次數一多,常常只能靠大量 console.log() 去追。

Signal 可以透過 debugName 加上比較容易辨識的名稱:

readonly products = signal(
  [
    { name: 'Keyboard', price: 2000 },
    { name: 'Mouse', price: 1000 },
  ],
  {
    debugName: 'cartProducts',
  }
);

computed() 也可以設定:

readonly totalPrice = computed(
  () =>
    this.products().reduce(
      (total, product) => total + product.price,
      0
    ),
  {
    debugName: 'cartTotalPrice',
  }
);

debugName 不會影響 Signal 的運作,主要是讓 Angular DevTools 顯示 Signal 時,可以用比較容易辨識的名稱呈現。

當 Component 裡的 Signal 越來越多時,替重要的狀態或衍生資料加上名稱,排查資料關係會比看到一堆匿名 Signal 好理解不少。

Method、Pipe、RxJS 與 computed 怎麼選?

這幾種做法沒有誰一定要取代誰,差別主要在使用情境。

如果只是 Template 顯示格式轉換,例如:

{{ createdAt | date }}
{{ price | currency }}

使用 Pipe 還是很適合。

如果處理的是 HTTP、WebSocket、使用者輸入,或多個非同步事件之間的組合,RxJS 仍然很適合。

如果某個值本身就是由其他 Signal 計算出來,例如總金額是根據 products 產生,就很適合交給 computed()。

Template Method 也不是完全不能使用,只是如果這個 Method 本身代表一份可以明確推導的資料,而且還會在畫面中重複使用,就可以考慮改成 computed()。

可以先用這種方式區分:

  • Template 顯示轉換 → Pipe
  • 非同步資料與事件流 → RxJS
  • Signal 的衍生資料 → computed()
  • 很單純、成本很低的操作 → Method 仍然可以使用

本日結語

Template 中呼叫 Method 本身沒有錯,需要注意的是,當 Angular 檢查這個 View 時,Method 可能會再次執行。如果裡面包含較重的計算,或同樣的邏輯散落在多個位置,就容易產生額外成本。

在 Signal 出現以前,可以透過 Pure Pipe 減少部分重複計算,也可以透過 RxJS 建立共用的衍生資料。

Signal 出現後,computed() 讓這類由其他狀態算出來的值有了更直接的做法。它會追蹤相依資料、保留計算結果,也可以透過 equal 控制新舊結果是否真的不同。

Pipe、RxJS 和 computed() 都有各自適合的情境,重點不是全部改成 Signal,而是依照資料來源和用途選擇適合的處理方式。

資料來源


上一篇
Day 20:Signal Forms 非同步驗證,validateHttp 與 validateAsync
下一篇
Day 22:Angular Render Lifecycle,從 afterNextRender 到 DestroyRef
系列文
Angular 22 Signal 進化論 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言