策略模式(Strategy)

核心要点

  • 定义:把一组做同类事情的算法分别封装成独立的单元,让它们对外长得一样(入参、返回值一致),从而可以随时互相替换。
  • 一句话理解:同一个入口,传进来的「类型 / 参数」不同,就命中不同的处理策略。
  • 三个关键词怎么落地:
    • 「算法」:任何一段业务计算逻辑,比如运费怎么算、积分怎么给、字段怎么校验;
    • 「封装」:把每种逻辑单独抽成一个函数(或类),各管各的;
    • 「可替换」:用一张「key → 策略」的映射表来选择策略,而不是在一个函数里堆 if-else。
  • 好处:干掉成片的 if-else / switch;每个策略可以单独复用、单独测试;新增一种情况只加一条映射,不改老代码。
  • 背后的两条设计原则:
    • 单一职责:一个函数只处理一种算法,出 Bug 能精确定位,修一个不会连累其他;
    • 开放封闭:对扩展开放(加新策略)、对修改封闭(分发逻辑和老策略不动),测试只需要覆盖新增的那条。
  • 重构路线:if-else 大函数 → 先把每段逻辑拆成独立函数(解决「执行」)→ 再用对象映射取代条件判断(解决「分发」)。
  • 工程上的封装:策略表和分发函数放进一个模块,只 export 对外的方法,调用方 import 后按 key 调用。
  • 常见应用:价格 / 运费 / 折扣计算、表单校验规则、不同环境的上报方式、动画缓动函数、权限或路由的分支处理。
  • 补充注意点:查表前要处理未知 key(否则会报 xxx is not a function,普通对象还可能命中原型上的 toString 之类的方法),可以用 Object.hasOwn、Object.create(null) 或 Map。

先看一个最小例子

商城会员下单后按等级返积分:白银会员 1 倍、黄金会员 1.5 倍、钻石会员 2 倍。按等级去查一个对象就能拿到对应的计算方式:

// points.js
const pointRules = {
  silver: (spent) => Math.floor(spent * 1),
  gold: (spent) => Math.floor(spent * 1.5),
  diamond: (spent) => Math.floor(spent * 2),
};

const earnPoints = (tier, spent) => pointRules[tier](spent);

console.log(earnPoints('gold', 300)); // 450

// 规则表留在模块内部,只把计算入口暴露出去
export default { earnPoints };

别的文件这样用:

import points from './points.js';

points.earnPoints('diamond', 88); // 176

这里已经包含了策略模式的全部要素:

角色在例子里对应什么
一组策略(算法)pointRules 里的三个函数,签名都是 (spent) => number
选择策略的依据会员等级 tier
上下文 / 分发者earnPoints,按等级找到策略并执行
封装边界模块只导出 earnPoints,规则表对外不可见

好处很直接:没有一条 if,规则也都能单独拿出来复用。下面用一个更接近真实业务的场景,完整走一遍「从 if-else 到策略模式」的重构。

实战场景:结算页的运费计算

结算页要根据用户选择的配送方式算运费,规则如下:

配送方式标识运费规则
普通快递standard订单满 99 元包邮,不满收 8 元
次日达express固定 15 元,订单满 199 元运费减半
同城闪送sameDay首重 1kg 收 12 元,之后每 kg(向上取整)加 4 元
门店自提pickup免运费

先给每种方式起一个英文标识(上表第二列),代码里就用这个标识来区分。

直觉写法:一个函数包办一切

// 根据配送方式和订单信息返回运费
function calcShipping(method, order) {
  const { amount, weight } = order;

  // 普通快递
  if (method === 'standard') {
    if (amount >= 99) return 0;
    return 8;
  }

  // 次日达
  if (method === 'express') {
    if (amount >= 199) return 15 / 2;
    return 15;
  }

  // 同城闪送
  if (method === 'sameDay') {
    const extraKg = Math.max(0, Math.ceil(weight) - 1);
    return 12 + extraKg * 4;
  }

  // 门店自提
  if (method === 'pickup') {
    return 0;
  }
}

