深入解析在JavaScript中实现策略模式的三种主流写法:面向对象Class、轻量对象字面量与高动态Map集合。通过电商结算与表单校验实战,详解架构原理、重构步骤与选型决策。
JS实现策略模式的三种写法:类、对象字面量与Map
时间:2026-11-03
浏览:604
文章目录
在日常 JavaScript 开发中,面对按用户等级计算折扣、按支付渠道派发网关、或按输入类型校验表单等业务需求,代码很容易堆砌成臃肿难读的多重 if-else 或 switch 分支。策略模式(Strategy Pattern)的核心目标是将算法的定义、创建与使用彻底解耦,使得具体算法可以独立演进且相互替换。在拥有“函数是一等公民”特性的 JavaScript 中,开发者不必拘泥于面向对象语言中基于“类(Class)”的传统实现,还可以利用“对象字面量(Object Literal)”与 ES6 “Map 集合”实现更加轻巧、直观或高动态性的算法分发。本文将通过具体可运行的代码示例,详细讲解这三种写法的具体落地步骤、运行机制与选型权衡。
一、策略模式的核心结构与痛点解耦
为了直观感受策略模式解决的问题,我们先来看一段随处可见的订单折扣计算逻辑:
// 未引入设计模式时的典型多重条件分支
function calculatePrice(customerType, originalPrice) {
if (customerType === 'normal') {
return originalPrice;
} else if (customerType === 'vip') {
return originalPrice * 0.85;
} else if (customerType === 'super_vip') {
return originalPrice * 0.7 - 10;
} else if (customerType === 'internal') {
return originalPrice * 0.5;
} else {
return originalPrice;
}
}
这种写法存在三个显而易见的弊端:
- 违反开闭原则(OCP):每当运营新增一种会员等级(如黑金会员),或者调整已有优惠算法时,都必须修改
calculatePrice函数源码,极易对已有逻辑造成回归破坏。 - 算法复用性极差:计算 VIP 折扣的代码硬编码在局部条件分支内,其他模块若需要预估折扣金额,无法单独复用该算法。
- 单函数复杂度不可控:随着分支内计算逻辑复杂化(如叠加优惠券、积分抵扣、满减梯度),主函数将迅速膨胀为上百行的复杂函数,难以进行单元测试。
策略模式通过分离三个核心角色来解决上述问题:上下文(Context)负责对接外部调用并协调流程;策略协议(Strategy Interface)约束所有算法的输入与输出;具体策略(Concrete Strategies)独立承担单一算法运算。
二、写法一:基于类(Class)的传统面向对象实现
在传统的面向对象设计中,策略模式要求每个算法都是一个实现了统一接口的策略类。在现代 JavaScript (ES6+) 或 TypeScript 中,可以通过基类(Base Class)或接口规范来约束具体策略的行为,再由上下文类(Context Class)接收策略实例并委派执行。
1. 代码实现与执行示例
// 1. 定义策略基类(提供接口契约与防御性断言)
class DiscountStrategy {
calculate(amount) {
throw new Error('具体策略类必须实现 calculate(amount) 方法');
}
}
// 2. 派生具体策略类
class NormalStrategy extends DiscountStrategy {
calculate(amount) {
return amount;
}
}
class VipStrategy extends DiscountStrategy {
calculate(amount) {
return amount * 0.85;
}
}
class SuperVipStrategy extends DiscountStrategy {
#flatDiscount = 10; // 私有字段:封装独立状态
calculate(amount) {
const discounted = amount * 0.7;
return Math.max(0, discounted - this.#flatDiscount);
}
}
// 3. 定义上下文容器(Context)
class OrderContext {
#strategy;
constructor(strategy) {
this.setStrategy(strategy);
}
setStrategy(strategy) {
if (!(strategy instanceof DiscountStrategy)) {
throw new TypeError('传入的策略必须继承自 DiscountStrategy');
}
this.#strategy = strategy;
}
checkout(originalPrice) {
if (typeof originalPrice !== 'number' || originalPrice < 0) {
throw new RangeError('订单金额必须为非负数');
}
// 将计算工作委派给当前策略实例
return this.#strategy.calculate(originalPrice);
}
}
// 4. 客户端调用执行
const normalOrder = new OrderContext(new NormalStrategy());
console.log('普通客户实付:', normalOrder.checkout(200));
// 运行时无缝切换策略
normalOrder.setStrategy(new SuperVipStrategy());
console.log('超级会员实付:', normalOrder.checkout(200));
2. 特点与适用场景
基于类的写法优势在于封装性极强。当某种算法不仅是纯数学计算,还需要维护自己的内部状态(如独立计数器、限额缓存、私有属性 #flatDiscount)或包含前后置生命周期钩子时,类是天然的容器。此外,在企业级大型 TypeScript 项目中,借助 interface Strategy 能获得最完备的静态类型约束与 IDE 重构提示。其局限性则在于语法较为冗长,存在较多的类声明样板代码。
三、写法二:基于对象字面量(Object Literal)的轻量函数映射
在 JavaScript 中,函数是一等公民(First-Class Citizens),既可以作为变量赋值,也可以作为对象的属性值。这意味着我们无需为每一个几行代码的算法单独声明一个 Class,直接使用对象字面量维护一个以键为分支名称、值为策略函数的映射表,就能实现极为紧凑的策略模式。
1. 代码实现与执行示例
// 1. 策略表:纯函数集合
const discountStrategies = {
normal: (amount) => amount,
vip: (amount) => amount * 0.85,
super_vip: (amount) => Math.max(0, amount * 0.7 - 10),
internal: (amount) => amount * 0.5,
};
// 2. 默认兜底策略(防御未知类型)
const defaultDiscountStrategy = (amount) => amount;
// 3. 上下文分发函数(Context)
function calculateDiscount(customerType, amount) {
// 直接以 O(1) 复杂度通过属性寻址匹配算法,若无匹配则回退至默认策略
const strategy = discountStrategies[customerType] || defaultDiscountStrategy;
return strategy(amount);
}
// 4. 客户端调用执行
console.log('VIP 折扣价:', calculateDiscount('vip', 200));
console.log('内部员工价:', calculateDiscount('internal', 200));
console.log('未知等级兜底:', calculateDiscount('guest', 200));
|| defaultStrategy 或通过 Object.hasOwn() 验证键是否存在。否则当调用端传入未注册的 customerType 时,将得到 undefined,紧接着执行调用会导致抛出 TypeError: strategy is not a function 致命异常。
2. 特点与适用场景
对象字面量写法是前端业务代码中最常见、性价比最高的策略模式变体。它完全消除了 new 实例化的开销与冗余语法,所有分支逻辑一目了然。非常适合纯输入输出的无状态计算,如输入框校验规则、数据格式转换器、图表配置生成等静态分发场景。缺点是对象键只能是字符串或 Symbol,且不利于动态隔离复杂的私有状态。
四、写法三:基于 Map 的高动态性与复杂键支持
ES6 引入的 Map 进一步扩展了键值映射的能力。与普通对象字面量相比,Map 具备三个关键优势:键可以是任意类型(包括对象、数字、正则或函数)、内建完备的动态增删方法(set、delete、has)、以及高频增删场景下的性能优化。这使得 Map 成为构建支持插件机制、可插拔策略系统的首选载体。
1. 代码实现与执行示例
// 1. 初始化策略注册中心
class StrategyRegistry {
#strategies = new Map();
#defaultStrategy = null;
constructor(defaultStrategy = null) {
this.#defaultStrategy = defaultStrategy;
}
// 动态注册策略
register(key, strategyFn) {
if (typeof strategyFn !== 'function') {
throw new TypeError('策略必须是一个可调用的函数');
}
this.#strategies.set(key, strategyFn);
return this; // 支持链式调用
}
// 动态注销策略
unregister(key) {
return this.#strategies.delete(key);
}
// 上下文执行分发
execute(key, ...args) {
let strategy = this.#strategies.get(key);
// 支持按正则表达式动态匹配键名
if (!strategy) {
for (const [registeredKey, fn] of this.#strategies.entries()) {
if (registeredKey instanceof RegExp && registeredKey.test(key)) {
strategy = fn;
break;
}
}
}
const finalHandler = strategy || this.#defaultStrategy;
if (!finalHandler) {
throw new Error(`未找到针对键 [${String(key)}] 的处理策略且无兜底处理器`);
}
return finalHandler(...args);
}
}
// 2. 实例化并配置策略注册中心
const discountCenter = new StrategyRegistry((amt) => amt);
discountCenter
.register('NORMAL', (amt) => amt)
.register('VIP', (amt) => amt * 0.85)
// 支持非纯字符串/正则匹配:例如匹配所有渠道推广促销活动
.register(/^PROMO_/, (amt) => amt * 0.6);
// 3. 执行测试
console.log('普通结算:', discountCenter.execute('NORMAL', 100));
console.log('活动推广结算:', discountCenter.execute('PROMO_SUMMER', 100));
// 运行期动态注销
discountCenter.unregister('VIP');
console.log('注销VIP后自动回退兜底:', discountCenter.execute('VIP', 100));
2. 特点与适用场景
Map 写法将策略表提升为一个活的动态集合。外部模块可以在不侵入核心调度代码的前提下,在运行期通过 register 挂载自己的算法(类似插件微内核架构),也可以安全地卸载或热更新算法。当策略的分发键需要使用非字符串类型(如 DOM 元素、复合对象、正则规则)时,Map 是不可替代的选择。
五、从条件分支到策略模式的重构流程
在面对遗留项目的大规模分支判断时,不建议盲目推倒重来。遵循清晰的四步演进流程,可以确保在单测保护下平稳重构。
- 定位变化分支:梳理所有
if-else或switch-case,将公共的前置参数校验、后置持久化操作保留在主流程中,精准剥离出随业务规则频繁变动的部分。 - 统一契约接口:确定所有具体策略必须接收的参数签名(例如统一要求输入
(amount, contextData))以及统一的返回值结构。入参不统一是重构失败最常见的原因。 - 离散策略实现:根据策略是否具有复杂私有状态,决定将算法提取为独立类文件,还是集中归纳为对象字面量/Map 注册项。每个策略文件仅关注自身逻辑,禁止在策略内部反向引用上下文的私有属性。
- 查表委派分发:在原有函数位置替换为策略寻址调用。必须加入兜底策略机制,确保未知入参时系统依然平稳降级,而不是抛出崩溃异常。
六、三种写法的多维横向对比
三种写法在语法成本、内存占用、动态性与类型约束上各有侧重。开发者应根据团队工程规范与实际业务特征选择最合适的方案。
| 比对维度 | 基于类(Class) | 基于对象字面量(Object) | 基于 Map 映射表 |
|---|---|---|---|
| 语法与样板代码 | 较重(需声明基类、派生类及实例化) | 极轻(纯字面量键值对,开箱即用) | 轻量(标准集合 API 操作) |
| 内部状态管理 | 极佳(原生支持 #private 字段与生命周期) |
较弱(推荐无状态纯函数,闭包易污染) | 良好(可通过闭包或存储对象实例保持状态) |
| 动态增删策略 | 中等(需配合工厂函数管理子类引用) | 简单(直接对对象属性进行赋值或 delete) |
极佳(原生 set() / delete() / has()) |
| 键(Key)类型支持 | 通常通过实例或枚举字符串映射 | 仅支持 String 或 Symbol |
支持任意类型(对象、正则、数字、函数) |
| TypeScript 支持 | 极高(通过 implements IStrategy 强约束) |
优秀(通过 Record<Keys, StrategyFn> 提示) |
优秀(通过 Map<KeyType, StrategyFn> 约束) |
| 首选适用领域 | 企业级 OOP 架构、状态复杂的核心模块 | 前端表单校验、数值计算、纯函数转换 | 插件式扩展系统、微前端、高动态注入环境 |
七、选型决策指南与工程避坑建议
为了帮助开发者在具体业务开发中快速定夺,下图归纳了策略模式的技术选型决策树:
工程实践避坑建议:
如果某个业务分支只有 2~3 个判断条件,且业务在可预期的半年内极少变动(例如仅有“白天模式 / 夜间模式”),直接使用简单的三元运算符或简单
if-else 即可,强行引入策略模式反而会增加代码跳跃度与阅读心智负担。
不同策略在计算时所依赖的外部数据可能不同。切忌为了满足某个特殊策略的需求而在策略接口中无节制增加形参。推荐使用单一对象入参(Context Parameter Object):
strategy({ amount, user, coupon, env }),由各策略按需解构所需字段,保证签名的一致性与弹性。
如果使用普通对象作为策略映射表,并且外部用户输入可以直接充当查找 Key,必须注意防范
toString、constructor 等原型链属性引发的逻辑错乱。安全的做法是使用 Object.create(null) 创建无原型对象,或者直接选用 Map。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。