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

重构第一步:每种算法各自独立
先处理问题一。把四段计费逻辑分别搬进自己的函数,原来的大函数只负责判断该调谁:
// 普通快递
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), { ... }) |
| 改用 Map | const 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 避免命中原型上的属性。表单校验、价格折扣、埋点上报方式选择都是它的典型应用。