const order = { amount: 120, weight: 2.3 };
['standard', 'express', 'sameDay', 'pickup'].map((m) => calcShipping(m, order));
// [0, 15, 20, 0]

功能上完全正确,跑起来也没问题。问题出在「以后」。

这种写法哪里不好

问题一:一个函数干了四份活(违背单一职责)

calcShipping 里塞了四套互不相干的计费逻辑,后果是:

  • 牵一发动全身:任何一个分支写崩了(比如手滑把 amount 写成 amout),整个运费函数都可能出错,四种配送方式一起受影响。
  • 定位困难:线上运费算错时,要在一个越来越长的函数里逐个分支排查。
  • 没法单独复用:别的页面(比如商品详情页的「预估运费」)只想用同城闪送的算法,只能把那段代码复制出去。复制品和原逻辑从此分叉,原规则一改,复制的地方就得跟着找出来再改一遍。

看到一个函数里装着多块独立逻辑,第一反应就应该是:拆。

问题二:加需求只能改老函数(违背开放封闭)

假设运营又提了一个需求:生鲜商品支持「冷链配送」,固定 25 元,订单满 300 元免运费。在现有写法下,只能打开 calcShipping,在末尾再追加一段:

function calcShipping(method, order) {
  const { amount, weight } = order;

  if (method === 'standard') {
    if (amount >= 99) return 0;
    return 8;
  }
  if (method === 'express') {
    if (amount >= 199) return 15 / 2;
    return 15;
  }
  if (method === 'sameDay') {
    const extraKg = Math.max(0, Math.ceil(weight) - 1);
    return 12 + extraKg * 4;
  }
  if (method === 'pickup') {
    return 0;
  }

  // 新增:冷链配送
  if (method === 'coldChain') {
    if (amount >= 300) return 0;
    return 25;
  }
}

改动看起来只有几行,但被修改的是所有配送方式共用的那个函数体。从测试的角度看,谁也不能保证这次没碰坏前面的分支,于是只能把四种老的配送方式连同新功能一起完整回归一遍。需求每加一次,这份不必要的回归成本就付一次。

新增冷链配送时,if-else 写法要改动共用函数体、全量回归;策略表写法只新增一行映射,只测新策略

重构第一步:每种算法各自独立

先处理问题一。把四段计费逻辑分别搬进自己的函数,原来的大函数只负责判断该调谁:

// 普通快递
function standardFee({ amount }) {
  return amount >= 99 ? 0 : 8;
}

// 次日达
function expressFee({ amount }) {
  return amount >= 199 ? 7.5 : 15;
}

// 同城闪送
function sameDayFee({ weight }) {
  return 12 + Math.max(0, Math.ceil(weight) - 1) * 4;
}

// 门店自提
function pickupFee() {
  return 0;
}

function calcShipping(method, order) {
  if (method === 'standard') return standardFee(order);
  if (method === 'express') return expressFee(order);
  if (method === 'sameDay') return sameDayFee(order);
  if (method === 'pickup') return pickupFee(order);
}

现在每个函数的职责一目了然:

函数职责
standardFee计算普通快递运费
expressFee计算次日达运费
sameDayFee计算同城闪送运费
pickupFee计算门店自提运费
calcShipping根据配送方式,把订单交给对应的计算函数

收益立刻就有了:

  • 哪种配送方式算错了,就去看哪个函数,不用在一大坨代码里翻找;
  • 商品详情页要预估闪送运费,直接 import { sameDayFee } 调用即可,规则以后改了也只改这一处;
  • 每个计费函数都是纯函数,可以单独写单元测试。

回头看整个流程,其实只有两个动作:

选出该用哪种算法(分发)  ──>  用这种算法算出结果(执行)

第一步重构把「执行」拆了出去,各种算法之间已经互不干扰。但「分发」还是那串 if,新增冷链配送时依然要改 calcShipping:先写一个 coldChainFee,再往 calcShipping 里补一行 if (method === 'coldChain') return coldChainFee(order)。函数体照样被动了,开放封闭的问题没有解决。

重构第二步:用映射表取代条件分支

