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.85;fullReduction 策略就是传入价格,如果达到门槛就减掉优惠金额。
第三个是环境角色(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. 案例一:表单校验,用策略数组拆掉连续 if
3.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) 函数统一收敛选择逻辑。第三种是组合模式,把多个策略串联成一条策略链,整体对外看还是一个策略,可以用在优先级管线、校验规则链这些场景。
如果你刚开始在团队里推广策略模式,不建议一上来就引入所有配套。先把「策略注册表 + 纯函数策略 + 统一入口」这组最小骨架搭起来,跑通一个真实痛点,再根据业务复杂度逐步加入工厂和配置化的能力。技术方案的演进应该跟着需求走,而不是为了塞满设计模式。