首页 / 文章 / 通过实际代码示例理解SOLID原则

通过实际代码示例理解SOLID原则

本指南通过具体的代码示例详细讲解了五个SOLID原则,展示了它们在真实项目及React应用中的运用方式。

1903 词

在开发软件时,让代码正常运行仅是完成工作的一半。

一旦应用程序开始发展,其代码库往往变得更难阅读、更难修改,也更难以保持正常运行。对系统某一部分的微小改动就可能会悄悄破坏其他无关的部分。

这正是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 — 里斯科夫替换原则

“子类型应能被其基类型替代。”

该原则的核心内容如下:

如果BA的子类型,那么应在所有使用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原则的含义更为重要。

优秀的软件并不仅仅是目前能够正常运行的软件。

优秀的软件是那种能够持续优雅地演变,而不会变成噩梦的软件。

相关阅读

  • 为什么前端抽象会悄然演变成技术债务 — 了解为何过早使用前端抽象会增加隐性复杂性,以及如何判断共享组件、钩子或工具函数是否真的值得开发。
  • 六种用于解决意大利面代码的JavaScript设计模式 — 阐述了策略、工厂、观察者、适配器、组合和管道这六种实用的JavaScript模式,它们能将混乱的代码转化为易于维护的结构。