那一串 if 本质上在表达什么?其实只是「配送方式标识 → 计费函数」这一层对应关系。而 JavaScript 里表达「键 → 值」对应关系最自然的工具,就是对象(或 Map)。

把所有策略收进一张表:

const shippingStrategies = {
  standard: standardFee,
  express: expressFee,
  sameDay: sameDayFee,
  pickup: pickupFee,
};

分发函数退化成一次查表:

function quoteShipping(method, order) {
  const strategy = shippingStrategies[method];
  if (!strategy) {
    throw new Error(`未知的配送方式:${method}`);
  }
  return strategy(order);
}

(原始的 if-else 版本遇到没有定义的配送方式会默默返回 undefined,这种错误往往要到页面上显示出 NaN 才会被发现,所以这里顺手加了一个显式的报错。)

这时候再接冷链配送的需求,只需要新增一条映射,quoteShipping 和已有的四个策略一个字符都不用碰:

function coldChainFee({ amount }) {
  return amount >= 300 ? 0 : 25;
}

shippingStrategies.coldChain = coldChainFee;

quoteShipping('coldChain', { amount: 120, weight: 2.3 }); // 25

提测的时候也就有底气了:这次只是加了一种配送方式,老的计费逻辑和分发逻辑都没改,只测冷链这一个新功能点就够。

策略模式的结构:调用方把配送方式交给分发函数,分发函数查策略表找到对应的计费函数执行,新增冷链只需加一行映射

回到定义

整个重构走完,再看策略模式的定义:

定义一系列算法,把它们逐个封装起来,并让它们可以相互替换。

刚读这句话时容易觉得抽象:算法指什么?怎么封装?替换又是怎么做到的?对照上面的运费例子就具体了:

定义里的词在运费例子里的样子
一系列算法五种配送方式各自的计费规则。「算法」不一定是排序、搜索那种,任何一段功能逻辑都算
封装把每种规则抽成一个独立的计费函数(standardFee、expressFee……)
相互替换所有计费函数签名一致(都接收 order、返回数字),调用方换一个 key 就换了一种算法;选择过程交给映射表,而不是硬写 if-else

也就是说,「可替换」是建立在「封装」之上的:先把算法拆干净、接口统一,再找一个比条件判断更好的映射方式来做选择。

策略模式的经典形态(面向对象写法)

在 GoF 的原始描述里,策略模式通常有三个角色:

  • Strategy(策略接口):规定所有策略必须提供的方法,比如 calc(order);
  • ConcreteStrategy(具体策略):实现这个接口的各个类;
  • Context(上下文):持有一个策略对象,把具体计算委托给它,并且可以在运行时更换策略。

用 class 写出来是这样:

class StandardShipping {
  calc({ amount }) {
    return amount >= 99 ? 0 : 8;
  }
}

class SameDayShipping {
  calc({ weight }) {
    return 12 + Math.max(0, Math.ceil(weight) - 1) * 4;
  }
}

class Checkout {
  constructor(shipping) {
    this.shipping = shipping;
  }
  setShipping(shipping) {
    this.shipping = shipping; // 运行时切换策略
  }
  total(order) {
    return order.amount + this.shipping.calc(order);
  }
}

const cart = { amount: 60, weight: 3.2 };
const checkout = new Checkout(new StandardShipping());
checkout.total(cart); // 68

checkout.setShipping(new SameDayShipping());
checkout.total(cart); // 84

JavaScript 里函数是一等公民,一个策略往往就是一个函数,所以前面「对象 + 函数」的写法是同一个思想的轻量版本:对象映射充当了 Context 选择策略的那部分职责,函数本身就是 ConcreteStrategy。两种写法任选,策略需要携带状态或多个方法时用 class 更合适,只有一个计算逻辑时用函数更简洁。

工程实践中的几个细节

1. 把策略表封装进模块

策略表属于实现细节,不应该让调用方随意读写。通常的做法是放在一个模块里,只导出需要的方法:

// shipping.js
const shippingStrategies = {
  standard: ({ amount }) => (amount >= 99 ? 0 : 8),
  express: ({ amount }) => (amount >= 199 ? 7.5 : 15),
  sameDay: ({ weight }) => 12 + Math.max(0, Math.ceil(weight) - 1) * 4,
  pickup: () => 0,
};

