高级进阶之JavaScript中的SOLID原则
前言
在软件开发领域,构建可维护、可扩展和可重用的软件系统一直是开发人员追求的目标。然而,随着项目的增长和复杂性的提高,代码变得越来越难以理解、修改和扩展。为了应对这些挑战,面向对象设计中的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() {
return “Car Test Started”;
}
startTruckTest() {
return null;
}
}
class TruckDrivingTest extends DrivingTest {
constructor(userType) {
super(userType);
}
startCarTest() {
return null;
}
startTruckTest() {
return “Truck 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() {
return ‘Car Test Started’;
},
};
const truckUserTests = {
startTruckTest() {
return ‘Truck 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应用程序中采用这些概念。作为开发人员,理解并运用这些核心概念对我们的应用程序至关重要。有时,在处理小型应用程序时,这些原则的好处可能并不明显,但一旦开始处理大型项目,您肯定会意识到它们所带来的差异。
转载自:https://juejin.cn/post/7273384313958236172