
1. 先看一段真实的“折扣地狱”if-else 是怎么一步步失控的1.1 从三个会员等级到无穷分支去年年底的某个版本迭代里我接手了一个电商项目的价格计算模块。最初代码其实特别干净就是按照会员等级区分折扣function getDiscount(level, price) { if (level normal) { return price; } if (level vip) { return price * 0.85; } if (level svip) { return price * 0.75; } return price; }如果这个项目到此为止这段代码没有任何问题。可现实是运营每两个月就会整一次新玩法。先是新增了「年度会员」要打八折于是代码变成了function getDiscount(level, price, user) { if (level normal) { return price; } if (level vip) { if (user.isAnnual) { return price * 0.85 * 0.9; } return price * 0.85; } if (level svip) { if (user.isAnnual) { return price * 0.75 * 0.9; } return price * 0.75; } return price; }注意到这里「VIP 用户是年度会员再九折」这个逻辑已经复制了两遍。没过两个月运营又提了「满 500 减 80」的券还得叠加在会员折扣之后。于是每个 if 分支里又要再套一层判断。到这里代码已经有点别扭了但还没彻底失控。再往下加需求时函数很快就超过了五十行里面有至少七个 if-else 分支还有两个分支逻辑完全一致只是折扣系数不同。每次需求变更我都得在这个函数里找「该改哪里」还得祈祷不要漏改其中一个分支。这就是我观念里最典型的 if-else 地狱。1.2 失控的三个典型信号根据这次经历我总结出 if-else 开始失控的三个典型信号大家可以对照自己的代码自查。第一个信号是函数体积膨胀。一个本来三五行搞定的计算逻辑因为不断往里面塞分支慢慢长到几十行甚至上百行。当你要理解整个函数的意图时不得不同时在十几个分支之间来回跳大脑缓存一下就溢出了。第二个信号是相同逻辑在多个分支里重复。同一个「年度会员再九折」在 vip 和 svip 两个分支里各写一遍。后续需求只要涉及这个规则就强迫开发者复制粘贴两遍漏改一处就会出现「同一个用户在不同场景下拿到不同价格」的诡异线上 bug。第三个信号是优先级靠 if 嵌套的物理位置来表达。先算会员折扣还是先算满减完全取决于这段逻辑写在哪个 if 的里面、哪个 else 的上面。代码里没有任何显式的东西告诉你「满减一定在折扣之后执行」肉眼排查时很容易忽略顺序导致价格算错。1.3 问题本质业务规则和流程骨架焊在一起了我重构这个模块时想明白了一件事if-else 本身没有原罪真正的债务在于我把「流程骨架」和「业务规则」焊在了一个函数里。流程骨架是固定的先看会员等级再算折扣再叠加优惠券。业务规则是易变的每个会员等级的折扣系数、满减门槛、新人红包金额。今天要调一个系数明天要加一个玩法都在同一个函数里动刀子。于是每动一次主流程被碰一次回归测试的范围被放大一次。策略模式要做的事情就是把这层「易变的业务规则」从「稳定的流程骨架」中拆出来各自封装各自演化。下面进入正题。2. 策略模式核心思想把算法从流程中拆出来2.1 策略模式的三个角色策略模式不是一个很玄的概念它本质上就三个角色。第一个是策略接口。它约定所有策略必须长成什么样比如「接受价格参数返回处理后的价格」。在 JavaScript 里策略接口通常不显式声明而是通过函数的参数和返回值来约定也就是俗称的鸭子类型。第二个是具体策略。每个具体的折扣规则、每个校验规则都是策略接口的一个实现。比如 vip 策略就是传入价格返回价格乘以 0.85fullReduction 策略就是传入价格如果达到门槛就减掉优惠金额。第三个是环境角色Context。它是调用方看到的唯一入口负责接收外部参数、选中合适的策略、并把请求委托给策略执行。在前面那个代码里calcPrice 就是环境角色它不需要知道折扣内部怎么算只需要知道「找哪个策略、怎么调用」。可以拿点外卖类比你要的是一个最终结果「外卖送到家」而配送方式可以是骑车、开车、无人机。你不关心配送员具体走哪条路只关心「把餐放到门口」。这里的配送方式就是具体策略你本人就是环境角色而「送达」这个统一动作就是策略接口的定义。2.2 为什么在 JavaScript 里落地特别轻量很多设计模式是从 Java 这种强类型语言里总结出来的到了 JavaScript 里往往被简化为「对象映射 普通函数」。因为 JavaScript 函数是一等公民可以当值传递也可以放进对象里按 key 取出来直接调用。所以策略模式的落地成本极低大多数情况下连类都不用写。最简单的策略容器长这样const discounts { normal: (price) price, vip: (price) price * 0.85, svip: (price) price * 0.75, };那计算入口就变成了function calcPrice(level, price) { const strategy discounts[level] ?? discounts.normal; return strategy(price); }这里有三个点大家可以细品一下。第一discounts 对象就是策略注册表它把「策略名称」和「策略实现」一一映射。第二discounts[level] 这行代码替代了原本那个 if-else 判断链取不到时 fallback 到 normal。第三calcPrice 这个环境角色从此稳定不动了不管以后加多少会员等级只要往 discounts 里注册新策略就行。顺带说一句??是空值合并运算符左边的值是 null 或 undefined 时才取右边的值这里用来做兜底刚刚好。如果团队风格偏向面向对象也可以写成 class。但就我个人经验在业务代码里用「对象 纯函数」的方式已经够了还更符合 JavaScript 的函数式习惯。下面的对比表可以帮你做个选型判断维度对象 函数class样板代码几乎没有每个策略一个类略显啰嗦内部状态无状态参数显式传入可以在实例上保存状态测试难度函数直接导入直接测需先构造实例推荐场景绝大多数业务规则策略内部极其复杂、需要持有状态时2.3 一次需求变更的成本对比为了让你更直观地感受差别我们拿「新增一个 annual 年度会员折扣系数 0.8」这个需求做对比。使用 if-else 的传统方式先要在 getDiscount 里找会员等级判断的位置然后在所有相关分支里同步补充年度会员的判断再处理它和满减、新人红包的叠加顺序最后写一堆测试用例覆盖全部路径。整个改动通常会碰到至少两三个互相纠缠的分支。使用策略模式只需要在 discounts 里新增一行annual: (price) price * 0.8,然后 calcPrice 不用动其它策略不用改回归测试主要关注新增策略本身以及它与相邻策略的组合结果。改动面从「整个函数」缩小到「一个文件里的一行」这就是策略模式最朴实的好处。3. 案例一表单校验用策略数组拆掉连续 if3.1 传统校验代码的复制粘贴式扩充聊完理论看第一个实战案例表单校验。这是一个几乎所有业务系统里都会遇到的需求。注册表单里有用户名、邮箱、手机号三个字段规则无非是必填、长度限制、格式校验。不少项目的校验代码会越堆越长最后长成下面这样function validate(data) { const errors []; if (!data.username) { errors.push(用户名不能为空); } else if (data.username.length 3 || data.username.length 10) { errors.push(用户名长度需要在3到10之间); } if (!data.email) { errors.push(邮箱不能为空); } else if (!/\S\S\.\S/.test(data.email)) { errors.push(邮箱格式不正确); } if (!data.phone) { errors.push(手机号不能为空); } else if (!/^1\d{10}$/.test(data.phone)) { errors.push(手机号格式不正确); } return errors; }这段代码的问题跟折扣模块一模一样每加一个字段、每加一条规则都要往 validate 函数里插一段 if-else。当字段从 3 个涨到 10 个时validate 的体积会非常可观。更要命的是「必填」这种通用规则被每个字段各自复制了一遍将来如果必填的含义变化——比如允许全空格不参与校验——就要在每个字段的判断里都改一遍漏一处就会出 bug。3.2 用策略函数加规则数组重构用策略模式来改思路是把「每条规则」都当成一个策略函数统一签名是(value, field) string返回错误提示文案空字符串表示通过。然后每个字段的多个规则放进一个数组由环境角色统一执行。先定义一组通用的策略工厂const required (value, field) { if (value undefined || value null || String(value).trim() ) { return ${field}不能为空; } return ; }; const minLength (min) (value, field) { if (String(value ?? ).trim().length min) { return ${field}长度不能少于${min}个字符; } return ; }; const maxLength (max) (value, field) { if (String(value ?? ).trim().length max) { return ${field}长度不能超过${max}个字符; } return ; }; const emailFormat (value, field) { if (!/\S\S\.\S/.test(String(value))) { return ${field}格式不正确; } return ; }; const phoneFormat (value, field) { if (!/^1\d{10}$/.test(String(value))) { return ${field}格式不正确; } return ; };然后按字段组织规则数组这就是我们的策略注册表const fieldRules { username: [required, minLength(3), maxLength(10)], email: [required, emailFormat], phone: [required, phoneFormat], };最后是环境角色 validate它不需要关心具体规则只需要遍历字段再遍历字段对应的规则数组遇到第一条错误就停下function validate(data) { const errors []; for (const [field, rules] of Object.entries(fieldRules)) { for (const rule of rules) { const error rule(data[field], field); if (error) { errors.push(error); break; } } } return errors; }这里有个细节值得说一下通过闭包生成策略比如 minLength(3)本质上是把「策略的定制参数」和「策略的执行逻辑」一起封装在一个函数里。这在 JavaScript 策略模式的落地中非常常见也很实用因为策略往往需要携带不同的配置。3.3 改造后的收益清单把这段代码落地之后我的感受是真正需要维护的东西变少了。具体收益可以列一下。新增校验规则时只要写一个策略函数塞到对应字段的规则数组里validate 完全不用动。调整校验顺序时直接调整数组里的位置比在 if 树里挪分支安全得多。通用规则可以实现真正的复用minLength 和 maxLength 可以同时给用户名、昵称、标题等所有字段用。还有一个容易被忽视的好处策略函数都是纯函数可以独立导出后单独写单元测试。比如只需测试 minLength(3) 在不同输入下的返回值不用再为了测一个字段规则去模拟一整份表单数据。这对测试的友好度提升非常大。4. 案例二促销价格计算用优先级驱动策略管线4.1 多策略叠加时if-else 的短板在哪里第二个案例回到开头说的价格计算场景。不过这次需求更复杂不再是单一会员折扣而是多个优惠活动可以叠加会员折扣、满减券、限时折扣、新人红包。并且结算顺序有明确要求先会员折扣再满减再限时折扣最后减新人红包。传统写法大概是这样function calcPrice(product, user, promotion) { let price product.price; if (user.level vip) { price * 0.85; } else if (user.level svip) { price * 0.75; } if (promotion.type fullReduction price promotion.threshold) { price - promotion.reduction; } if (promotion.type discount promotion.isValid) { price * promotion.rate; } if (user.isNew) { price - 20; } return Math.max(price, 0); }这看起来一次就跑完了为什么还说它有问题因为你没法在一个函数里表达「同一类优惠可能有多个实例并存」。比如用户既有一张满 500 减 80 的券又有一张满 300 减 50 的券当前结算时应该自动挑选最优惠的一张。再比如平台同时有「全场八折」和「会员折上折」它们之间的优先级如果变化你要改的是嵌套层级而不是一行配置。用 if-else 实现优先级等于把顺序规则硬编码进代码结构里肉眼很难一眼看出谁先谁后。4.2 策略注册表加优先级排序的具体实现我重构之后的思路是每个策略都是一个独立对象对象里带一个 priority 字段表示执行优先级数字越小越先执行。环境角色把所有策略按 priority 排序后用 reduce 逐个累加计算结果。先把策略注册表写出来const discountStrategies { memberDiscount: { priority: 10, apply: (price, ctx) { const rateMap { normal: 1, vip: 0.85, svip: 0.75, }; return price * (rateMap[ctx.user.level] ?? 1); }, }, fullReduction: { priority: 20, apply: (price, ctx) { const reductions ctx.promotions.fullReductionList || []; // 多个满减券时选抵扣金额最高且满足门槛的一张 const best reductions .filter((item) price item.threshold) .sort((a, b) b.reduction - a.reduction)[0]; return best ? price - best.reduction : price; }, }, rateDiscount: { priority: 30, apply: (price, ctx) { const rate ctx.promotions.rate; return rate ? price * rate : price; }, }, newUserCoupon: { priority: 40, apply: (price, ctx) (ctx.user.isNew ? price - 20 : price), }, };环境角色 calcPrice 就变得非常稳定function calcPrice(product, user, promotions) { const ctx { user, promotions }; const strategies Object.values(discountStrategies) .sort((a, b) a.priority - b.priority); return strategies.reduce( (price, strategy) Math.max(strategy.apply(price, ctx), 0), product.price, ); }这段代码有四个亮点逐个解释一下。第一个亮点优先级显式化。每个策略的 priority 字段写得很清楚10、20、30、40谁先谁后一目了然。如果要调整顺序把对应 priority 改一下就行不用移动代码块。第二个亮点策略之间互不感知。会员折扣策略不需要知道后面还有满减它只负责把当前价格处理完并返回。这跟 if-else 嵌套形成了本质区别每个策略都变成了可独立检查的单元。第三个亮点复杂逻辑被封装在策略内部。比如「多张满减券自动选最优」那段 filter 加 sort如果放在主流程里会非常不显眼但封装在 fullReduction 策略里职责就非常清晰。第四个亮点兜底逻辑写在公共入口。每个策略返回的价格都会经过 Math.max(value, 0)防止多策略叠加后算成负数。这个兜底不属于任何策略放在管线的收口位置最合理。4.3 让策略由配置动态组合价格计算这个案例还有一个更进阶的玩法策略列表不写死在前端而是由后端配置下发。因为运营玩法比较多变前端提前实现好所有策略而当前订单该用哪些策略、按什么顺序用由配置中心下发一个 JSON 数组。比如后端返回这样的配置const strategyConfig [ { key: memberDiscount, params: {} }, { key: fullReduction, params: { list: [{ threshold: 500, reduction: 80 }] } }, { key: newUserCoupon, params: { amount: 20 } }, ];前端根据 key 从注册表取出对应策略把 params 注入进去再按数组顺序或策略自带的 priority 组装执行管线。这样做的好处是显而易见的新活动上线时后端只要下发一条新配置前端不用发版就能支持新组合如果策略本身已经内置连改动都不用运营在后台把参数一填活动就生效了。这也引出了一个实际经验策略参数的传递方式要统一设计成类似 context 的结构这样配置系统可以无差别地给所有策略下发参数不至于每个策略的入参千奇百怪。5. 落地策略模式时绕不开的坑和我的实战经验5.1 什么时候不该用策略模式这个模式虽好但真的别逢 if 就上。我的判断标准很简单分支数量少于三个、规则几乎不会变、每个分支逻辑只有一两行这种情况写 if-else 反而更好读硬引入策略模式只会增加抽象层级和阅读成本。比如判断一个用户是否成年if (user.age 18) { // ... }你非要把这个判断包装成一个策略类吗没必要直接写更清爽。策略模式真正的发力点在于「分支多、分支会继续增长、每个分支内部可能会各自复杂化」。你可以拿这个标准去套别为了写模式而写模式这是我在很多 code review 里反复提醒自己的。5.2 策略选择逻辑本身要怎么处理用策略模式解决了「算法膨胀」有人会遇到下一个问题选择策略的那段代码还是在写 if-else。比如function getStrategy(key) { if (key a) return strategyA; if (key b) return strategyB; }这不是 bug反而很正常。策略模式解决的是「策略之间的执行与扩展」问题而「输入某个 key 对应哪个策略」本来就是环境角色或工厂的职责。如果选择逻辑本身比较简单用对象映射就行如果选择逻辑受多个条件影响、会继续膨胀可以考虑把选择逻辑做成独立的策略工厂或直接由配置驱动。总之不要把所有 if-else 都强行消灭该留在工厂里的选择判断让它自然存在就好。5.3 兜底策略一次线上故障的教训我在生产环境里踩过一个坑运营在配置后台填写策略 key 时把 memberDiscount 拼成了 memeberDiscount。策略注册表查不到对应的 key直接抛出了 undefined 不是函数整条结算链路报错用户一点「去结算」就是白屏。从那以后所有通过 key 查找策略的代码我都强制要求带一个兜底策略function getStrategy(key) { return discountStrategies[key] ?? discountStrategies.normal; }normal 策略通常就是「原样返回」。如果你的业务里没有默认策略至少也要打一条日志并抛一个可读性好的错误而不是让未捕获异常一路飞出去。这个教训看着很小但在线上发生一次代价可能就拉满了。5.4 策略函数保持纯净别在内部依赖 this另一个我踩过的是 this 丢失问题。曾经有个同事在策略函数里用了 this.state认为调用时这个 this 指向注册表对象。结果某天另一个模块把策略函数解构出来直接调用this 就变成了 undefined瞬间抛出一串 Cannot read properties of undefined 的报错。要避免这类问题最简单的方法是让策略函数保持纯净所有依赖都通过参数显式传入内部完全不使用 this。如果确实需要携带状态请用箭头函数捕获外部变量或创建一个闭包来保存状态而不是依赖调用方的 this。纯函数还有一个额外好处测试时可以零成本地单独调用。5.5 策略模式与 Factory、Registry 等习惯用法的配合最后分享一个配套经验。策略模式在真实项目中几乎不会孤零零地出现经常会和几种习惯用法组合在一起。第一种是策略注册表就是我前面反复提到的对象映射 discounts、fieldRules、discountStrategies它负责管理「key 到策略」的映射关系是策略模式最常用的落地载体。第二种是工厂模式当策略的选择依赖多个维度、甚至需要根据运行时参数动态创建策略实例时会用一个 createStrategy(type, options) 函数统一收敛选择逻辑。第三种是组合模式把多个策略串联成一条策略链整体对外看还是一个策略可以用在优先级管线、校验规则链这些场景。如果你刚开始在团队里推广策略模式不建议一上来就引入所有配套。先把「策略注册表 纯函数策略 统一入口」这组最小骨架搭起来跑通一个真实痛点再根据业务复杂度逐步加入工厂和配置化的能力。技术方案的演进应该跟着需求走而不是为了塞满设计模式。