專案一長大,規則就越來越多。折扣怎麼算?列表要怎麼排?金流費率怎麼換?如果你腦中浮現一排 if/else,那這篇剛好救你。
策略(Strategy)模式做的事很單純:把「做法」獨立成可替換的策略,呼叫端只要用同一套介面就能切換。
你可以從最常見的折扣、排序開始,把凌亂的分支抽離成清楚的小函數或類別;再往上,加入註冊表、預設與回退、非同步策略、測試與觀測。文中會用 JavaScript 實作一路演練:從基礎版的 class,到偏函數式的 Map 寫法,再到實務案例與踩雷清單。希望本篇文章能幫助到需要的您。
目錄
{tocify} $title={目錄}
為什麼需要策略(Strategy)模式
當你的程式需要在多種可替代的「做法/算法」中切換,而且希望:
將「選擇策略」與「執行策略」解耦;
讓每種做法可以獨立擴充、測試與上線;
避免到處充斥 if/else 或 switch;
策略模式就派上用場。它的重點是以「一致的介面」封裝不同算法,讓呼叫端不必知道細節,只負責挑選或注入合適的策略即可。
常見場景:
訂單折扣規則(滿額、會員等級、行銷活動)
金流/物流費率計算(不同供應商)
排序規則切換(熱門、最新、評分)
壓縮/轉檔與不同引擎替換
風險評分、推薦排序、AB 測試分流等
核心概念與結構
Strategy(策略):
定義一個共同介面(如 execute(input): Result),每個具體策略皆遵守這份介面。
ConcreteStrategy(具體策略):
不同算法的實作體(如 PercentageDiscount、FlatDiscount)。
Context(上下文):
對外提供功能的物件,它持有一個策略,但不關心策略如何實作。
Strategy Selector(選擇器)/ Factory(工廠):
負責依「條件或設定」選出/建立策略實例(可選,用於集中管理)。
效果:呼叫端只和 Context 或策略介面互動,不需理解每種策略內部的細節。
何時該用、何時不該用
適合:
規則會變、數量會增減,且需要熱插拔或 A/B 測試。
需要在執行期(runtime)動態切換算法。
需要讓新策略可獨立上線與測試。
不適合:
規則只有一種、幾乎不變;抽象化反而增加心智負擔。
策略之間共享大量複雜狀態(導致耦合很高)。
策略切換其實是物件狀態流轉(更像 State 模式)。
基礎範例:折扣計算器(Class 版)
需求:根據不同行銷規則計算折扣:百分比折扣、固定金額、滿額折抵。
// 共同「介面」約定(以 JSDoc 註解協助開發者維持一致)
/**
* @typedef {Object} DiscountStrategy
* @property {(amount: number, options?: any) => number} apply - 回傳折扣金額(非折後價)
*/
// 具體策略:百分比折扣
class PercentageDiscount {
/**
* @param {number} rate 0~1 之間,例如 0.2 代表 20% 折扣
*/
constructor(rate) {
if (rate < 0 || rate > 1) throw new Error('rate 必須介於 0~1');
this.rate = rate;
}
/** @param {number} amount */
apply(amount) { return amount * this.rate; }
}
// 具體策略:固定金額折扣
class FlatDiscount {
constructor(fee) {
if (fee < 0) throw new Error('fee 不可為負數');
this.fee = fee;
}
apply(amount) { return Math.min(this.fee, amount); }
}
// 具體策略:滿額折抵
class ThresholdDiscount {
constructor(threshold, off) {
this.threshold = threshold;
this.off = off;
}
apply(amount) {
return amount >= this.threshold ? Math.min(this.off, amount) : 0;
}
}
// Context:對外提供「計算折後價」服務,持有策略
class PriceCalculator {
/** @param {DiscountStrategy} strategy */
constructor(strategy) { this.strategy = strategy; }
setStrategy(strategy) { this.strategy = strategy; }
/** @param {number} base */
finalAmount(base) {
const discount = this.strategy.apply(base);
return Math.max(0, base - discount);
}
}
// 使用
const cartTotal = 1000;
const calculator = new PriceCalculator(new PercentageDiscount(0.2));
console.log(calculator.finalAmount(cartTotal)); // 800
calculator.setStrategy(new FlatDiscount(150));
console.log(calculator.finalAmount(cartTotal)); // 850
calculator.setStrategy(new ThresholdDiscount(1200, 300));
console.log(calculator.finalAmount(cartTotal)); // 1000(未達門檻,不折)
重點:
Context 不知道策略內部邏輯,只負責呼叫 apply。
新增策略=新增一個類別,不用改舊碼(符合開放封閉原則)。
函數式範例:用物件 Map + 純函數實作
不用 class 也行,很多 JS 團隊偏好純函數 + Map,更輕量、易測試。
// 每個策略都是 (amount, options) => discount
const strategies = {
percentage: (amount, { rate }) => {
if (rate < 0 || rate > 1) throw new Error('rate 必須介於 0~1');
return amount * rate;
},
flat: (amount, { fee }) => Math.min(fee, amount),
threshold: (amount, { threshold, off }) => amount >= threshold ? Math.min(off, amount) : 0,
};
/**
* 通用計算器:以 key 選策略,未命中走預設
*/
function calcFinalAmount(base, key, options = {}, defaultKey = 'flat') {
const fn = strategies[key] ?? strategies[defaultKey];
const discount = fn(base, options);
return Math.max(0, base - discount);
}
// 使用
console.log(calcFinalAmount(1000, 'percentage', { rate: 0.1 })); // 900
console.log(calcFinalAmount(1000, 'flat', { fee: 120 })); // 880
console.log(calcFinalAmount(800, 'threshold', { threshold: 1200, off: 300 })); // 800
這種寫法:
很容易透過設定檔或後端回傳 key來決定策略;
測試只要針對每個函數打;
適合 Node 與前端共用一套策略實作。
進階案例 A:金流手續費策略(含非同步)
情境:不同金流商收費方式不同,且有的需要呼叫外部 API(例如動態費率或匯率)。
/**
* @typedef {Object} PaymentInput
* @property {number} amount - 金額(原幣)
* @property {string} currency - 貨幣代碼,如 'USD'
* @property {boolean} isInternational - 是否國際卡
*/
/** @typedef {(input: PaymentInput) => Promise<number>} FeeStrategyAsync */
// Stripe:固定 2.9% + 0.3,國際卡加 1%
const stripeFee /** @type {FeeStrategyAsync} */ = async (input) => {
const base = input.amount * 0.029 + 0.3;
const intl = input.isInternational ? input.amount * 0.01 : 0;
return base + intl;
};
// AcmePay:需查匯率 API(此處用假資料模擬)
async function fetchFxRate(from, to) {
await new Promise(r => setTimeout(r, 5)); // 模擬 I/O
if (from === to) return 1;
return 1.1; // 假設 USD->TWD 匯率
}
const acmeFee /** @type {FeeStrategyAsync} */ = async (input) => {
const rate = await fetchFxRate(input.currency, 'USD');
const amountUSD = input.amount / rate;
return amountUSD * 0.025 + 0.25;
};
const feeStrategies = { stripe: stripeFee, acme: acmeFee };
/** 計算總費用(非同步) */
async function calcFee(providerKey, input) {
const strategy = feeStrategies[providerKey];
if (!strategy) throw new Error(`未知金流:${providerKey}`);
return strategy(input);
}
// 使用
(async () => {
const input = { amount: 100, currency: 'USD', isInternational: true };
console.log(await calcFee('stripe', input)); // 2.9 + 0.3 + 1 = 4.2
console.log(await calcFee('acme', input)); // 依假匯率計算
})();
要點:
策略可以是非同步函數,Context 以 await 方式統一調用;
呼叫端不管策略細節,可在執行期更換策略。
進階案例 B:可插拔排序策略(前端常見)
商品列表要支援多種排序:最新、熱度、評分、價格(升/降)。
/**
* @typedef {{ id:string, createdAt:number, views:number, rating:number, price:number }} Item
*/
/** @type {Record<string, (a: Item, b: Item) => number>} */
const comparators = {
newest: (a, b) => b.createdAt - a.createdAt,
hot: (a, b) => b.views - a.views,
rating: (a, b) => b.rating - a.rating,
priceAsc: (a, b) => a.price - b.price,
priceDesc: (a, b) => b.price - a.price,
};
function sortItems(items, key, fallback = 'newest') {
const cmp = comparators[key] ?? comparators[fallback];
// 保持純粹:回傳新陣列,避免就地排序影響外部狀態
return [...items].sort(cmp);
}
// 使用
const data = [
{ id:'a', createdAt: 10, views: 50, rating: 4.5, price: 100 },
{ id:'b', createdAt: 20, views: 30, rating: 4.7, price: 80 },
];
console.log(sortItems(data, 'priceAsc'));
進階案例 C:影像處理策略(依賴注入、替換引擎)
同一張圖,可能要用不同壓縮器(瀏覽器原生 / WebAssembly / 雲端 API)。
/**
* @typedef {(blob: Blob, options?: any) => Promise<Blob>} CompressStrategy
*/
// 瀏覽器 Canvas 壓縮(示意;細節視專案而定)
const browserCanvasCompress = async (blob, { quality = 0.8 } = {}) => {
const img = await createImageBitmap(blob);
const canvas = new OffscreenCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const result = await canvas.convertToBlob({ type: 'image/jpeg', quality });
return result;
};
// WebAssembly 壓縮(假想)
const wasmCompress = async (blob, { level = 3 } = {}) => {
// 假設有 wasmLib.compress
// return wasmLib.compress(blob, { level });
return blob; // demo
};
// 雲端 API 壓縮(上傳 → 壓縮 → 下載)
const cloudCompress = async (blob, { token }) => {
// fetch 上傳與下載(省略),回傳壓縮後 Blob
return blob; // demo
};
const compressors = { browser: browserCanvasCompress, wasm: wasmCompress, cloud: cloudCompress };
async function compressImage(blob, key, options = {}, fallback = 'browser') {
const fn = compressors[key] ?? compressors[fallback];
return fn(blob, options);
}
這種設計讓你可以在不同環境(本機、雲端、WebWorker)替換實作而不動核心呼叫碼。
與其他模式的差異
Strategy vs State:
Strategy 用於可替換的算法;State 表示物件的狀態流轉導致行為改變。若「狀態 A→B→C」是流程重點,考慮 State。
Strategy vs Template Method:
Template 固定流程、部分步驟讓子類覆寫;Strategy 是把整個算法抽成獨立替換單元。
Strategy vs Factory:
Factory 負責建立對象;Strategy 關注執行行為。兩者常搭配,用 Factory 幫你挑選/產生策略。
Strategy vs Policy(政策物件):
很多語境下幾乎同義,強調以物件封裝一套規則(策略)。
策略選擇與註冊表(Registry)設計
集中管理策略,便於擴充與治理(權限、版本、灰度發布)。
class StrategyRegistry {
constructor() { this.map = new Map(); }
register(key, strategy) {
if (this.map.has(key)) throw new Error(`策略重複:${key}`);
this.map.set(key, strategy);
}
get(key) { return this.map.get(key); }
has(key) { return this.map.has(key); }
keys() { return [...this.map.keys()]; }
}
// 使用
const registry = new StrategyRegistry();
registry.register('percentage', (amount, { rate }) => amount * rate);
// ...更多註冊
function runStrategy(key, ...args) {
const s = registry.get(key);
if (!s) throw new Error(`找不到策略:${key}`);
return s(...args);
}
例外處理、預設策略與回退設計
預設策略(Default):當 key 無效時回到預設(例如 flat:0)。
回退(Fallback):主要策略失敗(如 API 掛了)時,退回備援策略。
防衛式編程:對輸入做範圍驗證(如折扣率 0~1)。
async function runWithFallback(primary, fallback) {
try { return await primary(); }
catch (e) { return fallback(); }
}
效能、測試與維護性
效能:策略模式本身開銷極低;真正的成本在策略內部算法。
單元測試:對每個策略分開測,Context 測「是否正確呼叫策略」。
可觀測性:記錄策略 key、耗時、錯誤率,便於灰度與回退。
版本控制:策略以檔案區隔;避免一個檔案內塞爆多策略。
文件化:針對每個策略說明用途、前置條件、參數範圍。
從 if/else 地獄重構到策略模式(逐步實操)
function discount(amount, user) {
if (user.level === 'VIP') {
return amount * 0.2;
} else if (user.level === 'GOLD') {
return amount * 0.1;
} else if (user.coupon && user.coupon.type === 'FLAT') {
return Math.min(user.coupon.value, amount);
} else {
return 0;
}
}
步驟:
找出變化點:VIP/GOLD/COUPON 規則是可替換算法。
抽共同介面:每種回傳折扣金額。
移除條件分支:改成 key→策略的 Map。
建立選擇器:由 user 狀態推導策略 key(或由外部設定)。
加入預設與回退。
重構後:
const discountStrategies = {
VIP: (amount) => amount * 0.2,
GOLD: (amount) => amount * 0.1,
COUPON_FLAT: (amount, { value }) => Math.min(value, amount),
NONE: () => 0,
};
function pickKey(user) {
if (user.level === 'VIP') return 'VIP';
if (user.level === 'GOLD') return 'GOLD';
if (user.coupon?.type === 'FLAT') return 'COUPON_FLAT';
return 'NONE';
}
function discount(amount, user) {
const key = pickKey(user);
const fn = discountStrategies[key] ?? discountStrategies.NONE;
const params = key === 'COUPON_FLAT' ? { value: user.coupon.value } : undefined;
return fn(amount, params);
}
常見錯誤與雷點
1. 策略介面不一致:有的回傳折扣金額,有的回傳折後價 → 呼叫端混亂。
解:在 JSDoc 或說明文件明確規範輸入/輸出。
2. Context 偷看策略內部細節:在 Context 中寫死 instanceOf 某策略 的特例處理。
解:策略差異應由策略內部解決;Context 只管呼叫。
3. 策略混入共享可變狀態:多策略互相改同一個物件,導致不可預期。
解:策略盡量純函數,必要狀態透過參數傳入。
4. 預設策略/回退缺失:key 錯誤直接爆炸。
解:提供 defaultKey 或安全回退;在註冊時驗證 key。
5. 策略瘋狂增生:每個小差異都切出一個策略,導致維護困難。
解:用參數化策略(同一策略內接受參數),或合併相似策略。
6. 把策略變成 switch 集合:在策略內再用 switch 分支實作多策略,等於繞回原點。
解:一個策略檔案/函數代表一種算法;不要在策略內再分派。
7. 選擇邏輯到處散落:不同地方各自決定用哪個策略。
解:集中到選擇器/工廠或Registry;方便治理與灰度。
8. 錯誤處理牽出耦合:策略遇錯誤直接 alert 或存取全域 UI。
解:策略只回傳結果或丟出錯誤;UI/記錄在外層做。
9. 測試只測 Context:忽略策略本身的單元測試。
解:每個策略單獨測;Context 只測「是否正確使用策略」。
10. 把 State 當 Strategy:需要狀態流轉但你用策略硬做,結果各策略間需互知前後狀態。
解:遇到狀態機問題,改用 State 或 XState/Redux 等方案。
11. 非同步策略忘了 await:回傳 Promise 卻當成同步值用。
解:統一策略為同步或非同步;非同步就讓 Context 全部 await。
12. 命名含糊:strategy1/2/3 沒有語意。
解:語意化命名,如 percentage/flat/threshold、priceAsc。
問題集
Q1:我的規則只有兩三種,值得上策略模式嗎?
A:若短期不會擴充,也不需要動態切換,直接寫清楚的條件判斷即可;不要過度設計。
Q2:策略之間需要共享設定(例如稅率表)怎麼辦?
A:把設定透過建構子或參數注入;或建立「不可變設定物件」傳入,避免策略互改。
Q3:要不要搭配 Factory?
A:當選擇策略的規則變複雜、需驗證/記錄/灰度時,Factory/Selector 很有幫助。
Q4:前端專案用哪種寫法好?class 還是函數?
A:偏好函數式更輕量;若有 OOP 架構或需要可替換實例狀態,class 也很合適。
結語
策略模式的價值,在於把「可替換的做法」標準化,讓擴充變簡單、測試更聚焦、部署更安全。
從小處開始——把一段雜亂的 if/else 抽成兩三個策略,再慢慢長大到 Registry 與 Selector。等你下次要上新規則時,就會慶幸當初做了這個抽離。
延伸閱讀推薦:
javaScript設計模式 : Factory Method (工廠方法)
javaScript設計模式 : Abstract Factory(抽象工廠)
javaScript設計模式 : Builder(建造者模式)
javaScript設計模式 : Prototype(原型)
javaScript設計模式 : Singleton (單例模式)
javaScript設計模式 : Adapter(轉接器模式)
javaScript設計模式 : Bridge( 橋接模式 )
javaScript設計模式 : Composite(組合模式)
javaScript設計模式 : Decorator(裝飾者)
javaScript設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
