通过实际代码示例理解SOLID原则
本指南通过具体的代码示例详细讲解了五个SOLID原则,展示了它们在真实项目及React应用中的运用方式。
在开发软件时,让代码正常运行仅是完成工作的一半。
一旦应用程序开始发展,其代码库往往变得更难阅读、更难修改,也更难以保持正常运行。对系统某一部分的微小改动就可能会悄悄破坏其他无关的部分。
这正是SOLID原则旨在解决的类型问题。
SOLID是一组来自面向对象编程的五项设计原则,它们有助于打造出如下特性的代码:
- 更易于维护
- 更易于测试
- 更易于扩展
- 耦合度更低
- 团队更容易理解
这些原则的缩写含义如下:
S — 单一职责原则
O — 开闭原则
L — 里氏替换原则
I — 接口隔离原则
D — 依赖倒置原则
让我们通过简单的例子来逐一讲解。
1. S — 单一职责原则
“一个类应该只有一个变更的理由。”
简而言之,每个类或模块都应围绕一项任务来构建。
以负责以下功能的User类为例:
- 存储用户数据
- 与数据库交互
- 发送邮件
- 生成报告
把这么多功能集中在一个类中实在不合理。
class User {
createUser() {
// create user
}
saveToDatabase() {
// save user
}
sendEmail() {
// send email
}
generateReport() {
// generate report
}
}
如果邮件发送逻辑需要修改,就必须编辑User类。
如果数据库处理方式发生变化,同样要修改这个类。
更合理的做法是将这些功能分开处理。
class User {
createUser() {
// create user
}
}
class UserRepository {
saveToDatabase() {
// database logic
}
}
class EmailService {
sendEmail() {
// email logic
}
}
class ReportService {
generateReport() {
// report logic
}
}
通过这种拆分,每个类现在只负责一项功能。
这有什么好处?
每当需求发生变化时,你就能立刻知道该修改哪段代码。
每个类只承担一项任务,也就意味着该类只有这一个变更理由。
2. O — 开闭原则
“软件实体应当对扩展开放,但对修改封闭。”
这句话听起来很抽象,但其背后的概念其实并不复杂。
其目标就是在不重复编写现有功能代码的情况下引入新功能。
以支付处理为例:
function processPayment(type, amount) {
if (type === "card") {
// card payment
} else if (type === "upi") {
// UPI payment
} else if (type === "paypal") {
// PayPal payment
}
}
现在假设你需要支持以下支付方式:
- Stripe
- Razorpay
- Apple Pay
- Google Pay
每增加一种支付方式,该功能就会变得更为庞大且混乱。
更好的策略是为每种支付方式创建独立的类。
class CardPayment {
pay(amount) {
console.log(`Card payment: ${amount}`);
}
}
class UpiPayment {
pay(amount) {
console.log(`UPI payment: ${amount}`);
}
}
class PaypalPayment {
pay(amount) {
console.log(`PayPal payment: ${amount}`);
}
}
采用这种结构后,添加新的支付选项时无需修改已编写的类。
class StripePayment {
pay(amount) {
console.log(`Stripe payment: ${amount}`);
}
}
原有的实现方式保持不变。
核心理念
应通过添加新代码来扩展系统,而非反复修改已稳定的代码。
3. L — 里斯科夫替换原则
“子类型应能被其基类型替代。”
该原则的核心内容如下:
如果
B是A的子类型,那么应在所有使用A的地方替换为B,应用程序仍能正常运行。
一个经典的示例是鸟类。
假设你定义了:
class Bird {
fly() {
console.log("Flying");
}
}
然后对其进行扩展:
class Sparrow extends Bird {
fly() {
console.log("Sparrow is flying");
}
}
目前一切正常。
但是企鹅会怎样呢?
class Penguin extends Bird {
fly() {
throw new Error("Penguins cannot fly");
}
}
这暴露了设计中的缺陷。
如果代码的其他部分假设所有Bird类都能飞行,那么传入Penguin实例就会打破这一预期。
更合理的设计是将飞行行为单独提取出来。
class Bird {
eat() {
console.log("Eating");
}
}
class FlyingBird extends Bird {
fly() {
console.log("Flying");
}
}
class Sparrow extends FlyingBird {}
class Penguin extends Bird {}
这样,企鹅就不再被迫实现那些不适用于它们的功能了。
经验教训
避免构建逻辑上站不住脚的继承层次结构。
子类必须在其父类能够正常工作的任何地方都能正确运行。
4. I — 接口隔离原则
“不应强迫客户端依赖他们不使用的方法。”
想象这样一个构建的接口:
print()
scan()
fax()
copy()
现在想象一下一种只能进行打印操作的普通打印机。
为什么非要要求这种打印机同时实现scan()、fax()和copy()功能呢?
这并没有合理的理由。
更好的做法是根据每种功能的实际作用来划分接口。
例如:
class Printer {
print() {
console.log("Printing...");
}
}
class Scanner {
scan() {
console.log("Scanning...");
}
}
class FaxMachine {
fax() {
console.log("Faxing...");
}
}
一个简单的打印机只需实现它真正支持的功能即可。
在现代 JavaScript 中
JavaScript 并不像 Java 或 C# 那样拥有正式的接口,但核心理念依然适用。
你可以通过以下方式将其付诸实践:
- 小型模块
- 小型 API
- 组合设计
- 独立的服务
- 专注的 React 组件
而不是将所有功能都打包到一个庞大的服务中:
userService.getUser();
userService.createUser();
userService.deleteUser();
userService.sendEmail();
userService.generateReport();
将职责分开:
userService.getUser();
userService.createUser();
emailService.sendEmail();
reportService.generateReport();
经验教训
绝不要强迫某个组件、类或模块去使用它并不需要的功能。
5. D — 依赖倒置原则
“高层模块不应直接依赖于底层模块,二者都应依赖抽象。”
这一原则的存在旨在减少紧密耦合。
以这个例子为例:
class MongoDB {
save(data) {
console.log("Saving to MongoDB");
}
}
class UserService {
constructor() {
this.database = new MongoDB();
}
saveUser(user) {
this.database.save(user);
}
}
问题在于UserService直接与MongoDB相连。
日后若要更换为PostgreSQL,就必须回头修改UserService本身。
更好的做法是注入依赖关系。
class UserService {
constructor(database) {
this.database = database;
}
saveUser(user) {
this.database.save(user);
}
}
这样就可以自由地传入不同的数据库实现。
const mongoDB = new MongoDB();
const userService = new UserService(mongoDB);
之后更换数据库也变得非常简单:
const postgresDB = new PostgreSQL();
const userService = new UserService(postgresDB);
UserService 永远无需知道其背后使用的是哪个数据库。
这有什么好处?
这样能让代码具备以下特点:
- 更易于测试
- 更便于替换
- 耦合度更低
- 长期维护更为方便
实际项目中的 SOLID 原则
遵循 SOLID 原则并不意味着要为每一件事都创建一个专门的类。
这个区别非常重要。
SOLID 原则强调的是做出合理的架构设计决策,而非仅仅为了抽象而堆砌抽象层。
例如在 React 应用中,当你将不同功能分离时,这些理念会自然体现出来:
Components
↓
Hooks
↓
Services
↓
API Layer
↓
Database
组件的主要职责是处理用户界面。
自定义钩子可以负责可复用的状态逻辑。
API 服务可以处理 HTTP 通信。
后端则负责业务逻辑的实现。
数据库层负责数据的持久化存储。
采用这种划分方式,随着应用程序的扩展,其结构依然易于管理。
SOLID原则与React
尽管SOLID原则源自面向对象设计,但其部分理念同样适用于React开发。
单一职责原则
不要创建一个功能过于庞大的组件:
Dashboard.jsx
试图包办所有功能,而应将其拆分为:
Dashboard
UserProfile
Statistics
RecentOrders
Notifications
这样每个部分的功能就会更加明确。
开闭原则
设计可复用的组件,通过属性添加新功能,而非每次需要新变体时就重写其内部代码。
<Button variant="primary">
Save
</Button>
<Button variant="danger">
Delete
</Button>
依赖倒置原则
不要将组件直接绑定到某种特定的数据获取方式,而应将API逻辑放在服务或钩子函数中。
const users = await userService.getUsers();
该组件本身无需了解请求在底层是如何处理的。
为何 SOLID 很重要
SOLID 带来的实际好处与让代码看起来更优雅并无太大关系。
它的意义在于降低未来修改的难度。
想象这样一个项目:
10 名开发人员 → 100 个功能 → 数千个文件 → 持续的变更
如果没有合理的设计,一个微小的需求就可能导致一系列无关的故障。
通过清晰的模块划分和松散的耦合,变更将变得更为可预测。
SOLID 能帮助你:
- 减少代码重复
- 降低紧密耦合度
- 提高可测试性
- 让功能更易于扩展
- 简化调试过程
- 提升团队协作效率
- 保持大型应用程序的可维护性
SOLID并不等同于过度设计
这或许是其中最重要的一点。
不要将SOLID当作一套僵化的检查清单来使用。
以一个简单的函数为例:
function add(a, b) {
return a + b;
}
它并不需要五个类、三个接口以及一个依赖注入容器来支撑。
其目的从来不是让代码变得更加复杂。
真正的目的是让真正复杂的代码更易于处理。
只有当系统的复杂性确实需要额外的结构时,才应考虑应用SOLID原则。
简要总结
S — 单一职责原则:一个类或模块应只承担一项主要职责。
O — 开闭原则:通过扩展功能来实现变化,而非反复修改已能正常工作的代码。
L — 利斯科夫替换原则:子类应在父类可使用的任何场景下都能正常工作。
I — 接口隔离原则:不要让客户端依赖它们并不需要的功能。
D — 依赖倒置原则:应依赖抽象,而非直接使用具体的实现。
总结
SOLID原则并非为了让人们在面试时记住五个定义而存在的。
它是一种思考软件设计的思维方式。
在编写代码时,不妨暂停并思考以下问题:
这个模块是否试图同时处理太多任务?
添加新功能是否会迫使我们重写现有代码?
这里是否引入了不必要的依赖关系?
这段代码能否轻松地进行测试?
是否有某种力量在强迫软件支持它其实并不需要的功能?
思考这些问题往往比记住每个SOLID原则的含义更为重要。
优秀的软件并不仅仅是目前能够正常运行的软件。
优秀的软件是那种能够持续优雅地演变,而不会变成噩梦的软件。
相关阅读
- 当AI编写你的React应用却忽视了整洁代码原则 — 了解七种整洁代码习惯——DRY原则、单一职责原则、防护条款等——AI生成的React代码常常违反这些原则,以及如何加以修正。