過去在寫 Angular 時,因為 Template 可以直接呼叫 Method,很容易在專案裡看到大量這樣的寫法。
這種方式很直覺,也很方便,但通常不建議在 Template 中大量使用 Method。重點不是「不能這樣寫」,而是要知道它為什麼可能被重複執行,以及過去通常會怎麼處理這類問題。
Signal 加入 Angular 之後,computed() 也提供了另一種做法。這篇會從 Template Method 開始,看看過去常見的 Pipe、RxJS 解法,以及現在可以怎麼用 computed() 處理。
先看一個常見的例子。
假設畫面需要顯示購物車總金額:
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 是否適合在 Change Detection 時反覆執行。
在 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 先算好,再提供給 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 取值,整體寫法就會稍微麻煩一些。

前面的 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() 也會自動追蹤計算過程中讀取到的 Signal,所以不需要自己處理 totalPrice 應該在什麼時候更新。
對這類「根據現有狀態算出另一個值」的情境來說,computed() 會比 Template Method 更適合,也比單純為了衍生資料建立 RxJS 流程更精簡。
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 會再比較新舊結果。如果內容相同,就不會把這次結果視為新的變化。

一般情況不需要特別設定 equal。比較本身也需要成本,如果只是很簡單的資料,為了避免一次更新反而加入更複雜的比較,不一定比較划算。
因此先使用預設的 Object.is() 就可以,只有在結果經常產生新的 Array 或 Object,而且內容又常常沒有變化時,再考慮是否需要自訂 equal。
文章一開始提到 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 好理解不少。
這幾種做法沒有誰一定要取代誰,差別主要在使用情境。
如果只是 Template 顯示格式轉換,例如:
{{ createdAt | date }}
{{ price | currency }}
使用 Pipe 還是很適合。
如果處理的是 HTTP、WebSocket、使用者輸入,或多個非同步事件之間的組合,RxJS 仍然很適合。
如果某個值本身就是由其他 Signal 計算出來,例如總金額是根據 products 產生,就很適合交給 computed()。
Template Method 也不是完全不能使用,只是如果這個 Method 本身代表一份可以明確推導的資料,而且還會在畫面中重複使用,就可以考慮改成 computed()。
可以先用這種方式區分:
computed()
Template 中呼叫 Method 本身沒有錯,需要注意的是,當 Angular 檢查這個 View 時,Method 可能會再次執行。如果裡面包含較重的計算,或同樣的邏輯散落在多個位置,就容易產生額外成本。
在 Signal 出現以前,可以透過 Pure Pipe 減少部分重複計算,也可以透過 RxJS 建立共用的衍生資料。
Signal 出現後,computed() 讓這類由其他狀態算出來的值有了更直接的做法。它會追蹤相依資料、保留計算結果,也可以透過 equal 控制新舊結果是否真的不同。
Pipe、RxJS 和 computed() 都有各自適合的情境,重點不是全部改成 Signal,而是依照資料來源和用途選擇適合的處理方式。
computed() 延遲計算並保留結果。Object.is() 比較新舊值。equal 與 debugName 兩個選項。