JavaScript 的 SOLID 原则

OOP 范式的引入使继承、多态、抽象和封装等关键编程概念流行起来。OOP 迅速成为一种广泛接受的编程范式,并在 Java、C++、C#、JavaScript 等多种语言中实现。随着时间的推移,OOP 系统变得越来越复杂,但其软件仍然能够抵御变化。为了提高软件的可扩展性并减少代码僵化,Robert C. Martin(又名 Uncle Bob)在 21 世纪初引入了 SOLID 原则。

SOLID 是一组原则的首字母缩写,包括单一职责原则、开放封闭原则、里氏替换原则、接口隔离原则和依赖倒置原则,可帮助软件工程师设计和编写可维护、可扩展且灵活的代码。其目的是什么?提高遵循面向对象编程 (OOP) 范式开发的软件的质量。

在本文中,我们将深入研究 SOLID 的所有原则,并说明如何使用最流行的 Web 编程语言之一 JavaScript 实现它们。

单一职责原则(SRP)

SOLID 的首字母代表单一责任原则。该原则建议一个类或模块只执行一个角色。

简而言之,一个类应该具有单一职责或单一更改原因。如果一个类处理多个功能,则在不影响其他功能的情况下更新一个功能会变得很棘手。随后的复杂性可能会导致软件性能故障。为了避免此类问题,我们应该尽力编写关注点分离的模块化软件。

如果一个类有太多职责或功能,修改起来就会很麻烦。通过使用单一职责原则,我们可以编写模块化、更易于维护且不易出错的代码。以一个人的模型为例:

class Person {
    constructor(name, age, height, country){
      this.name = name
      this.age = age
      this.height = height
      this.country = country
  }
  getPersonCountry(){
    console.log(this.country)    
  }
  greetPerson(){
    console.log("Hi " + this.name)
  }
  static calculateAge(dob) { 
    const today = new Date(); 
    const birthDate = new Date(dob);

    let age = today.getFullYear() - birthDate.getFullYear(); 
    const monthDiff = today.getMonth() - birthDate.getMonth();

    if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { 
      age--; 
    }
    return age; 
  } 
}

上面的代码看起来没什么问题,对吧?其实不然。示例代码违反了单一职责原则。Person 类不仅是可以创建其他 Person 实例的唯一模型,还承担着其他职责,例如“calculateAge、greetPerson 和 getPersonCountry”。

“Person”类处理的这些额外职责使得更改代码的某个方面变得很困难。例如,如果您尝试重构“calculateAge”,则可能还不得不重构“Person”模型。根据我们代码库的紧凑性和复杂程度,重新配置代码而不导致错误可能会很困难。

让我们尝试改正这个错误。我们可以将职责分成不同的类,如下所示:

class Person {
    constructor(name, dateOfBirth, height, country){
      this.name = name
      this.dateOfBirth = dateOfBirth
      this.height = height
      this.country = country
  }
}

