ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

策略模式在JavaScript中的实践:用映射表替代if/else

策略模式在JavaScript中的实践:用映射表替代if/else 接手一个上年写的老模块时我习惯先搜代码里出现了几个switch (type)。同一个函数里 switch 超过三次后面每加一个分支基本就要拉着几个人一起加班。JavaScript 设计模式里的策略模式就是这类扩容问题的直接解药把每个分支里承担不同“做法”的代码各自拎出来放进独立的函数或对象再用一张映射表把名字和实现挂起来运行时谁需要哪个就把哪个实现交出去。这篇内容属于设计模式系列的续写专门把策略模式摊开讲。我们不讲教科书上的抽象定义而是直接按 JavaScript 的实际写法走一遍从最普遍的 if/else 痛点切入对比三种实现方案再用一个运费计算模块完整重构最后把我在真实项目里踩过的边界和坑一起列出来。不管你是刚学设计模式的前端新人还是已经在业务代码里被各种分支折磨过一阵子的开发者这篇文章应该都能让你少走几步弯路。1. 策略模式要收拾的通常是那几层没人敢动的 if/else1.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 元vipFreeVIP 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 元”的规则你还是要回来改_decideStrategyContext 一样没逃出不断膨胀的命运。策略模式把算法本身从大函数里解放出来了但“如何选算法”的复杂度并没有消失只是被移到了别处。正确姿势是承认“策略选择”是一层独立业务逻辑把它放到 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 闭包捕获的是配置快照不是最新配置另外一个坑来自闭包。有些策略函数会在注册时把配置读一次然后写死在闭包里。
返回列表