高级进阶之JavaScript中的SOLID原则

1,409 阅读8分钟

前言

image.png

在软件开发领域,构建可维护、可扩展和可重用的软件系统一直是开发人员追求的目标。然而,随着项目的增长和复杂性的提高,代码变得越来越难以理解、修改和扩展。为了应对这些挑战,面向对象设计中的SOLID原则应运而生。

SOLID原则为开发人员提供了一套有力的工具和指导原则,帮助他们构建可维护、可扩展和可重用的软件系统。通过遵循这些原则,我们能够编写高质量的代码,提高开发效率,降低维护成本,并为未来的功能扩展奠定坚实的基础。在接下来的文章中,我们将深入探讨每个原则的概念和实践,并展示它们如何共同协作,构建出优秀的软件系统。

SOLID原则是什么?

  • 单一职责原则(Single Responsibility Principle,SRP):一个类应该只有一个引起它变化的原因。换句话说,一个类应该只有一个职责。这样可以提高类的内聚性,使其更易于理解、修改和测试。
  • 开放封闭原则(Open-Closed Principle,OCP):软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。意味着在添加新功能时,不应该修改现有的代码,而是通过扩展现有代码来实现新功能。
  • 里式替换原则(Liskov Substitution Principle,LSP):子类应该能够替换掉父类并且不会破坏程序的正确性。也就是说,子类应该能够在不改变程序正确性的前提下扩展父类的功能。
  • 接口隔离原则(Interface Segregation Principle,ISP):客户端不应该强迫依赖于它们不使用的接口。接口应该精确地定义客户端所需的功能,避免定义冗余的接口。
  • 依赖倒置原则(Dependency Inversion Principle,DIP):高层模块不应该依赖于低层模块,两者都应该依赖于抽象。抽象不应该依赖于具体实现细节,具体实现细节应该依赖于抽象。

单一职责原则

一个类、一个模块或一个函数应该只负责一个角色。因此,它应该只有一个改变的原因。

单一职责原则是SOLID原则中最简单的原则之一。然而,开发人员经常误解它,认为一个模块应该只做一件事情。

让我们来考虑一个简单的例子来理解这个原则。下面的JavaScript代码片段有一个名为ManageEmployee的类,以及几个用于管理员工的函数。

class ManageEmployee {

  constructor(private http: HttpClient)
  SERVER_URL = 'http://localhost:5000/employee';

  getEmployee (empId){
     return this.http.get(this.SERVER_URL + `/${empId}`);
  }

  updateEmployee (employee){
     return this.http.put(this.SERVER_URL + `/${employee.id}`,employee);
  }

  deleteEmployee (empId){
     return this.http.delete(this.SERVER_URL + `/${empId}`);
  }

  calculateEmployeeSalary (empId, workingHours){
    var employee = this.http.get(this.SERVER_URL + `/${empId}`);
    return employee.rate * workingHours;
  }

}

乍一看,之前的代码似乎完全没问题,很多开发者也会采用同样的方法。然而,由于它负责两个角色,这个类违反了单一职责原则。getEmployee()、updateEmployee()和deleteEmployee()函数直接与人力资源管理相关,而calculateEmployeeSalary()函数与财务管理相关。

将来,如果需要为人力资源或财务部门更新功能,将不得不更改ManageEmployee类,从而影响到两个角色。因此,ManageEmployee类违反了单一职责原则。你需要将与人力资源和财务部门相关的功能分离,以使代码符合单一职责原则。以下代码示例演示了这一点。

class ManageEmployee {

  constructor(private http: HttpClient)
  SERVER_URL = 'http://localhost:5000/employee';

  getEmployee (empId){
     return this.http.get(this.SERVER_URL + `/${empId}`);
  }

  updateEmployee (employee){
     return this.http.put(this.SERVER_URL + `/${employee.id}`,employee);
  }

  deleteEmployee (empId){
     return this.http.delete(this.SERVER_URL + `/${empId}`);
  }

}

class ManageSalaries {

  constructor(private http: HttpClient)
  SERVER_URL = 'http://localhost:5000/employee';

  calculateEmployeeSalary (empId, workingHours){
    var employee = this.http.get(this.SERVER_URL + `/${empId}`);
    return employee.rate * workingHours;
  }

}

 开闭原则