export function quoteShipping(method, order) {
  if (!Object.hasOwn(shippingStrategies, method)) {
    throw new Error(`未知的配送方式:${method}`);
  }
  return shippingStrategies[method](order);
}

// 需要开放扩展时,提供一个注册入口而不是把整张表导出去
export function registerShipping(method, strategy) {
  shippingStrategies[method] = strategy;
}
// checkout.js
import { quoteShipping, registerShipping } from './shipping.js';

registerShipping('coldChain', ({ amount }) => (amount >= 300 ? 0 : 25));
quoteShipping('coldChain', { amount: 320, weight: 5 }); // 0

2. 小心未知 key 和原型链

直接 shippingStrategies[method] 查表有个隐患:普通对象会继承 Object.prototype 上的属性。如果 method 来自接口或 URL 参数,传进来一个 'toString',查到的是一个真实存在的函数,不会进入「未知配送方式」的分支,而是返回一个莫名其妙的字符串。几种规避方式:

做法写法
判断自有属性Object.hasOwn(table, key)(旧环境用 Object.prototype.hasOwnProperty.call(table, key))
创建无原型对象const table = Object.assign(Object.create(null), { ... })
改用 Mapconst table = new Map([['standard', standardFee], ...]),用 table.get(key) 取值

3. 策略模式不只用来算价格

只要是「同一件事,有多种做法,按条件选一种」,都可以套这个结构。表单校验就是一个经典例子:

const validators = {
  required: (value) => (value.trim() === '' ? '不能为空' : ''),
  minLength: (value, n) => (value.length < n ? `至少 ${n} 个字符` : ''),
  phone: (value) => (/^1\d{10}$/.test(value) ? '' : '手机号格式不对'),
};

// rules 形如 [['required'], ['minLength', 6]],按顺序执行,遇到第一个错误就返回
function validate(value, rules) {
  for (const [name, ...args] of rules) {
    const message = validators[name](value, ...args);
    if (message) return message;
  }
  return '';
}

validate('', [['required']]); // '不能为空'
validate('abc', [['required'], ['minLength', 6]]); // '至少 6 个字符'

新增一种校验规则(比如邮箱)只需往 validators 里加一项,validate 不用改。其他常见场景还有:埋点在不同环境下选择 sendBeacon / 图片打点 / fetch 上报,动画库里 linear、ease-in 等缓动函数,按用户角色渲染不同的操作按钮等。

优点与代价

优点代价 / 注意
消除大量 if-else / switch,分发逻辑一眼看懂策略数量多时,文件和函数会变多,需要合理组织目录
每个策略可以单独复用、单独测试调用方要知道有哪些 key 可用(可以用常量或 TypeScript 联合类型约束)
新增策略不改老代码,回归范围小只有两三个且基本不会再变的分支时,直接写 if 反而更直白,不必为了模式而模式
策略可在运行时动态切换或注册策略之间如果需要共享大量数据,接口设计要更小心

面试速答模板

策略模式就是把一组做同一类事情的算法分别封装起来,并且让它们接口一致、可以互相替换,调用时根据条件选一个来执行。在 JS 里最常见的写法是用一个对象做映射:key 是类型,value 是对应的处理函数,分发函数只做一次查表。比如运费计算,原本一个函数里用 if-else 处理普通快递、次日达、闪送、自提四种规则,既违背单一职责,一个分支出错会影响全部、也没法单独复用,又违背开放封闭,每加一种配送方式都得改这个函数、测试要整体回归。重构分两步:先把每种规则拆成独立函数,解决执行层面的耦合;再用对象映射替代 if-else,解决分发层面的问题,之后新增配送方式只需要往表里加一项,测试也只测新增的部分。实际使用时我会把策略表封装在模块里只导出入口方法,并且对未知 key 做兜底,用 Object.hasOwn 或 Map 避免命中原型上的属性。表单校验、价格折扣、埋点上报方式选择都是它的典型应用。

Last Updated:
Contributors: leeguooooo