class PersonUtils {
  static calculateAge(dob) { 
    const today = new Date(); 
    const birthDate = new Date(dob);

    let age = today.getFullYear() - birthDate.getFullYear(); 
    const monthDiff = today.getMonth() - birthDate.getMonth();

    if(monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { 
      age--; 
    }
    return age; 
  } 
}

const person = new Person("John", new Date(1994, 11, 23), "6ft", "USA"); 
console.log("Age: " + PersonUtils.calculateAge(person.dateOfBirth));

class PersonService {
  getPersonCountry(){
    console.log(this.country)    
  }
  greetPerson(){
    console.log("Hi " + this.name)
  }
}

从上面的示例代码中可以看出,我们已经分离了职责。`Person` 类现在是一个模型,我们可以使用它来创建一个新的 person 对象。而 `PersonUtils` 类只有一个职责 — 计算一个人的年龄。`PersonService` 类负责处理问候并向我们显示每个人的国家/地区。

如果我们愿意,我们仍然可以进一步减少这个过程。按照 SRP,我们希望将类的职责分离到最低限度,这样当出现问题时,重构和调试就可以轻松完成。

通过将功能划分为单独的类,我们遵守单一责任原则并确保每个类负责应用程序的特定方面。

在我们讨论下一个原则之前,应该注意,遵守 SRP 并不意味着每个类都应该包含单一方法或功能。

然而,坚持单一职责原则意味着我们应该有意识地将功能分配给类。类执行的所有操作都应该在各个方面紧密相关。我们必须注意不要让多个类分散在各处,并且我们应尽一切努力避免代码库中出现臃肿的类。

开放封闭原则(OCP)

开放封闭原则规定软件组件(类、函数、模块等)应该对扩展开放,对修改封闭。我知道你在想什么——是的,这个想法乍一看可能有点矛盾。但 OCP 只是要求软件的设计方式允许扩展,而不必修改源代码。

OCP 对于维护大型代码库至关重要,因为此准则可让您引入新功能,而几乎不会破坏代码。当出现新需求时,您不应修改现有的类或模块,而应通过添加新组件来扩展相关类。在执行此操作时,请务必检查新组件是否不会给系统带来任何错误。

OC 原则可以在 JavaScript 中使用 ES6+ 类的 `Inheritance` 特性来实现。

以下代码片段说明了如何使用前面提到的 ES6+ class 关键字在 JavaScript 中实现开放-封闭原则:

class Rectangle { 
  constructor(width, height) {
    this.width = width; 
    this.height = height; 
  } 
  area() { 
  return this.width * this.height; 
  } 
} 

class ShapeProcessor { 
    calculateArea(shape) { 
    if (shape instanceof Rectangle) { 
    return shape.area(); 
    } 
  }
}  
const rectangle = new Rectangle(10, 20); const shapeProcessor = new ShapeProcessor(); console.log(shapeProcessor.calculateArea(rectangle));

上面的代码运行良好,但仅限于计算矩形的面积。现在想象一下有一个新的计算需求。例如,假设我们需要计算一个圆的面积。我们必须修改“shapeProcessor”类来满足这一要求。但是,按照 JavaScript ES6+ 标准,我们可以扩展此功能以计算新形状的面积,而不必修改“shapeProcessor”类。

我们可以这样做:

class Shape {
  area() {
    console.log("Override method area in subclass");
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  area() {
    return Math.PI * this.radius * this.radius;
  }
}

class ShapeProcessor {
  calculateArea(shape) {
    return shape.area();
  }
}

const rectangle = new Rectangle(20, 10);
const circle = new Circle(2);
const shapeProcessor = new ShapeProcessor();

console.log(shapeProcessor.calculateArea(rectangle));
console.log(shapeProcessor.calculateArea(circle));

在上面的代码片段中,我们使用“extends”关键字扩展了“Shape”类的功能。在每个子类中,我们重写了“area()”方法的实现。遵循这一原则,我们可以添加更多形状和处理区域,而无需修改“ShapeProcessor”类的功能。

OCP 为何如此重要?

  • 减少错误:OCP 通过避免系统修改来帮助避免大型代码库中的错误。
  • 鼓励软件适应性:OCP 还提高了在不破坏或更改源代码的情况下向软件中添加新功能的简易性。
  • 测试新功能:OCP 促进代码扩展而不是修改,使得新功能更容易作为一个单元进行测试,而不会影响整个代码库。
  • 里氏替换原则

    里氏替换原则规定,子类的对象应该能够替换超类的对象而不会破坏代码。让我们通过一个例子来解释一下它的工作原理:如果 L 是 P 的子类,那么 L 的对象应该替换 P 的对象而不会破坏系统。这只是意味着子类应该能够以不破坏系统的方式覆盖超类方法。

    在实践中,里氏替代原则确保遵守以下条件:

  • 子类应该覆盖父类的方法而不破坏代码
  • 子类不应偏离父类的行为,这意味着子类只能添加功能,但不能改变或删除父类功能
  • 与父类实例一起工作的代码应该与子类的实例一起工作,而不需要知道该类已经发生了变化
  • 现在是时候用 JavaScript 代码示例来说明里氏替换原则了。请看一看:

    class Vehicle {
      OnEngine(){
        console.log("Engine is steaming!")
      }
    }
    
    class Car extends Vehicle {
      // you can invoke the super class OnEngine method and implement how Cars On engine
    }
    class Bicycle extends Vehicle {
      OnEngine(){
        throw new Error("Bicycles technically don't have an engine")
      }
    }
    