函数、模块和类应该是可扩展的,但不可修改的。

在实施大规模应用程序时,遵循这一重要原则非常关键。根据这一原则,我们能够轻松地向应用程序添加新功能,但不应该对现有代码引入破坏性的变更。

例如,假设我们已经实现了一个名为calculateSalaries()的函数,它使用一个包含定义的职位角色和小时工资的数组来计算工资。

class ManageSalaries {
  constructor() {
    this.salaryRates = [
      { id: 1, role: 'developer', rate: 100 },
      { id: 2, role: 'architect', rate: 200 },
      { id: 3, role: 'manager', rate: 300 },
    ];
  }

  calculateSalaries(empId, hoursWorked) {
    let salaryObject = this.salaryRates.find((o) => o.id === empId);
    return hoursWorked * salaryObject.rate;
  }
}

const mgtSalary = new ManageSalaries();
console.log("Salary : ", mgtSalary.calculateSalaries(1, 100));

直接修改salaryRates数组将违反开闭原则。例如,假设您需要扩展新角色的薪资计算。在这种情况下,您需要创建一个单独的方法,将薪资率添加到salaryRates数组中,而不对原始代码进行修改。

class ManageSalaries {
  constructor() {
    this.salaryRates = [
      { id: 1, role: 'developer', rate: 100 },
      { id: 2, role: 'architect', rate: 200 },
      { id: 3, role: 'manager', rate: 300 },
    ];
  }

  calculateSalaries(empId, hoursWorked) {
    let salaryObject = this.salaryRates.find((o) => o.id === empId);
    return hoursWorked * salaryObject.rate;
  }

  addSalaryRate(id, role, rate) {
    this.salaryRates.push({ id: id, role: role, rate: rate });
  }
}

const mgtSalary = new ManageSalaries();
mgtSalary.addSalaryRate(4, 'developer', 250);
console.log('Salary : ', mgtSalary.calculateSalaries(4, 100));

里氏替换原则

设P(y)是关于类型为A的对象y可证明的属性。那么对于类型为B的对象x,其中B是A的子类型,P(x)应该为真。

在互联网上,你会找到关于Liskov替换原则的不同定义,但它们都暗示着相同的意义。简单来说,Liskov原则指出,如果子类在应用程序中产生了意外行为,我们就不应该用子类替换父类。

例如,考虑一个名为Animal的类,其中包含一个名为eat()的函数。

class Animal{
  eat() {
    console.log("Animal Eats")
  }
}

现在我将Animal类扩展为一个名为Bird的新类,其中包含一个名为fly()的函数。

class Bird extends Animal{
  fly() {
    console.log("Bird Flies")
  }
}

var parrot = new Bird();
parrot.eat();
parrot.fly();

在之前的例子中,我创建了一个名为parrot的对象,它是从Bird类继承而来的,并调用了eat()和fly()方法。由于鹦鹉能够执行这两个动作,将Animal类扩展到Bird类并不违反Liskov原则。

现在让我们进一步扩展Bird类,并创建一个名为Ostrich的新类。

class Ostrich extends Bird{
  console.log("Ostriches Do Not Fly")
}

var ostrich = new Ostrich();
ostrich.eat();
ostrich.fly();

这个对Bird类的扩展违反了Liskov原则,因为鸵鸟不能飞行——这可能会在应用程序中产生意外的行为。解决这个问题的最佳方法是从Animal类扩展Ostrich类。

class Ostrich extends Animal{

  walk() {
    console.log("Ostrich Walks")
  }

}

接口隔离原则

客户不应被迫依赖于他们永远不会使用的接口。

这个原则与接口有关,重点是将大的接口分解为小的接口。例如,假设你要去驾校学习开车,他们给你一大堆关于开车、卡车和火车的指令。由于你只需要学习开车,不需要其他所有的信息。驾校应该将指令分开,只给你关于汽车的指令。

由于JavaScript不支持接口,因此在基于JavaScript的应用程序中采用这一原则较为困难。然而,我们可以使用JavaScript组合来实现这一点。组合允许开发人员向类中添加功能,而无需继承整个类。例如,假设有一个名为DrivingTest的类,其中包含两个名为startCarTest和startTruckTest的函数。如果我们为CarDrivingTest和TruckDrivingTest扩展DrivingTest类,我们必须强制这两个类都实现startCarTest和startTruckTest函数。

