Day 26:SignalStore 與 Strategy Pattern,透過 DI 動態切換業務邏輯
7.3k 字,約 7 分鐘
2026 iThome 鐵人賽:Angular 22 Signal 進化論。 原文發表於 iThome,也可以在下方留言。
文章目錄
昨天我們使用 Angular 原生 Signal 與 NgRx SignalStore 實作購物車,透過 State 管理商品資料,再利用 Computed 計算商品數量與總金額。
不過,當購物車需要根據不同會員套用折扣時,可能會直接在 Store 裡面增加判斷:
const finalTotal = computed(() => {
const total = store.total();
if (memberType() === 'vip') {
return total * 0.9;
}
if (memberType() === 'business') {
return total * 0.85;
}
return total;
});
目前只有三種會員,這樣寫沒有什麼問題。但如果未來增加更多折扣規則,Store 就需要不斷修改。
今天我們會使用 Strategy Pattern(策略模式) 拆分折扣計算,再搭配 Angular Dependency Injection(DI)與 Signal,讓會員類型改變時,可以動態切換對應的折扣規則。
Strategy Pattern 是什麼?
Strategy Pattern 是一種行為型設計模式,主要是將不同的演算法或業務規則封裝成獨立物件,讓使用端透過共同介面操作。
以購物車為例,假設有三種會員:
- 一般會員:原價。
- VIP 會員:9 折。
- 企業會員:85 折。
三種會員的計算方式不同,但都需要根據總金額計算折扣,因此可以先定義共同介面。
定義折扣策略
discount.strategy.ts
import { Injectable } from '@angular/core';
export interface DiscountStrategy {
calculate(total: number): number;
}
@Injectable({ providedIn: 'root' })
export class NormalDiscount implements DiscountStrategy {
calculate(total: number) {
return total;
}
}
@Injectable({ providedIn: 'root' })
export class VipDiscount implements DiscountStrategy {
calculate(total: number) {
return total * 0.9;
}
}
@Injectable({ providedIn: 'root' })
export class BusinessDiscount implements DiscountStrategy {
calculate(total: number) {
return total * 0.85;
}
}
每個 Class 都實作 DiscountStrategy,提供相同的 calculate() 方法,但使用不同的折扣規則。未來新增其他折扣時,只需要建立新的 Strategy,不必將所有判斷集中在 Store 裡。
這裡透過 @Injectable({ providedIn: 'root' }) 將三個 Strategy 註冊到 Angular DI。它們通常會在第一次被注入時才建立實體,之後由 Root Injector 共用,而不是 Application 啟動時就全部建立。
下圖整理三個 Strategy 與共同介面的關係,無論使用哪種折扣規則,都能透過相同的 calculate() 方法計算。
使用 Service 動態切換 Strategy
將折扣規則拆開後,還需要根據目前的會員類型,選擇要使用哪一種 Strategy。
這次建立 DiscountService,透過 Signal 記錄會員類型,並利用 Angular DI 取得三個折扣策略。
建立 DiscountService
discount.service.ts
import { Injectable, inject, signal } from '@angular/core';
import {
NormalDiscount,
VipDiscount,
BusinessDiscount
} from './discount.strategy';
import type { DiscountStrategy } from './discount.strategy';
export type MemberType = 'normal' | 'vip' | 'business';
@Injectable({ providedIn: 'root' })
export class DiscountService implements DiscountStrategy {
readonly memberType = signal<MemberType>('normal');
private readonly strategies = {
normal: inject(NormalDiscount),
vip: inject(VipDiscount),
business: inject(BusinessDiscount)
};
calculate(total: number) {
return this.strategies[
this.memberType()
].calculate(total);
}
change(type: MemberType) {
this.memberType.set(type);
}
}
DiscountService 本身也實作了 DiscountStrategy,但不直接計算折扣,而是根據 memberType() 選擇對應的 Strategy。
這裡透過 inject() 取得三個 Strategy,因此當 DiscountService 第一次建立時,就會一併取得這些實體。之後呼叫 change() 切換會員類型時,只需要改用對應的 Strategy,不需要重新建立。
例如目前是 VIP 會員,就使用 VipDiscount;當呼叫 change('business') 後,便改由 BusinessDiscount 計算。
透過 Angular DI 提供 Strategy
有了 DiscountService 後,我們希望 SignalStore 只需要使用 calculate(),不必直接依賴 DiscountService 這個 Class。
由於 TypeScript Interface 在編譯後不會保留,無法直接作為 Angular DI 的 Token,因此需要建立 InjectionToken。
discount.token.ts
import { InjectionToken } from '@angular/core';
import type { DiscountStrategy } from './discount.strategy';
export const DISCOUNT_STRATEGY =
new InjectionToken<DiscountStrategy>('DISCOUNT_STRATEGY');
DISCOUNT_STRATEGY 是注入時使用的 Token,泛型則指定取得的物件需要符合 DiscountStrategy 介面。
接著在 app.config.ts 註冊:
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
import { DISCOUNT_STRATEGY } from './cart/discount.token';
import { DiscountService } from './cart/discount.service';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
{
provide: DISCOUNT_STRATEGY,
useExisting: DiscountService
}
]
};
這裡使用 useExisting,讓 DISCOUNT_STRATEGY 成為 DiscountService 的另一個注入名稱。由於 DiscountService 已經透過 providedIn: 'root' 註冊,因此不需要再加入 providers。
useExisting 與 useClass 的差別在於是否共用實體:
| Provider | 說明 |
|---|---|
useClass | 由 DI 為該 Token 建立指定 Class 的實體 |
useExisting | 使用另一個 Token 對應的同一個實體 |
如果改成 useClass: DiscountService,Angular 就會為 DISCOUNT_STRATEGY 建立另一個 DiscountService 實體,與原本透過 Root 註冊的實體分開管理。
這次使用 useExisting,是因為 Component 需要切換會員類型,而 SignalStore 需要根據相同的會員狀態計算折扣,因此兩者必須取得同一個 DiscountService 實體。
在 SignalStore 使用 Strategy
延續昨天的 NgRxCartStore,保留原本的購物車功能,另外增加折扣後的 finalTotal。
ngrx-cart.store.ts
import { computed, inject } from '@angular/core';
import {
signalStore,
withState,
withComputed,
withMethods,
patchState
} from '@ngrx/signals';
import type { CartItem, Product } from './cart.types';
import { DISCOUNT_STRATEGY } from './discount.token';
export const NgRxCartStore = signalStore(
{ providedIn: 'root' },
withState({
items: [] as CartItem[]
}),
withComputed(({ items }) => {
const discount = inject(DISCOUNT_STRATEGY);
const count = computed(() =>
items().reduce(
(sum, item) => sum + item.quantity,
0
)
);
const total = computed(() =>
items().reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
);
const finalTotal = computed(() =>
discount.calculate(total())
);
return { count, total, finalTotal };
}),
withMethods(store => ({
add(product: Product) {
patchState(store, state => {
const existing = state.items.find(
item => item.id === product.id
);
const items = existing
? state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
: [...state.items, { ...product, quantity: 1 }];
return { items };
});
},
remove(id: number) {
patchState(store, state => ({
items: state.items.filter(item => item.id !== id)
}));
},
clear() {
patchState(store, { items: [] });
}
}))
);
這次主要新增:
const discount = inject(DISCOUNT_STRATEGY);
const finalTotal = computed(() =>
discount.calculate(total())
);
inject(DISCOUNT_STRATEGY) 會透過 Angular DI 取得前面註冊的 DiscountService 實體,再由 calculate() 計算折扣後的金額。
這裡也可以直接使用 inject(DiscountService),但透過 DISCOUNT_STRATEGY,Store 只需要知道 calculate(),不必依賴 DiscountService 的其他功能。未來如果更換成其他符合介面的折扣服務,也不需要修改 Store。
Component 切換會員類型
最後在 Component 加入會員選單,讓使用者可以切換折扣。
cart.ts
import { Component, inject } from '@angular/core';
import { NgRxCartStore } from './ngrx-cart.store';
import { DiscountService } from './discount.service';
import type { MemberType } from './discount.service';
@Component({
selector: 'app-root',
template: `
<main style="padding: 24px; max-width: 600px;">
<h1>Strategy Pattern 購物車</h1>
<h3>商品列表</h3>
<button (click)="store.add({
id: 1,
name: '鍵盤',
price: 1200
})">
加入鍵盤 $1200
</button>
<button (click)="store.add({
id: 2,
name: '滑鼠',
price: 800
})">
加入滑鼠 $800
</button>
<hr />
<h3>會員類型</h3>
<select
[value]="discount.memberType()"
(change)="changeMember($event)"
>
<option value="normal">一般會員</option>
<option value="vip">VIP 會員</option>
<option value="business">企業會員</option>
</select>
<hr />
<h3>購物車</h3>
@for (item of store.items(); track item.id) {
<p>
{{ item.name }} × {{ item.quantity }}
= {{ item.price * item.quantity }} 元
<button (click)="store.remove(item.id)">
移除
</button>
</p>
} @empty {
<p>購物車目前沒有商品</p>
}
<hr />
<p>商品數量:{{ store.count() }}</p>
<p>原價:{{ store.total() }} 元</p>
<h2>折扣後:{{ store.finalTotal() }} 元</h2>
<button (click)="store.clear()">
清空購物車
</button>
</main>
`
})
export class CartComponent {
readonly store = inject(NgRxCartStore);
readonly discount = inject(DiscountService);
changeMember(event: Event) {
const select = event.target as HTMLSelectElement;
this.discount.change(select.value as MemberType);
}
}
Component 直接注入 DiscountService,因為需要呼叫 change() 切換會員類型;SignalStore 則透過 DISCOUNT_STRATEGY 使用同一個實體來計算折扣。
假設購物車有一個 1,200 元的鍵盤,以及一個 800 元的滑鼠:
| 會員類型 | 折扣 | 結帳金額 |
|---|---|---|
| 一般會員 | 原價 | 2,000 元 |
| VIP 會員 | 9 折 | 1,800 元 |
| 企業會員 | 85 折 | 1,700 元 |
切換會員類型後,購物車商品與原價都不會改變,只有折扣金額會跟著更新。
Angular SignalStore 與 Strategy Pattern,透過 DI 動態切換業務邏輯 - StackBlitz
為什麼切換後會自動更新?
回頭看 SignalStore 裡面的 finalTotal:
const finalTotal = computed(() =>
discount.calculate(total())
);
雖然這裡沒有直接讀取 memberType(),但 DiscountService 的 calculate() 內部會使用這個 Signal:
calculate(total: number) {
return this.strategies[
this.memberType()
].calculate(total);
}
Angular 的 computed() 會追蹤計算過程中讀取的 Signal,即使透過其他方法間接讀取,也一樣會建立依賴關係。
因此,finalTotal 同時依賴購物車的 total() 與會員類型的 memberType()。當其中一個改變時,就會在下次讀取時重新計算。
需要注意的是,Runtime 切換的是 DiscountService 內部使用的 Strategy,而不是 Angular DI Provider 本身。DI 負責提供實體,Signal 則讓折扣計算能隨著會員類型改變。
這次為了示範,讓使用者可以直接切換會員類型。實際系統中的會員等級通常由登入資訊或後端資料決定,最終結帳金額也應由後端驗證。
本日小結
今天透過 Strategy Pattern,將不同會員的折扣規則拆成獨立 Class,讓 SignalStore 不需要處理各種折扣判斷,只需要呼叫共同的 calculate() 方法。
Angular DI 負責建立與管理 Strategy,DiscountService 則透過 inject() 取得各個折扣實體。搭配 InjectionToken 與 useExisting,即使 SignalStore 與 Component 注入不同的 Token,也能共用同一個 Service。
當會員類型改變時,DiscountService 會選擇對應的 Strategy,而 SignalStore 的 Computed 也會依照最新狀態重新計算金額,不需要重新建立 Store 或替換 DI Provider。
這個範例將折扣規則與購物車狀態分開管理。未來需要增加新的會員折扣時,可以擴充 Strategy,讓原本的 Store 不必跟著調整。
資料來源
- Angular 官方文件:useExisting:
useExisting共用實體,useClass另建實體。 - Angular 官方文件:Can TypeScript interfaces be identifiers for injection?:Interface 不能當 DI Token,改用
InjectionToken。 - Angular Signals 官方文件:Computed signal dependencies are dynamic:
computed()追蹤計算中實際讀取的 Signal。 - NgRx 官方文件:SignalStore:
withComputed()在 Injection Context 執行,可使用inject()。
留言
留言使用 GitHub 帳號登入(Giscus),留言會存在 GitHub Discussions。