接手一个上年写的老模块时,我习惯先搜代码里出现了几个switch (type)。同一个函数里 switch 超过三次,后面每加一个分支,基本就要拉着几个人一起加班。JavaScript 设计模式里的策略模式,就是这类扩容问题的直接解药:把每个分支里承担不同“做法”的代码各自拎出来,放进独立的函数或对象,再用一张映射表把名字和实现挂起来,运行时谁需要哪个,就把哪个实现交出去。
这篇内容属于设计模式系列的续写,专门把策略模式摊开讲。我们不讲教科书上的抽象定义,而是直接按 JavaScript 的实际写法走一遍:从最普遍的 if/else 痛点切入,对比三种实现方案,再用一个运费计算模块完整重构,最后把我在真实项目里踩过的边界和坑一起列出来。不管你是刚学设计模式的前端新人,还是已经在业务代码里被各种分支折磨过一阵子的开发者,这篇文章应该都能让你少走几步弯路。
1. 策略模式要收拾的,通常是那几层没人敢动的 if/else
1.1 先看一段真实的运费计算“千层饼”
很多讲策略模式的文章都喜欢拿“超市促销打折”举例,但我实际工作中最常撞见这类结构的地方,反而是各种算钱、算费、算分值的模块。拿一个很典型的运费试算场景来说,刚开始代码还算清爽,后来产品和运营加了一堆规则,最终一个函数会长成下面这种样子:
function calcShipping(order) { if (order.region === 'remote') { return order.weight * 30; } else if (order.vipLevel >= 2) { if (order.total >= 88) { return 0; } return order.total > 300 ? 10 : 20; } else if (order.coupon === 'freeShipping' || order.total >= 399) { return 0; } // 再往下还有一堆平台活动分支 return 12; }这段代码最大的问题不是缩进,而是所有运费规则都被揉进了同一个函数体。产品下个迭代要加“偏远地区中的指定县域走特殊价格”,你只能继续在这个函数里加一层判断;QA 要回归,前面所有场景都要重新点一遍;新来的同事接这个需求,光看 if 分支就得花半天。
时间一长,这个函数就变成团队里没人敢碰的“地雷区”。有一次我数了一下,光一个结算页的优惠分摊模块,类似的分支判断居然复制粘贴了三个版本,分别放在前端、Node 端和一个定时脚本里。逻辑稍微不一致,对账就会出问题。
1.2 策略模式真正划分的边界:算法集合与调用时机
策略模式的核心思想其实非常朴素:把“做同一件事的多种做法”分别封装成独立的策略,再让调用方在运行时选择并传入其中一个。它把原来散落在 if/else 各分支里的算法块,收敛成一组可独立命名、独立测试、独立替换的策略。
这里涉及两个经典角色:
- 策略(Strategy):每种具体做法,封装为函数或对象。比如“普通运费算法”“会员免邮算法”“偏远地区算法”。
- 上下文(Context):保存当前使用的策略引用,并对外提供统一执行入口。调用方只需要知道 Context,不必关心底层到底是哪套算法。
JavaScript 有一个天然优势:函数是一等对象。策略不一定要定义成 class,一个普通函数、一个对象方法,甚至一个箭头函数,都可以作为策略存在。这也是为什么很多 Java/C++ 设计模式书里的策略模式案例搬到 JS 里会显得笨重——书里强调必须先定义策略接口、再创建一堆策略类、最后在 Context 里持有并调用,而 JS 里直接用对象映射表就能完成同样的事。
1.3 这里为什么先说“可替换”而不是“自动选择”
先提醒一个重要边界:策略模式本身并不负责“该用哪个策略”。很多网上的例子会把一个switch (type)直接改成一个strategies[type](),看起来 if/else 没了,但实际上只是把判断从语句变成了对象属性访问,真正决定 type 的代码仍然在别处。
经典的策略模式里,“选择哪种算法”是客户端的事,或者由上层配置决定。策略模式解决的是“算法可以互相替换且调用方式不变”的问题,而不是“如何从一堆规则里自动挑出最合适那一个”。如果业务里最复杂的是“怎么选出策略”而不是“策略内部怎么算”,那你要考虑的就不只是策略模式了,后面第 4 章会专门展开。
2. 同一套思想,三条落地路线:class、对象映射、Map 注册表
2.1 基于 class 的经典封装:考试作业与类移植场景都用得上
先看最标准、最接近 GoF 原意的写法。假设运费策略有三种:普通、VIP、包邮。
class StandardFreight { calculate(order) { return order.weight * 2 + 8; } } class VIPFreight { calculate(order) { if (order.vipLevel >= 3) { return 0; } return order.total >= 88 ? 12 : 18; } } class FreeShippingFreight { calculate() { return 0; } } class FreightContext { constructor(strategy) { this.strategy = strategy; } setStrategy(strategy) { this.strategy = strategy; } calculate(order) { if (!this.strategy) { throw new Error('没有指定运费策略'); } return this.strategy.calculate(order); } } // 使用 const context = new FreightContext(new StandardFreight()); const fee1 = context.calculate(order1); context.setStrategy(new VIPFreight()); const fee2 = context.calculate(order2);这种实现的好处是结构清晰,非常适合课堂作业、面试手写、或者你从 Java 项目迁移过来的第一阶段。每个策略类都显式暴露了calculate方法,Context 的职责也很单纯:拿着策略对象,执行策略。
但问题是,JS 里每个策略类都没有内部状态的话,每个实例都长得一模一样。你每切换一个策略就要new一次,代码写起来比较啰嗦。假如一个运费策略只是纯计算函数,硬套 class 反而增加了抽象成本。
2.2 对象字面量写法:大多数中小页面推荐的入门选择
在真实前端项目里,我更常用也更推荐的是对象字面量映射。
const freightStrategies = { standard(order) { return order.weight * 2 + 8; }, vip(order) { return order.vipLevel >= 3 ? 0 : order.total >= 88 ? 12 : 18; }, free() { return 0; }, }; function calcFreight(type, order) { const strategy = freightStrategies[type]; if (!strategy) { throw new Error(`未知的运费策略: ${type}`); } return strategy(order); } calcFreight('vip', currentOrder);这种写法的最大优点是把“策略名”和“策略实现”放在同一个地方,新增一种策略就加一个键值对,删除一种策略就删掉一行,代码量比 class 版本少了一半以上。而且它不需要初始化一个 Context 对象再去调用,直接calcFreight('vip', order)就行。
只要策略数量不超过十个、策略之间没有复杂联动,这种写法是最高性价比的选择。很多同学会说“这不是一个 Map 吗,算什么设计模式”,但策略模式从来不是说必须写多少个类,而是看是否符合“封装多种算法、互相可替换、运行时选择”这个核心结构。对象字面量映射完全满足。
2.3 Map 注册表:适合多人扩展的插拔式仓库
当系统大到一定程度,策略会从不同模块、不同团队甚至不同文件里添加进来,对象字面量就会遇到点问题:
- 多人同时改一个对象,git 冲突概率增加;
- 没法在注册时做重复名检查;
- 策略文件可能被拆到不同目录,主文件 import 列表会越来越长。
这时候可以把对象字面量升级成 Map + 注册函数:
const freightStrategies = new Map(); function registerFreight(name, handler) { if (typeof handler !== 'function') { throw new Error(`运费策略 ${name} 必须是一个函数`); } if (freightStrategies.has(name)) { console.warn(`重复注册运费策略: ${name}`); } freightStrategies.set(name, handler); } function getFreight(name) { if (!freightStrategies.has(name)) { throw new Error(`未知运费策略: ${name}`); } return freightStrategies.get(name); } function calcShipping(order) { const strategy = getFreight(order.shippingType); return strategy(order); } registerFreight('standard', (order) => order.weight * 2 + 8); registerFreight('vip', (order) => order.vipLevel >= 3 ? 0 : order.total >= 88 ? 12 : 18 ); registerFreight('free', () => 0);这种模式把“注册”和“执行”拆开了。策略模块可以散布在多个文件里,各自负责registerFreight,核心计算入口只维护一个calcShipping。更重要的是,它天然给了你一个弹性的扩展点:以后不管来多少策略,只要在项目初始化阶段完成注册,主链路代码一行都不用改。
2.4 三条路线怎么选
| 实现路线 | 代码量 | 适合场景 | 主要短板 |
|---|---|---|---|
| class + Context | 大 | 学院派作业、多人协作需要强制结构 | 对纯函数场景来说太啰嗦 |
| 对象字面量 | 小 | 中小项目、策略数量有限 | 全局集中,多人并发改一个对象易冲突 |
| Map 注册表 | 中 | 中大型项目、策略分散在多模块 | 需要额外管理制度,否则命名会乱 |
我的真实选择标准很简单:策略少于 5 个且不会频繁扩展时,直接用对象字面量;策略可能被不同业务线各自添加时,第一天就上 Map 注册表;团队里有强类型偏好或者是从 Java 转过来的,class 方式作为过渡也能接受,但不要为了“模式感”硬上。
3. 一次实战重构:把多规则运费计算模块从 if/else 里解出来
3.1 重构前的运费条件与选择器
第 1 章那段“千层饼”函数其实还是简化版,真实业务里运费规则往往包含更多维度:订单金额、包裹重量、会员等级、收货区域、支付方式、仓库类型。有一回我接手一个电商项目,运费规则已经到了 4 种:
standard:普通快递,首重一公斤 12 元,续重每公斤 2 元;remote:偏远地区固定 30 元;vipFree:VIP 3 级以上免运费,3 级以下满 88 元免运费,不满则收 12 元;cashOnDelivery:货到付款订单,普通运费基础上再加 8 元服务费。
模块当前只有一个calcShipping(order)函数,里面先用if判断地区、再用if判断会员、再用if判断支付方式,三层嵌套。重构目标不是把运费规则里的 if 全部消灭,而是让主链路不再因为规则增加而持续膨胀。
3.2 重构成策略库的关键步骤
第一步,把所有策略各自定义成独立函数:
// 策略定义 const shippingStrategies = new Map(); function registerShipping(name, handler) { shippingStrategies.set(name, handler); } registerShipping('standard', (order) => { const firstKgPrice = 12; const extraKg = Math.max(0, Math.ceil(order.weight - 1)); return firstKgPrice + extraKg * 2; }); registerShipping('remote', (order) => { return order.weight > 0 ? 30 : 15; }); registerShipping('vipFree', (order) => { if (order.vipLevel >= 3) return 0; return order.total >= 88 ? 0 : 12; }); registerShipping('cashOnDelivery', (order) => { const base = order.total >= 99 ? 0 : 12; return base + 8; });第二步,让外部只依赖一个统一执行入口:
export function calcShipping(order) { const handler = shippingStrategies.get(order.shippingType); if (!handler) { throw new Error(`运费规则不存在: ${order.shippingType}`); } const fee = handler(order); console.log('[shipping]', order.shippingType, order.orderNo, fee); return fee; }第三步,把决定shippingType的选择逻辑独立到一个 selector 模块:
const selectors = [ { name: 'vipFree', match: (order) => order.vipLevel >= 3 }, { name: 'remote', match: (order) => order.region === 'remote' }, { name: 'cashOnDelivery', match: (order) => order.payType === 'cod' }, { name: 'standard', match: () => true }, ]; export function selectShippingType(order) { const rule = selectors.find((selector) => selector.match(order)); if (!rule) { throw new Error(`找不到适合订单 ${order.orderNo} 的运费规则`); } return rule.name; }业务侧最终调用:
const shippingType = selectShippingType(order); const fee = calcShipping({ ...order, shippingType });重构完成后,calcShipping从曾经 140 多行的嵌套函数,瘦身成了 10 行左右的注册表查询和执行器。以后新增一种“新用户首单免运费”,只需要注册一个firstOrderFree策略,并在selectors数组里加一行规则,完全不用打开calcShipping这个核心文件。
3.3 补充测试锁住原有行为
重构一旦涉及老代码,最怕的就是“我以为等价,结果变了”。所以我在重构运费模块时,会先用现成的数据把重构前所有运费输出结果记下来,再让重构后的计算逻辑跑一遍同样的用例,然后对拍。
测试主要覆盖三类:
describe('shipping strategies', () => { it('standard: 首重 + 续重正确', () => { const fee = calcShipping({ shippingType: 'standard', weight: 3 }); expect(fee).toBe(16); // 12 + 2 * 2 }); it('vipFree: VIP3 级直接免运费', () => { const fee = calcShipping({ shippingType: 'vipFree', vipLevel: 3, total: 10 }); expect(fee).toBe(0); }); it('未知策略必须抛错', () => { expect(() => calcShipping({ shippingType: 'notExist' })).toThrow(); }); });只要这些测试在重构前后都保持绿色,就能比较安全地深夜上线。就我个人的实践来说,策略模式的代码本身不难写,难的是把老逻辑的行为一丝不差地保留下来,所以测试用例一定要在动手重构前先写好。
4. 策略模式最常见的三个误用,以及我踩过的边界
4.1 别让 Context 兼任“选策略”的裁判
很多人会把策略模式和“消灭所有 if/else”画上等号,于是重构时把判定逻辑也塞进 Context。
// 错误示范:Context 内部偷偷做了决策 class FreightContext { constructor(order) { this.order = order; this.strategy = this._decideStrategy(); } _decideStrategy() { if (this.order.vipLevel >= 3) return new VIPFreight(); if (this.order.total >= 88) return new FreeShippingFreight(); return new StandardFreight(); } }问题在于,产品下个迭代如果增加一个“同城急送统一 15 元”的规则,你还是要回来改_decideStrategy,Context 一样没逃出不断膨胀的命运。策略模式把算法本身从大函数里解放出来了,但“如何选算法”的复杂度并没有消失,只是被移到了别处。
正确姿势是承认“策略选择”是一层独立业务逻辑,把它放到 Context 之外,由专门的 selector、规则表或后端下发的规则配置负责。策略模式只管执行,selector 只管选路,两者各司其职,代码才不会从一个泥潭挪进另一个泥潭。
4.2 策略别按条件组合去笛卡尔积
第二个坑是不少人会把策略拆得过于“原子化”,导致策略数量爆炸。
比如运费本身有两个维度:一个是“是否偏远地区”,一个是“是否 VIP”。如果为了完全避免 if,新手可能注册出四个策略:remoteVip、remoteNormal、normalVip、normalNormal。当第三个维度“是否货到付款”再加入时,策略数量直接翻倍成八个。
这不是策略模式,这是排列组合。真正值得封装成独立策略的,应该是那些能独立描述、独立计价、或独立变更的“算法”,例如“按重量计算”“按固定价格计算”“按订单金额满减计算”。而地区、会员这些条件,应该尽量作为订单参数传入策略,或者通过规则优先级在 selector 层决定。
如果一个策略内部确实需要结合多个维度,可以在策略内部做决策,这不丢人。设计模式是帮你控制复杂度,不是逼你用更高的复杂性去换表面的“无 if”。
4.3 后端新 value 来了,兜底逻辑必须存在
还有一个我在生产环境真实踩过的坑:策略表通过 type 字段取值,后端某天新增了一个运费类型,但前端联调时没有同步升级,于是所有这类订单直接抛错,页面上运费显示一片空白。
策略模式虽然让“未知策略”更容易被发现,但也让这类错误更致命——以前是某个 if 分支不匹配后走默认逻辑,现在 Map 里找不到 key 就直接抛异常。解决办法很简单,注册一个兜底策略,或者在 get 不到时走默认算法:
registerShipping('default', (order) => { // 最保守的计费方式,保证用户不会看不到运费 return 10; }); export function calcShipping(order) { const handler = shippingStrategies.get(order.shippingType) || shippingStrategies.get('default'); return handler(order); }同时要在日志里把未命中的order.shippingType记录下来,方便及时补策略。兜底不是绕过问题,而是让系统在异常情况下仍然可用。
4.4 策略模式、状态模式、责任链要分清
因为标题是“设计模式(二)”,我顺便提一句易混淆的模式边界,对理解策略模式很有帮助。
策略模式和状态模式在代码结构上非常像,都是“持有某个对象/函数,调用时委托给它”。但语义完全不同:状态模式针对的是“对象内部状态变化导致行为变化”,状态之间会相互流转,切换是自动的、有条件的;策略模式则由外部主动指定使用哪种策略,策略之间一般互不感知、互不切换。
责任链则解决的是“多个处理器依次尝试,直到某个能处理为止”。如果业务场景是“这台订单先看是否满足 A 活动,不满足再看 B 活动,直到命中”,那更适合用责任链。策略模式一般是一次选一个策略做完,责任链是给一串候选者轮流上手。把这三者区分清楚,你就不会在设计评审时被问到“这里为什么不用状态模式”而支支吾吾。
5. 上线前最容易翻车的细节:注册时序、this 指向与闭包过期
5.1 this 丢失报错:看起来注册了,执行时却 is not a function
策略模式把实现抽到独立对象后,一个很容易踩的坑就是this丢失。
const userStrategy = { threshold: 88, calc(order) { // 这里的 this 必须指向 userStrategy return order.total >= this.threshold ? 0 : 12; }, }; registerShipping('vip', userStrategy.calc); // 坏了当你把userStrategy.calc作为函数直接传给registerShipping,再在calcShipping里执行时,函数的this已经不再指向userStrategy。运行时会提示this.threshold is undefined,计算出来的运费用错逻辑却不容易一眼发现。
有一种情况大家肯定遇到过:在 Vue 项目里明明已经装了 ElementPlus,也配了自动导入,但运行时却提示某个组件“未定义”。很多时候不是组件不存在,而是它被调用的上下文不在预期的作用域链里。策略函数里的this丢失是同一类问题——表面看函数注册成功了,实际执行环境和定义时的预期不一致。
我的建议很简单:策略函数尽量写成纯函数,任何外部配置都通过参数传入。
registerShipping('vip', (order, config) => { return order.total >= config.vipFreeThreshold ? 0 : 12; });这样不仅避免this指向问题,还让策略更容易单元测试。你就是把公司全员叫来 review,也挑不出这种写法的毛病。
5.2 闭包捕获的是配置快照,不是最新配置
另外一个坑来自闭包。有些策略函数会在注册时把配置读一次,然后写死在闭包里。