一、从一个常见的“烦恼”说起
想象一下,你正在开发一个电商网站,其中有一个非常重要的功能:计算订单的最终价格。一开始,需求很简单,所有用户都享受统一的9折优惠。你可能会写出这样的代码:
// 技术栈:JavaScript (ES6+)
function calculatePrice(originalPrice) {
// 直接打九折
return originalPrice * 0.9;
}
// 使用示例
let price = calculatePrice(100);
console.log(`最终价格是:${price}元`); // 输出:最终价格是:90元
代码运行得很好。但没过多久,产品经理跑过来说:“我们需要支持多种促销策略!比如满100减20,或者打8折,以后可能还会有会员专属折扣……”
你可能会想,这简单,加几个if...else不就行了?于是代码变成了这样:
// 技术栈:JavaScript (ES6+)
function calculatePrice(originalPrice, promotionType) {
if (promotionType === 'discount9') {
return originalPrice * 0.9;
} else if (promotionType === 'discount8') {
return originalPrice * 0.8;
} else if (promotionType === 'minus100_20') {
// 满100减20的逻辑
return originalPrice - Math.floor(originalPrice / 100) * 20;
}
// 未来可能还有更多的 else if...
return originalPrice;
}
看起来功能实现了,但问题也随之而来。每增加一种新的促销方式,你都必须回来修改这个calculatePrice函数。这个函数会变得越来越臃肿,像一团理不清的毛线,难以阅读和维护。更糟糕的是,如果你不小心改动了某个策略的逻辑,可能会影响到其他看似无关的代码。这种“硬编码”的方式,让我们的代码失去了灵活性,而策略模式,正是为了解决这类“算法(或行为)频繁变化和替换”的问题而生的。
简单来说,策略模式就像是一个工具箱。你把不同的工具(算法)分门别类放好,当需要拧螺丝时,你就拿出螺丝刀;需要敲钉子时,你就拿出锤子。你不需要改造你的手,也不需要把螺丝刀和锤子焊在一起,你只需要根据情况,灵活地选择和使用合适的工具。在编程中,这个“工具箱”就是策略模式,它让你能定义一系列算法,把它们一个个封装起来,并且使它们可以相互替换,从而让算法的变化独立于使用算法的客户。
二、策略模式的核心:把算法“打包”起来
策略模式的核心思想非常直观:将算法(或行为)从其使用场景(上下文)中分离出来。怎么分离呢?就是为每一种算法都单独创建一个“策略类”或“策略函数”。这些策略都遵循相同的接口或规范,这样它们就可以像插件一样,被我们的主程序(上下文)随时切换使用。
让我们用代码来重构刚才那个令人头疼的计价函数。首先,我们把每一种计价算法都“打包”成独立的函数或对象:
// 技术栈:JavaScript (ES6+)
// 1. 定义一系列具体的策略(算法)
// 策略A:普通折扣
const normalDiscountStrategy = {
calculate(price) {
return price * 0.9;
}
};
// 策略B:疯狂折扣
const crazyDiscountStrategy = {
calculate(price) {
return price * 0.6;
}
};
// 策略C:满减策略
const fullReductionStrategy = {
calculate(price) {
const reduction = Math.floor(price / 100) * 20;
return price - reduction;
}
};
// 策略D:无优惠策略
const noDiscountStrategy = {
calculate(price) {
return price;
}
};
你看,我们把每一种算法都封装成了一个拥有calculate方法的独立对象。它们各司其职,互不干扰。接下来,我们需要一个“上下文”(Context)来使用这些策略。这个上下文就是原来的calculatePrice功能的升级版,它自己不负责计算,只负责“选择”和“委托”:
// 技术栈:JavaScript (ES6+)
// 2. 定义上下文(Context)类
class PriceCalculator {
constructor(strategy) {
// 在创建计算器时,就给它“装配”一个策略
this.strategy = strategy;
}
// 提供一个方法,允许动态更换策略
setStrategy(strategy) {
this.strategy = strategy;
}
// 执行计算,实际工作委托给当前策略对象
calculate(originalPrice) {
if (!this.strategy || typeof this.strategy.calculate !== 'function') {
throw new Error('请设置有效的计价策略');
}
return this.strategy.calculate(originalPrice);
}
}
现在,让我们看看如何使用这个全新的、灵活的计价系统:
// 技术栈:JavaScript (ES6+)
// 3. 客户端使用代码
// 场景一:普通促销日,使用9折策略
let calculator = new PriceCalculator(normalDiscountStrategy);
console.log(`普通促销价:${calculator.calculate(300)}元`); // 输出:270元
// 场景二:双十一大促,临时切换为疯狂6折策略
calculator.setStrategy(crazyDiscountStrategy);
console.log(`双十一狂欢价:${calculator.calculate(300)}元`); // 输出:180元
// 场景三:处理一个满减订单
calculator.setStrategy(fullReductionStrategy);
console.log(`满减后价格:${calculator.calculate(320)}元`); // 输出:280元 (满300减60)
// 场景四:恢复原价
calculator.setStrategy(noDiscountStrategy);
console.log(`商品原价:${calculator.calculate(300)}元`); // 输出:300元
通过这样的改造,代码的结构变得清晰多了。增加一个新的促销策略,比如“第二件半价”,你只需要新增一个策略对象,而完全不用去修改PriceCalculator类或者已有的其他策略。这完美符合了设计原则中的“开闭原则”:对扩展开放,对修改关闭。
三、更优雅的实现:利用JavaScript的函数特性
在JavaScript中,函数是“一等公民”,可以像变量一样被传递和赋值。这让我们实现策略模式变得更加简洁和自然。我们甚至不需要定义那些策略对象,直接把函数当作策略来用。
// 技术栈:JavaScript (ES6+)
// 1. 直接定义一系列策略函数
function normalDiscount(price) { return price * 0.9; }
function crazyDiscount(price) { return price * 0.6; }
function fullReduction(price) { return price - Math.floor(price / 100) * 20; }
function noDiscount(price) { return price; }
// 2. 一个极其简单的上下文
function createPriceCalculator(strategyFn) {
return {
setStrategy(newStrategyFn) {
strategyFn = newStrategyFn;
},
calculate(price) {
return strategyFn(price);
}
};
}
// 3. 使用示例
let myCalculator = createPriceCalculator(normalDiscount);
console.log(`使用函数策略 - 折扣价:${myCalculator.calculate(200)}元`); // 180元
myCalculator.setStrategy(fullReduction);
console.log(`使用函数策略 - 满减价:${myCalculator.calculate(250)}元`); // 210元
这种函数式的写法更加符合JavaScript的风格,减少了模板代码,让核心逻辑一目了然。策略模式在这里的本质,其实就是**“高阶函数”**的一个典型应用——一个函数(calculate)的内部逻辑,通过接收另一个函数(策略函数)作为参数来决定。
四、策略模式的用武之地
策略模式绝不仅仅用于计算价格。在任何需要根据不同条件执行不同算法或行为的场景,它都能大显身手。让我们再看两个例子。
场景一:表单验证 一个表单可能有多种验证规则(必填、邮箱格式、手机号格式、最小长度等)。我们可以为每一种规则定义一个策略。
// 技术栈:JavaScript (ES6+)
// 表单验证策略
const validationStrategies = {
required(value) {
return value !== undefined && value !== null && value.toString().trim() !== '';
},
email(value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
},
minLength(value, minLen) {
return value && value.length >= minLen;
}
};
// 验证器上下文
class FormValidator {
constructor() {
this.rules = []; // 存储验证规则 { strategy: fn, field: ‘字段名‘, args: [] }
}
addRule(field, strategyName, ...args) {
const strategyFn = validationStrategies[strategyName];
if (strategyFn) {
this.rules.push({ field, strategyFn, args });
}
return this; // 支持链式调用
}
validate(formData) {
const errors = [];
this.rules.forEach(rule => {
const value = formData[rule.field];
// 执行具体的验证策略
if (!rule.strategyFn(value, ...rule.args)) {
errors.push(`${rule.field} 验证失败`);
}
});
return errors;
}
}
// 使用
const validator = new FormValidator();
validator
.addRule('username', 'required')
.addRule('username', 'minLength', 6)
.addRule('email', 'email');
const userInput = { username: 'abc', email: 'wrong-email' };
const result = validator.validate(userInput);
console.log('表单验证结果:', result); // 输出:['username 验证失败', 'email 验证失败']
场景二:数据压缩与导出 一个系统可能需要支持将数据导出为不同的格式(JSON, CSV, XML),或者用不同的算法压缩(ZIP, GZIP, RAR)。每种格式或算法都是一个独立的策略。
// 技术栈:JavaScript (ES6+)
// 数据导出策略
const exportStrategies = {
toJSON(data) {
console.log(`正在将数据转换为美观的JSON字符串...`);
return JSON.stringify(data, null, 2);
},
toCSV(data) {
console.log(`正在将数据转换为CSV格式...`);
// 简化实现,假设data是对象数组
const headers = Object.keys(data[0]).join(',');
const rows = data.map(item => Object.values(item).join(','));
return [headers, ...rows].join('\n');
},
toXML(data) {
console.log(`正在将数据转换为XML格式...`);
// 简化实现
return `<data>${JSON.stringify(data)}</data>`;
}
};
// 导出器上下文
class DataExporter {
constructor(strategy = 'toJSON') {
this.setStrategy(strategy);
}
setStrategy(strategyName) {
this.strategyFn = exportStrategies[strategyName];
}
export(data, fileName) {
if (!this.strategyFn) {
throw new Error('未设置导出策略');
}
const content = this.strategyFn(data);
console.log(`内容已生成,准备保存为文件:${fileName}`);
// 此处模拟文件保存,实际中可能涉及Blob和下载链接创建
console.log('文件内容预览:\n', content.slice(0, 100) + '...');
return content;
}
}
// 使用
const data = [{ name: '张三', age: 25 }, { name: '李四', age: 30 }];
const exporter = new DataExporter('toCSV');
exporter.export(data, 'users.csv');
// 动态切换为JSON导出
exporter.setStrategy('toJSON');
exporter.export(data, 'users.json');
五、策略模式的优缺点与使用注意事项
就像任何工具一样,策略模式有其擅长的领域,也有其局限性。
优点:
- 极高的灵活性:算法可以自由切换,这是它最大的优点。就像给程序装上了可插拔的组件。
- 优秀的扩展性:增加新策略时,无需修改上下文或其他策略的代码,符合“开闭原则”。
- 良好的隔离性:每个策略类/函数只关注自己的算法逻辑,代码职责单一,易于理解和维护。
- 可以避免多重条件判断:它将原本可能冗长的
if-else或switch-case语句分散到各个策略中,使上下文代码更简洁。
缺点:
- 策略类/函数数量可能爆炸:如果算法只有寥寥几种且几乎不会变化,使用策略模式反而会增加系统的复杂度和类的数量。
- 客户端必须了解所有策略:使用方需要知道有哪些策略,以及它们之间的区别,才能做出正确的选择。这在一定程度上增加了使用成本。
- 通信开销:策略模式通常会增加程序中的对象数量。所有策略都需要实现相同的接口,这可能通过间接调用带来微小的性能开销,但在绝大多数场景下可忽略不计。
注意事项:
- 不要滥用:如果算法很稳定,很少发生变化,那么简单的条件判断可能更直接、更清晰。策略模式是为了应对“变化”而生的。
- 策略的创建与管理:当策略数量很多时,可以考虑结合工厂模式来创建策略对象,或者将策略的配置信息放在外部(如配置文件),使选择过程更动态化。
- 与状态模式的区别:初学者容易混淆策略模式和状态模式。简单来说,策略模式是客户端主动选择不同的算法来完成同一个任务;而状态模式是对象内部状态改变时,自动触发行为的改变,客户端通常不感知状态的存在。策略模式是“怎么做”的替换,状态模式是“是什么”的转换。
六、总结
策略模式是一种非常实用且强大的设计模式,它通过“分离变化”来提升代码的健壮性和可维护性。它将那些未来可能变化的算法部分抽取出来,封装成独立的、可互换的模块。在JavaScript这种灵活的语言中,我们可以用对象,也可以用更轻量的函数来实现它。
记住它的核心:定义算法家族,分别封装,让它们之间可以互相替换。此模式让算法的变化,不会影响到使用算法的客户。
当你下次再面对一堆令人头疼的if-else分支,并且预感到这些分支还会不断增长时,不妨停下来想一想:“这里是不是可以用策略模式来优化一下?” 学会识别并使用这种模式,能让你的代码在面对需求变更时更加从容不迫,真正写出易于扩展和维护的软件。
评论