    const myCar = new Car();
    const myBicycle = new Bicycle();
    
    myCar.OnEngine();
    myBicycle.OnEngine();

    在上面的代码片段中,我们创建了两个子类(Bicycle 和 Car)和一个超类(Vehicle)。出于本文的目的,我们为超类实现了一个方法(OnEngine)。

    LSP 的核心条件之一是子类应该在不破坏代码的情况下覆盖父类的功能。记住这一点,让我们看看我们刚刚看到的代码片段是如何违反里氏替换原则的。实际上,`Car` 有引擎,可以 `ON` 引擎,但自行车从技术上讲没有引擎,因此无法 `ON` 引擎。因此,`Bicycle` 无法在不破坏代码的情况下覆盖 `Vehicle` 类中的 `OnEngine` 方法。

    现在,我们已经确定了违反里氏替换原则的代码部分。`Car` 类可以覆盖超类中的 `OnEngine` 功能,并以与其他交通工具(例如飞机)区分开来的方式实现它,并且代码不会中断。`Car` 类满足里氏替换原则。

    在下面的代码片段中,我们将说明如何构造代码以符合里氏替换原则:

    class Vehicle { 
      move() {
       console.log("The vehicle is moving."); 
      } 
    }

    这是一个具有通用功能“移动”的“车辆”类的基本示例。人们普遍认为所有车辆都会移动;它们只是通过不同的机制移动。我们将要说明 LSP 的一种方法是覆盖“move()”方法,并以描述特定车辆(例如“汽车”)如何移动的方式实现它。

    为此,我们将创建一个“Car”类,该类扩展“Vehicle”类并重写移动方法来适应汽车的移动,如下所示:

    class Car extends Vehicle {
      move(){
        console.log("Car is running on four wheels")
      }
    }

    我们仍然可以在另一个子交通工具类(例如飞机)中实现移动方法。

    以下是我们的操作方法:

    class Airplane extends Vehicle {
        move(){
          console.log("Airplane is flying...")
      }
    }

    在上面的这两个例子中,我们说明了继承和方法覆盖等关键概念。

    注意:允许子类实现父类中已定义的方法的编程特性称为方法覆盖。

    让我们做一些整理工作,把所有东西放在一起,就像这样:

    class Vehicle { 
      move() {
       console.log("The vehicle is moving."); 
      } 
    }
    
    class Car extends Vehicle {
      move(){
        console.log("Car is running on four wheels")
      }
      getSeatCapacity(){
      }
    }
    
    class Airplane extends Vehicle {
        move(){
          console.log("Airplane is flying...")
      }
    }
    
    const car = new Car();
    const airplane = new Airplane();
    
    car.move() // output: Car is running on four wheels

    现在,我们有 2 个子类从父类继承并重写单个功能,并根据其要求实现它。这种新实现不会破坏代码。

    接口隔离原则 (ISP)

    接口隔离原则规定,不应强迫任何客户端依赖其不使用的接口。它要求我们创建与特定客户端相关的更小、更具体的接口,而不是拥有一个迫使客户端实现其不需要的方法的庞大单片接口。

    保持接口紧凑可使代码库更易于调试、维护、测试和扩展。如果没有 ISP,大型接口中某一部分的更改可能会迫使代码库中不相关的部分也发生更改,从而导致我们执行代码重构,在大多数情况下,根据代码库的大小,这可能是一项艰巨的任务。

    JavaScript 与 Java 等基于 C 语言的编程语言不同,它没有内置对接口的支持。不过,JavaScript 中有一些技术可以实现接口。

    接口是类必须实现的一组方法签名。

    在 JavaScript 中,你可以将接口定义为具有方法名称和函数签名的对象,如下所示:

    const InterfaceA = {
      method: function (){}
    }

    要在 JavaScript 中实现接口,请创建一个类并确保它包含与接口中指定的名称和签名相同的方法:

    class LogRocket {
      method(){
        console.log("This is a method call implementing an interface”)
      }
    }

    现在我们已经搞清楚了如何在 JavaScript 中创建和使用接口。接下来我们需要做的是说明如何在 JavaScript 中分离接口,以便我们了解它们如何组合在一起并使代码更易于维护。

    在以下示例中,我们将使用打印机来说明接口隔离原则。

