十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

策略模式实战指南:如何用优雅的代码替代if-else

策略模式实战指南:如何用优雅的代码替代if-else 1. 策略模式到底在解决什么问题1.1 从一段真实崩溃的if-else开始我做前端开发这些年最怕看到的不是报错而是一坨由if-else堆起来的业务逻辑。给大家看一个我去年接手的老项目代码它的功能是根据不同会员等级计算订单折扣function calculateDiscount(userLevel, price) { if (userLevel normal) { return price * 0.95; } else if (userLevel silver) { return price * 0.9; } else if (userLevel gold) { return price * 0.8; } else if (userLevel platinum) { if (price 1000) { return price * 0.7 - 50; } else { return price * 0.75; } } else if (userLevel black) { return price 800 ? price * 0.6 : price * 0.65; } else { return price; } }这段代码当时有40多行因为每个等级的折扣规则都不一样platinum和black里面还有嵌套判断和额外的满减逻辑。更麻烦的是需求每周都会变。今天产品说black会员要改门槛明天运营说gold会员要加一个生日双倍积分。每次改需求我都得像拆炸弹一样小心翼翼地定位应该改哪个分支改完之后还得把整个函数从上到下读一遍生怕影响其他等级。这段代码的问题恰恰就是策略模式要解决的。我们不是要消灭条件判断而是要把每一种算法、每一种变化单独拆出来封装好让它们可以互相替换、独立扩展。你把这句话多读几遍策略模式的本质就理解了一半。1.2 策略模式到底是个什么玩意儿策略模式的官方定义很绕定义一族算法将每个算法分别封装起来让它们可以互相替换使得算法的变化不会影响使用算法的客户端。我用大白话翻译一下你的业务系统里有一件事情有多种做法比如“计算价格”不同条件下有不同的算法规格。你不需要在主流程里写满if-else而是把每种做法写成一个独立的小模块然后在用的时候动态选择其中一个模块来执行。这里的关键词有三个第一个是“封装变化”。策略模式的核心思想就是找出程序中“变化的量”把它们从稳定的主干逻辑中分离出去。稳定的部分是“要计算折扣”这个动作变化的部分是“不同等级怎么计算”所以要封装的是后者。第二个是“取代继承”。以前遇到这种情况很多人会写一堆子类比如NormalUser、SilverUser、GoldUser然后每个子类里重写计算方法。但是继承的问题在于它把策略和上下文绑死了。如果你的一个用户在不同场景下需要不同策略你就没法动态切换。组合优于继承这句老话在策略模式里体现得最彻底。第三个是“开闭原则”。你新增一个会员等级时不应该去改动已经稳定运行的主流程代码而是增加一个新的策略类。新增代码而不是修改旧代码这是应对频繁需求变更的最优解。我一向觉得不懂设计模式的人脑子里可能是“顺序执行”懂一点的人开始有“分层”意识真正理解策略模式的人会形成一种“策略思维”——看到一个需求第一反应是这个场景里有哪些可以替换的算法哪些是容易变化的地方这种思维方式比记住一段代码模板值钱得多。2. 三个角色一张图先理清结构2.1 Context策略模式的“总调度”学习策略模式最关键的是先分清三个角色的职责。很多人写不好策略模式不是因为代码复杂而是因为没搞明白谁该干什么。第一个角色是Context上下文也叫环境类。它负责“持有”和执行一种策略但不关心策略内部怎么实现。对于外部调用方来说它只看到Context对外暴露的方法不需要知道背后具体跑的是哪个算法。用一个生活化的例子你出门上班是一种Context。今天天气好你决定骑车下雨了你改坐地铁时间来不及你打车。“你”就是Context每一种出行方式就是一个策略。你的目的永远是“到达公司”怎么去可以随时换这就是Context和Strategy的关系。在代码层面Context一般长这样class DiscountContext { constructor(strategy null) { this.strategy strategy; } setStrategy(strategy) { this.strategy strategy; } calculate(price) { if (!this.strategy) { throw new Error(请先设置策略); } return this.strategy.compute(price); } }注意这里有一个特别容易踩的坑Context里的方法名和Strategy里的方法名最好不要一样。如果都叫calculate读起来会绕调试的时候栈信息也不清晰。我习惯让Context的方法名偏业务化比如calculatePrice而Strategy的方法名偏算法化比如compute或doCalculate。2.2 Strategy定义所有策略的“统一接口”第二个角色是Strategy抽象策略。它其实不是必须用抽象类在JavaScript里它甚至可以是没有任何代码的“协议约束”。它的作用是给所有具体策略定一个规矩你们都必须提供一个名叫compute的方法接收同样的参数返回同样的结果。为什么一定要统一接口因为Context只依赖这个抽象接口不依赖任何具体实现。这样换策略时Context的代码不用动只替换传入的策略实例就行。这就是依赖倒置原则的体现高层模块不应该依赖低层模块二者都应该依赖抽象。在JavaScript这种鸭子类型的语言里我们不需要强制检查类型只要保证每个策略对象都有能力处理好对应的方法就行。你可以用类也可以用普通对象甚至可以用函数式工厂只要接口一致语言层面的自由度非常高。我建议团队里还是用约定加上JSDoc注释来维护接口约束因为JS没有编译期检查如果两个人各写各的策略一个人方法名叫compute另一个叫getPrice运行时不报错结果也不对排查起来很痛苦。2.3 ConcreteStrategy真正的业务算法所在第三个角色是ConcreteStrategy具体策略。每个具体策略封装一种算法、一种规则、一种行为方式它们之间互不依赖、互不影响。增加一个新策略不用动其他任何代码这是在所有模式里最干净的一种扩展方式。结合实际的表单校验场景来理解const validators { required(value) { return value ! ; }, minLength(value, length) { return value.length length; }, isMobile(value) { return /^1[3-9]\d{9}$/.test(value); } };这里的validators对象里有三个具体策略required、minLength、isMobile它们各自负责一种校验规则。以后要增加一个邮箱校验直接往这个对象里加一个isEmail方法就行调用方和Context完全不需要改动。这一瞬间你就能感受到策略模式带来的维护快感。不过这里也引出一个问题策略多了谁来决定选择哪一个这个判断逻辑不一定放在Context里有时候放在调用方有时候放在一个独立的“策略工厂”里这个我们后面专门讲。3. 手写一个策略模式拿表单校验练手3.1 从小项目里体会重构前后的差异我接触策略模式的第一反应是这不就是对象字面量加一个switch吗有什么特别的光看代码确实没什么魔法真正神奇的是重构前后整个项目的形态变化。先看一个典型的原始版表单校验function validateForm(form) { if (form.username ) { return 用户名不能为空; } if (form.username.length 3) { return 用户名长度不能小于3位; } if (!/^1[3-9]\d{9}$/.test(form.phone)) { return 手机号格式不正确; } if (form.email !/^\S\S\.\S$/.test(form.email)) { return 邮箱格式不正确; } return ; }这个函数的坏味道在于校验规则写在函数体里每增加一个规则就要改这个函数函数会越来越长规则无法复用另一个页面想用同样的手机号校验只能复制粘贴规则和业务页面耦合想动态调整哪些字段必填根本做不了。用策略模式重构第一步是把校验规则抽象出来变成一组可以任意组合的校验策略。第二步是提供一个Validator的上下文类让它可以添加规则、执行校验并把错误消息返回给调用方。第三步是在业务代码中声明这个表单需要哪些规则像配置文件一样把规则列表传进去。3.2 完整代码实现可直接复制调试下面是我在真实项目里用过的精简版去掉业务噪音后保留核心逻辑// 1. 定义所有具体策略校验规则 const strategy { required(value) { return value ? 此项必填 : ; }, minLength(value, length) { return value.length length ? 长度不能小于${length}位 : ; }, maxLength(value, length) { return value.length length ? 长度不能大于${length}位 : ; }, isMobile(value) { if (!value) return ; return /^1[3-9]\d{9}$/.test(value) ? : 手机号格式不正确; }, isEmail(value) { if (!value) return ; return /^\S\S\.\S$/.test(value) ? : 邮箱格式不正确; } }; // 2. 创建Context上下文类 class Validator { constructor() { this.rules []; this.errorMessages []; } add(value, ruleName, ruleArg) { this.rules.push({ value, ruleName, ruleArg }); return this; } validate() { this.errorMessages []; for (const item of this.rules) { const { value, ruleName, ruleArg } item; const validatorFn strategy[ruleName]; if (typeof validatorFn ! function) { throw new Error(没有找到名为${ruleName}的校验策略); } const error validatorFn(value, ruleArg); if (error) { this.errorMessages.push(error); } } return this.errorMessages.length 0; } getErrors() { return this.errorMessages; } } // 3. 客户端使用 const form new Validator(); form.add(, required) .add(abc, minLength, 5) .add(13800138000, isMobile) .add(testexample.com, isEmail); console.log(form.validate()); // false console.log(form.getErrors()); // [长度不能小于5位, 手机号格式不正确]这里我要特别强调两个设计细节。第一个add方法返回this支持链式调用这样业务端的代码读起来特别像声明式配置语义很清晰。第二个isMobile和isEmail里对空值做了特殊处理如果是空字符串就返回空表示“我不负责校验必填”必填的职责交给required策略。这样就把“是否必填”和“格式是否正确”两个维度的校验解耦了组合起来非常灵活。3.3 执行流程和动态替换的底层逻辑执行流程其实不复杂调用方先把校验规则通过add方法注册到Validator中Validator内部维护了一个数组调用validate时遍历数组取出每条规则对应的具体策略函数执行策略函数返回空字符串代表校验通过返回非空字符串代表校验失败就把错误消息收集起来。策略替换的底层逻辑更值得琢磨。JavaScript的对象属性访问本身就是一种动态分发机制策略对象里的方法本质上是值strategy[ruleName]只是一个字符串索引取到什么函数就执行什么函数。这意味着只要策略函数签名保持一致你可以随时替换、增删、甚至从外部注入新的策略函数。这正是策略模式在JavaScript中天然好用的原因。像Java这种静态语言你需要建一个Strategy接口再写一堆实现类然后在构造函数里传入具体实现。在JavaScript里只需一个个纯函数或者对象方法就完成了代价非常小。所以很多资深前端会不自觉地使用策略模式他们甚至不知道这个名字。3.4 一个加分项给策略扩展默认参数我后来在实际项目中给上面的Validator做了一次升级支持传递任意数量参数。比如minLength策略可能需要第二位参数是错误消息文案isMobile可能需要根据国家代码用不同的正则。用rest参数解决function minLength(value, ...args) { const [length, customMessage] args; if (value.length length) { return customMessage || 长度不能小于${length}位; } return ; }这样业务方可以传入自定义错误信息form.add(abc, minLength, 5, 用户名太短了至少5个字符);策略函数的参数设计不追求花哨但它决定了策略是否能适应复杂业务。我从这个细节里体会到写设计模式的时候往往是一个小参数设计决定了它能不能在真实项目里活下来只抄一个模式骨架是远远不够的。4. 企业级应用场景策略模式的前沿实战4.1 支付方式选择每加一个新渠道只加一个策略我做过一个电商后台的多渠道支付联调功能。业务方需要支持支付宝、微信、银联、花呗等多种支付方式每种支付方式的参数构造完全不同。这些支付渠道在技术上各自是一套独立的请求体系如果都写在同一个createOrder函数里用switch判断支付方式代码少说两百行而且各家SDK更新迭代频率不同你根本不敢轻易动主流程。用策略模式之后我建了两个文件paymentStrategies.js负责定义各种支付渠道的请求构造和签名逻辑paymentContext.js负责统一执行入口。主流程长这样// 支付上下文 class PaymentContext { constructor() { this.strategy null; } setPayStrategy(strategy) { this.strategy strategy; } async createPayment(orderInfo) { if (!this.strategy) { throw new Error(请先指定支付方式); } return this.strategy.pay(orderInfo); } } // 具体策略示例 const alipayStrategy { async pay(order) { // 构造支付宝参数、调用支付宝SDK const params { subject: order.title, outTradeNo: order.orderNo, totalAmount: order.amount.toFixed(2) }; return await alipaySdk.pay(params); } }; const wechatPayStrategy { async pay(order) { // 构造微信统一下单参数 const params { body: order.title, outTradeNo: order.orderNo, totalFee: Math.round(order.amount * 100) }; return await wechatSdk.unifiedOrder(params); } }; // 调用方 const ctx new PaymentContext(); if (userSelected alipay) { ctx.setPayStrategy(alipayStrategy); } else if (userSelected wechat) { ctx.setPayStrategy(wechatPayStrategy); } const result await ctx.createPayment(order);这个方案的好处在我接入第四个支付渠道的时候体现得淋漓尽致新建一个strategy文件在配置中心注册一下其他代码一概不动。既不会碰坏支付宝流程也不需要理解微信支付的完整参数新人也能快速接入。策略模式在跨部门协作、多供应商对接的场景里是真的能保住头发的。4.2 优惠计算引擎把复杂规则拆成独立策略电商的促销规则是设计模式的多发地带。满减、折扣、赠品、包邮、会员价、新人价……各种规则互相叠加如果没有清晰的结构优惠计算模块会变成一锅粥而且是每天都在加料的那种粥。我记得当时的需求是订单结算时要按照用户选择的优惠活动计算最终价格活动类型包括“满300减50”“全场8折”“新人立减20”“双倍积分”等等。用策略模式拆分以后每种优惠类型对应一个策略对象const discountStrategies { fullReduction(price, condition) { // condition { threshold: 300, reduction: 50 } if (price condition.threshold) { return price - condition.reduction; } return price; }, percentage(price, condition) { // condition { rate: 0.8 } return price * condition.rate; }, newcomer(price, condition) { // 新人立减condition { reduction: 20 } return Math.max(price - condition.reduction, 0); } }; class DiscountCalculator { constructor(strategy, condition) { this.strategy strategy; this.condition condition; } calculate(price) { return this.strategy(price, this.condition); } } // 使用 const calc new DiscountCalculator(discountStrategies.percentage, { rate: 0.8 }); const finalPrice calc.calculate(299); // 239.2我当时在代码评审时说过一句话优惠策略本质上是“一段接收价格和条件、返回新价格的纯函数”。因为它是纯函数所以测试极其好写每个策略独立测试不会互相影响。这也让我后来跟测试同学配合愉快了很多。4.3 动态规则配置策略模式让“改规则”不需要发版本一个更进阶的玩法是把策略和配置中心结合。业务运营人员想调整活动规则不应该每次都找开发改代码。我们可以把策略的标识和参数从后端接口读取前端动态选择策略。比如接口返回这样的配置// 后端返回的优惠配置 const activityConfig { type: fullReduction, condition: { threshold: 300, reduction: 50 } };前端拿到配置后从discountStrategies里找到对应的type创建DiscountCalculator执行。运营改了threshold不需要发版刷新页面就是新的规则。这种灵活性在传统写法下几乎不可能实现。不过要提醒一点策略参数化之后防错很重要。接口传来的type如果拼错了会在策略对象里查不到对应函数。所以我在封装时加了一个兜底策略const defaultStrategy { calculate(price) { return price; } };既然无法处理就不要改动价格。不抛出异常而是降级为原价或提示运营配置有误这是真实业务里更稳的做法。5. 遇到过的坑和排查心得一条条说给你听5.1 this指向的坑最隐蔽也最致命策略模式中如果你用类方法作为策略函数this的指向很容易出问题。比如class UserDiscountStrategy { constructor(baseRate) { this.baseRate baseRate; } compute(price) { return price * this.baseRate; } } const strategy new UserDiscountStrategy(0.85); const context { strategy: strategy, calculate(price) { return this.strategy.compute(price); } }; const fn context.calculate; fn(100); // TypeError: Cannot read properties of undefined (reading baseRate)这里因为方法内部使用了this而你把它当成独立函数调用this丢失了。解决办法有几种最推荐的方式策略函数不依赖this而是依赖传入参数写成纯函数形式这样天然免疫this丢失。如果用类可以在事件绑定或函数回调之前做一次bind或者使用箭头函数定义类字段class UserDiscountStrategy { constructor(baseRate) { this.baseRate baseRate; } compute (price) { return price * this.baseRate; } }箭头函数在定义时就捕获了this算是JS里一个比较干净的解决方案。5.2 策略对象膨胀注册表来救场策略模式有个天然的问题策略一多类或函数会爆炸式增长。一个项目里散落着十几个策略文件找起来费劲目录结构也不好看。我的解决方案是把它当作一个策略注册表按业务模块组织文件比如strategies/pay、strategies/discount、strategies/validator然后用一个index.js统一导出注册export const strategies { ...payStrategies, ...discountStrategies, ...validators };如果策略数量真的很大还可以在对象里维护一个meta数据比如策略名、适用场景、是否需要额外参数甚至配合后端配置做策略的“可配置化”。但这是后话一般项目做到注册表加模块划分就够了。5.3 别为了设计模式而设计模式这是所有学习设计模式的人都会犯的毛病。看到一个需求就往上套策略模式结果一个只有两种分支、永远不扩展的简单模块被拆成五个文件维护成本反而飙升。我自己的判断标准很简单如果这个逻辑三个月内大概率会增加新的分支而且每种分支的处理逻辑比较复杂、各自独立那就用策略模式。如果只有两三种情况、逻辑只有一行老老实实用if-else代码更直观。设计模式是帮人省事不是给人添堵的。5.4 策略模式与其他模式搭配效果翻倍策略模式很少单独出现它经常和工厂模式、单例模式、模板方法模式混着用。我之前在支付场景里把策略模式和简单工厂结合过。业务方只传一个字符串类型工厂内部决定创建哪个支付策略实例function createPayStrategy(type) { switch (type) { case alipay: return new AlipayStrategy(); case wechat: return new WechatPayStrategy(); default: throw new Error(不支持的支付方式: ${type}); } } // 调用方 const ctx new PaymentContext(); ctx.setPayStrategy(createPayStrategy(order.payType));工厂负责“创建”Context负责“持有和调用”策略负责“具体执行”三者各司其职。但这种结合也不是必须的取决于创建策略的过程是否复杂。如果创建过程很复杂组装策略的职责从调用方剥离出来调用方的代码会更干净。5.5 性能问题和其他小坑策略模式本身没有性能问题它就是一次对象属性查找加一次函数调用开销微乎其微。但要注意两点尽量不要在render循环或高频事件里每次都new一个Context。可以把Context实例化一次动态切换strategy。因为Context本身是轻量对象频繁创建不会造成什么大问题但如果策略对象里有重量级的资源连接就要考虑复用。第二点是错误处理。策略内部抛出的异常要在Context层面统一收集或处理不要让错误信息散落到各个策略里。定一个约定策略函数返回结果或者主动抛错Context统一判断。这样排查问题的时候定位路径清晰得多。6. 聊聊我在多个项目里的实战体验和最终建议回头看策略模式帮我解决过的最大问题其实不是代码效率而是团队的协作效率。业务模块里每多一种规则如果可以变成一个新策略文件就不会跟别人改同一个文件的冲突如果每多一种规则就要在旧的函数里插一段if团队里不同人的代码逻辑会纠缠在一起代码评审和合并都是一种煎熬。这个模式对我个人影响也很大。以前看到一个复杂计算函数第一反应是从头到尾读完然后小心翼翼地在里面修改。现在我的第一反应是这个函数里哪些规则是会变的能不能抽成一个策略入口能不能保持稳定这个思考方式的转变让我从“改代码的人”变成了“设计代码的人”。如果让我给学习前端设计模式的人一个建议我会说不要背定义也不要照着UML图画代码拿一个自己手头的真实业务场景练手最好。做过一次比读十篇文章都管用。你完全可以从一个几十行的if-else函数开始把它重构成策略模式然后体感一下改需求时的那种从容。最后再分享一个小技巧。如果你刚开始实践可以先不用类只用对象字面量加函数把一对一的关系走出来。等你对这个模式的结构手感熟了再引入工厂、注册表这些东西。前后端分离的项目里策略模式在TypeScript中能发挥更强大的类型约束但JS里一样可以做别让语言特性限制住你的设计能力。记住设计模式的本质是管理变化不是炫技。
返回列表