javaScript設計模式 : Strategy(策略模式)

 


專案一長大,規則就越來越多。折扣怎麼算?列表要怎麼排?金流費率怎麼換?如果你腦中浮現一排 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設計模式 : Facade(外觀模式)

javaScript設計模式 : Flyweight(享元模式)

javaScript設計模式 : Proxy(代理模式)

javaScript設計模式 : Chain of Responsibility(責任鏈)

javaScript設計模式 : Command Pattern(命令模式)

javaScript設計模式 : Interpreter(直譯器)

javaScript設計模式 : Iterator(迭代器)

javaScript設計模式 : Mediator(仲裁者)

javaScript設計模式 : Memento(備忘錄)

javaScript設計模式 : Observer( 觀察者 )

javaScript設計模式 : State(狀態模式)

javaScript設計模式 : Template Method (模板方法)

javaScript設計模式 : Visitor(訪問者模式)


張貼留言 (0)
較新的 較舊