Class DrivingTest {
  constructor(userType) {
    this.userType = userType;
  }

  startCarTest() {
    console.log(“This is for Car Drivers”’);
  }

  startTruckTest() {
    console.log(“This is for Truck Drivers”);
  }
}

class CarDrivingTest extends DrivingTest {
  constructor(userType) {
    super(userType);
  }

  startCarTest() {
    returnCar Test Started”;
  }

  startTruckTest() {
    return null;
  }
}

class TruckDrivingTest extends DrivingTest {
  constructor(userType) {
    super(userType);
  }

  startCarTest() {
    return null;
  }

  startTruckTest() {
    returnTruck Test Started”;
  }
}

const carTest = new CarDrivingTest(carDriver );
console.log(carTest.startCarTest());
console.log(carTest.startTruckTest());

const truckTest = new TruckDrivingTest( ruckdriver );
console.log(truckTest.startCarTest());
console.log(truckTest.startTruckTest());

然而,这种实现违反了接口隔离原则,因为我们强制两个扩展类都实现了两个功能。我们可以通过使用组合来为所需的类附加功能来解决这个问题,如下面的示例所示。

Class DrivingTest {
  constructor(userType) {
    this.userType = userType;
  }
}

class CarDrivingTest extends DrivingTest {
  constructor(userType) {
    super(userType);
  }
}

class TruckDrivingTest extends DrivingTest {
  constructor(userType) {
    super(userType);
  }
}

const carUserTests = {
  startCarTest() {
    returnCar Test Started’;
  },
};

const truckUserTests = {
  startTruckTest() {
    returnTruck Test Started’;
  },
};

Object.assign(CarDrivingTest.prototype, carUserTests);
Object.assign(TruckDrivingTest.prototype, truckUserTests);

const carTest = new CarDrivingTest(carDriver );
console.log(carTest.startCarTest());
console.log(carTest.startTruckTest()); // Will throw an exception

const truckTest = new TruckDrivingTest( ruckdriver );
console.log(truckTest.startTruckTest());
console.log(truckTest.startCarTest()); // Will throw an exception

现在,carTest.startTruckTest();会抛出一个异常,因为startTruckTest()函数没有分配给CarDrivingTest类。

依赖倒置原则

高级模块应该使用抽象化。然而,它们不应该依赖于低级模块。

依赖倒置的核心是解耦你的代码。遵循这个原则将使你的应用在最高层面上具备灵活性,可以轻松扩展和修改,而不会出现任何问题。

关于JavaScript,我们不需要考虑抽象,因为JavaScript是一种动态语言。然而,我们需要确保高层模块不依赖于低层模块。

让我们来考虑一个简单的例子来解释依赖倒置是如何工作的。假设你在应用程序中使用了Yahoo邮件API,现在你需要将其更改为Gmail API。如果你在控制器中没有使用依赖倒置,就像下面的示例一样,你需要对控制器进行一些更改。这是因为多个控制器使用了Yahoo API,你需要找到每个实例并进行更新。

class EmailController { 
  sendEmail(emailDetails) { 
    // Need to change this line in every controller that uses YahooAPI.const response = YahooAPI.sendEmail(emailDetails); 
    if (response.status == 200) { 
       return true;
    } else {
       return false;
    }
  }
}

依赖倒置原则能够帮助开发者避免这种昂贵的错误,通过将电子邮件API处理部分移动到一个独立的控制器中。这样,只需要在电子邮件API发生变化时修改该控制器即可。

class EmailController { 
  sendEmail(emailDetails) { 
    const response = EmailApiController.sendEmail(emailDetails);   
    if (response.status == 200) { 
       return true;
    } else {
       return false;
    }
  }
}

class EmailApiController {
  sendEmail(emailDetails) {
    // Only need to change this controller. return YahooAPI.sendEmail(emailDetails);
  }
}

 结论

在本文中,我们讨论了SOLID原则在软件设计中的重要性以及如何在JavaScript应用程序中采用这些概念。作为开发人员,理解并运用这些核心概念对我们的应用程序至关重要。有时,在处理小型应用程序时,这些原则的好处可能并不明显,但一旦开始处理大型项目,您肯定会意识到它们所带来的差异。