    假设我们有打印机、扫描仪和传真机,让我们创建一个定义这些对象功能的接口:

    const printerInterface = {
      print: function(){
      }
    }
    
    const scannerInterface = {
      scan: function(){
      }
    }
    
    const faxInterface = {
        fax: function(){
      }
    }

    在上面的代码中,我们创建了一个分离或隔离的接口列表,而不是使用一个定义所有这些功能的大型接口。通过将这些功能分解为更小的部分和更具体的接口,我们允许不同的客户端只实现他们需要的方法,而将所有其他部分排除在外。

    下一步,我们将创建实现这些接口的类。遵循接口隔离原则,每个类将仅实现其所需的方法。

    如果我们想要实现一个只能打印文档的基本打印机,我们可以通过 `printerInterface` 实现 `print()` 方法,如下所示:

    class Printer {
      print(){
        console.log(“printing document”)
      }
    }

    该类仅实现了 `PrinterInterface`。它不实现 `scan` 或 `fax` 方法。通过遵循接口隔离原则,客户端(在本例中为 `Printer` 类)降低了其复杂性并提高了软件的性能。

    依赖倒置原则 (DIP)

    现在来看看我们的最后一个原则:依赖倒置原则。该原则指出,高级模块(业务逻辑)应该依赖于抽象,而不是直接依赖于低级模块(具体化)。它有助于我们减少代码依赖,并为开发人员提供灵活性,使他们能够在更高级别上修改和扩展应用程序,而不会遇到复杂情况。

    为什么依赖倒置原则更倾向于抽象而不是直接依赖?这是因为引入抽象可以减少更改的潜在影响,提高可测试性(模拟抽象而不是具体实现),并在代码中实现更高的灵活性。这条规则使得通过模块化方法扩展软件组件变得更加容易,也有助于我们在不影响高级逻辑的情况下修改低级组件。

    遵守 DIP 可使代码更易于维护、扩展和扩展,从而防止因代码更改而可能出现的错误。它建议开发人员在类之间使用松耦合而不是紧耦合。通常,通过采用优先考虑抽象而不是直接依赖的思维方式,团队将获得灵活性,可以适应和添加新功能或更改旧组件,而不会造成连锁中断。在 JavaScript 中,我们能够使用依赖注入方法实现 DIP,如下所示:

    class MySQLDatabase {
      connect() {
        console.log('Connecting to MySQL database...');
      }
    }
    
    class MongoDBDatabase {
      connect() {
        console.log('Connecting to MongoDB database...');
      }
    }
    
    class Application {
      constructor(database) {
        this.database = database;
      }
    
      start() {
        this.database.connect();
      }
    }
    
    const mySQLDatabase = new MySQLDatabase();
    const mySQLApp = new Application(mySQLDatabase);
    mySQLApp.start(); 
    
    const mongoDatabase = new MongoDBDatabase();
    const mongoApp = new Application(mongoDatabase);
    mongoApp.start();

    在上面的基本示例中,“Application”类是依赖于数据库抽象的高级模块。我们创建了两个数据库类:“MySQLDatabase”和“MongoDBDatabase”。数据库是低级模块,它们的实例被注入到“Application”运行时中,而无需修改“Application”本身。

    结论

    SOLID 原则是可扩展、可维护且稳健的软件设计的基本构建块。这套原则可帮助开发人员编写简洁、模块化且适应性强的代码。

    SOLID 原则促进了功能凝聚力、无需修改的可扩展性、对象替换、接口分离以及对具体依赖的抽象。务必将 SOLID 原则集成到您的代码中,以防止出现错误并获得所有好处。

    LogRocket:通过理解上下文更轻松地调试 JavaScript 错误

    调试代码总是一项繁琐的任务。但是你对错误的了解越多,修复它们就越容易。

    LogRocket 可让您以新颖独特的方式了解这些错误。我们的前端监控解决方案可跟踪用户与您的 JavaScript 前端的互动情况,让您能够准确了解用户的操作导致错误。

    LogRocket Signup

    LogRocket 记录控制台日志、页面加载时间、堆栈跟踪、带有标头 + 正文的慢速网络请求/响应、浏览器元数据和自定义日志。了解 JavaScript 代码的影响从未如此简单!

    免费试用。