前端设计模式知识体系(一): 设计模式与面向对象编程
引言
为什么一些代码看起来整洁而优雅,而另一些却一团糟?在开发过程中,你是否遇到过这样的问题:同一个功能模块,某些代码逻辑清晰、结构紧凑,让人赏心悦目,而另一些代码却冗长混乱,难以理解?你是否曾反复重构某段代码,却始终无法避免重复和臃肿?为什么有些代码不仅易于维护,还能轻松扩展,而另一些代码一旦写好,就像一团乱麻,稍加修改就会引发更多问题?这些问题的背后,往往是代码设计思想的差异。好的代码不仅仅是功能正确,更重要的是具备良好的设计。
设计模式的概念最早由建筑师克里斯托弗·亚历山大(Christopher Alexander)在建筑领域提出,后来被软件工程界的“Gang of Four”(四人帮)——Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 引入编程世界。他们在 1994 年的著作《设计模式:可复用面向对象软件的基础》中,总结了 23 种常见的设计模式,提供了解决软件设计中常见问题的模板和最佳实践。从那时起,设计模式逐渐成为开发者编写高质量、可维护代码的重要工具,尤其在复杂的软件项目中,设计模式通过抽象出通用的解决方案,帮助开发者更好地应对代码的复杂性。
面向对象编程(OOP)作为一种流行的编程范式,提供了组织代码的基本原则,而设计模式则是我们应对复杂开发问题的有效武器。设计模式基于面向对象编程的原则,通过提供解决常见设计问题的可复用模板,帮助开发者更高效地组织和扩展对象之间的关系与行为。
本篇文章将通过探讨设计模式与 OOP 的结合,一起探索编写优雅、可维护代码的方法。
面向对象的 11 个基本概念
面向对象分析的目的是为了明确问题领域并深入理解问题,它包括五个关键活动:识别对象、组织对象、描述对象之间的交互、确定对象的操作行为,以及定义对象的内部数据结构。与传统面向对象语言不同,JavaScript 早期并未提供类式继承,而是通过原型链的方式实现对象之间的继承。此外,JavaScript 在语言层面上也不直接支持抽象类和接口的概念,开发者通常通过其他设计模式或语法技巧来模拟这些特性。
在深入学习设计模式之前, 我们首先应该深入理解面向对象的 11 个基本概念。
对象
由数据及其操作所构成的封装体, 是系统中用来描述客观事务的一个实体, 是构成系统的一个基本单位。一个对象通常可以由对象名、属性和方法三个部分组成。
医院管理系统 -> 医生实体 -> 属性(姓名、年龄、性别、专业、科室) -> 行为(开刀)
类
现实世界中实体的形式化描述, 类将该实体的属性(数据) 和操作(函数) 封装在一起。对象是类的实例, 类是对象的模板。
类可以分为三种: 实体类、接口类(边界类) 和 控制类。
实体类的对象表示现实世界中真实的实体, 如人、物等。
接口类(边界类) 的对象为用户提供一种与系统合作交互的方式, 分为人和系统两大类, 其中人的接口可以是显示屏、窗口、Web窗体、对话框、菜单、列表框、其他显示控制、条形码、二维码或者用户与系统交互的其他方法。系统接口涉及到把数据发送到其他系统,或者从其他系统接收数据。
控制类的对象用来控制活动流,充当协作者。
抽象
通过特定的实例抽取共同特征以后形成概念的过程。它强调主要特征, 忽略次要特征。一个对象是现实世界中一个实体的抽象, 一个类是一组对象的抽象, 抽象是一种单一化的描述, 它强调给出与应用相关的特性, 抛弃不相关的特性。
封装
是一种信息隐蔽技术, 将相关的概念组成一个单元模块, 并通过一个名称来引用。面向对象封装是将数据和基于数据的操作封装成一个整体对象, 对数据的访问或修改只能通过对象对外提供的接口进行。
继承
表示类之间的层次关系(父类与子类) , 这种关系使得某类对象可以继承另外一类对象的特征, 又可以分为单继承和多继承。
多态
不同的对象收到同一个消息时产生完全不同的结果。 包括参数多态(不同类型参数多种结构类型)、包含多态(父子类型关系)、过载多态(类似于重载, 一个名字不同含义)、强制多态(强制类型转换) 四种类型。多态由继承机制支持,将通过消息放在抽象层, 具体不同的过能能实现放在底层。
c# c++ 基于虚函数、抽象类, java 基于 接口。js 通过参数判断去模拟。不同的子类可以实现同一个接口。多态, 多种形态。
重载: 函数名一样, 参数不一样
接口
描述对操作规范的说明, 其只说明操作应该做什么, 并没有定义操作如何做。
消息
体现对象间的交互, 通过它向目标对象发送操作请求。
覆盖
子类在原有父类接口的基础上, 用适合于自己要求的实现去置换父类中的相应实现。即在子类中重定义一个与父类同名同参的方法。(是不是依赖倒置?)
函数重载
与覆盖要区分开,函数重载与子类父类无关, 且函数是同名不同参。
绑定
是一个把过程调用和响应调用所需要执行的代码加以结合的过程。在一般的程序设计语言中,绑定是在编译时进行的, 叫作静态绑定。动态绑定则是在运行时进行的, 因此, 一个给定的过程调用和代码的结合直到调用发生时才进行。
设计原则
在面向对象编程中,有一系列设计原则可以帮助开发者编写更清晰、灵活且可维护的代码。其中,以下六种原则尤为常用:
单一职责原则 (Single Responsibility Principle, SRP)
每个类或模块都应该只负责一个特定的功能。这样可以确保代码的可维护性和可测试性,当某个功能需求变化时,只需要修改对应的模块或类,不会影响其他部分。
当你有一个类或模块承担了多个职责时,容易导致代码臃肿、难以维护。SRP 鼓励你将这些职责分离到不同的类或模块中。
在前端项目中,一个组件既处理数据获取,又负责 UI 渲染。应将数据获取逻辑分离到单独的服务层,而 UI 渲染保持专注于展示。
class UserService {
getUserData() {
// 获取用户数据的逻辑
}
}
class UserComponent {
render(userData) {
// 负责渲染用户数据
}
}
里氏替换原则 (Liskov Substitution Principle, LSP)
子类必须能够替换其父类而不影响程序的正确性。也就是说,程序中的父类对象能够被其子类对象替换,而不会改变原有的行为。这个原则确保了继承体系的正确性和一致性。
当你使用继承时,子类必须能够替代父类,不改变程序的行为。如果子类不能完全替代父类,继承就会带来意想不到的问题,违反了 LSP。
假设我们有一个基类 Bird,它有一个 fly 方法。我们希望用不同类型的鸟类来扩展这个基类。
// 定义一个抽象类接口
class Bird {
move() {
throw new Error('Method must be implemented');
}
}
class Sparrow extends Bird {
move() {
console.log('Flying');
}
}
class Penguin extends Bird {
move() {
console.log('Swimming');
}
}
const sparrow = new Sparrow();
sparrow.move(); // Output: Flying
const penguin = new Penguin();
penguin.move(); // Output: Swimming
开放封闭原则 (Open/Closed Principle, OCP)
软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。换句话说,开发者应通过添加新功能来扩展现有代码,而不应直接修改已有的代码。这有助于保持代码的稳定性和可靠性,尤其是在不断扩展的项目中。
假设你在开发一个图形绘制应用,需要支持不同类型的形状,如圆形和矩形。你希望能够轻松地添加新的形状类型而无需修改现有的代码。
// 基类:形状接口
class Shape {
draw() {
throw new Error("Method must be implemented");
}
}
// 具体实现:圆形
class Circle extends Shape {
draw() {
console.log('Drawing a circle');
}
}
// 具体实现:矩形
class Rectangle extends Shape {
draw() {
console.log('Drawing a rectangle');
}
}
// 具体实现:三角形
class Triangle extends Shape {
draw() {
console.log('Drawing a triangle');
}
}
// 绘图应用,依赖于形状接口
class DrawingApp {
draw(shape) {
shape.draw();
}
}
const app = new DrawingApp();
app.draw(new Circle()); // Output: Drawing a circle
app.draw(new Rectangle()); // Output: Drawing a rectangle
app.draw(new Triangle()); // Output: Drawing a triangle
依赖倒置原则 (Dependency Inversion Principle, DIP)
高层模块不应该依赖于低层模块,而是应该依赖于抽象。这个原则强调代码中应依赖抽象接口,而非具体实现,能够减少模块之间的耦合度,提升系统的灵活性和可扩展性。
当高层模块直接依赖于低层模块时,系统扩展和维护就变得困难。通过依赖抽象接口而非具体实现,可以降低模块之间的耦合度。
接口分离原则 (Interface Segregation Principle, ISP)
客户端不应该被迫依赖它们不使用的接口。也就是说,每个接口都应该只包含客户端所需的方法,而不应让类实现不必要的功能。这可以避免臃肿的接口设计,提高代码的灵活性。
当接口设计过于庞大、包含太多不相关的方法时,客户端会被迫实现不需要的功能。ISP 鼓励将接口划分为更小、具体的部分,方便用户只实现自己需要的功能。
在一个前端项目中,假设有一个庞大的 UIComponent 接口,既包含渲染方法,也包含动画方法。可以将它拆分为 Renderable 和 Animatable 接口,避免不需要动画的组件也要实现动画相关方法。
最少知识原则(Least Knowledge Principle,LKP),也称为迪米特法则(Law of Demeter ,LoD)
旨在减少对象之间的耦合度。它的核心思想是:一个对象应该只与它直接依赖的对象进行交互,而不是访问或依赖于其他对象的内部细节。这样可以使得系统更具模块化、灵活性和易于维护。
最少知识原则要求一个对象尽可能少地了解其他对象的内部细节。它提倡对象之间的交互应该限于直接的合作伙伴,而不是通过中介对象来访问其它对象的内部状态。
最少知识原则鼓励设计中只与直接的合作伙伴交互,避免了对象之间的深层次耦合。通过减少对象间的知识依赖,可以提高系统的模块化程度,降低复杂性,使代码更易于维护和扩展。在实际开发中,遵循最少知识原则有助于构建更清晰、可维护的系统架构。
不符合 LoD 的例子
场景:你有一个 Car 类,它直接访问 Engine 类的内部细节。
class Engine {
constructor(model) {
this.model = model;
}
getModel() {
return this.model;
}
}
class Car {
constructor(engine) {
this.engine = engine;
}
displayEngineModel() {
// 不符合 LoD:Car 类直接访问 Engine 对象的内部细节
console.log(`Engine model: ${this.engine.model}`);
}
}
const engine = new Engine('V8');
const car = new Car(engine);
car.displayEngineModel(); // Output: Engine model: V8
在这个例子中,Car 类直接访问了 Engine 类的 model 属性,违反了最少知识原则,因为 Car 类对 Engine 类的内部实现有了过多了解。
符合 LoD 的例子
场景:改进 Car 类,使其通过 Engine 提供的公共方法来获取引擎模型信息,而不是直接访问其属性。
class Engine {
constructor(model) {
this.model = model;
}
getModel() {
// 符合 LoD:提供公共方法来获取引擎模型
return this.model;
}
}
class Car {
constructor(engine) {
this.engine = engine;
}
displayEngineModel() {
// 符合 LoD:Car 类通过 Engine 提供的公共方法获取信息
console.log(`Engine model: ${this.engine.getModel()}`);
}
}
const engine = new Engine('V8');
const car = new Car(engine);
car.displayEngineModel(); // Output: Engine model: V8
结语
通过本文对最少知识原则 (LoD) 的探讨,我们了解到,减少对象之间的耦合度可以显著提高代码的模块化和可维护性。遵循这一原则,我们不仅能构建更清晰、灵活的系统架构,还能为未来的扩展和维护奠定坚实基础。在后续的文章中,我们将继续深入探讨其他设计原则与模式,帮助您在前端开发中实现更高质量的代码设计。