本文深入解析JavaScript面向对象编程的七大核心原则,涵盖单一职责、开闭、里氏替换、依赖倒置、接口隔离、迪米特法则及组合聚合复用。通过具体代码实例,阐述如何降低耦合度、提升代码可维护性与扩展性,帮助开发者构建更健壮的系统架构。
本文通过具体实例,详细解析JavaScript面向对象编程中的七大基本原则,旨在为读者提供清晰的参考与学习路径。
面向对象编程具备独特的特性与规范。若对面向对象已有基础认知,应掌握其三大核心特征:封装、继承与多态。若对这些概念尚不熟悉,建议先参阅《面向对象之三个基本特征(JavaScript)》一文以夯实基础。
单一职责
在编程实践中,若单个类或方法包含过多功能,将严重损害代码可读性,导致维护困难。为解决这一痛点,单一职责原则应运而生。
什么是单一职责
单一职责,亦称单一功能原则,是面向对象五大基本原则(SOLID)之一。其核心定义是:一个类应当仅有一个引起它变化的原因。
具体而言,一个类应只负责一项职责,仅执行与其相关的事务。在类设计阶段,需按职责进行划分,确保各部分正交且互不干扰。
单一职责的好处
- 降低类的复杂性,使实现的功能职责清晰明确。
- 提高代码可读性,随着复杂性降低,代码逻辑更易理解。
- 提升可维护性,高可读性自然带来更便捷的维护体验。
- 降低变更带来的风险。若接口职责单一,修改一个接口通常只影响对应的实现类,而不波及其它接口,这对系统的扩展性与长期维护具有重大帮助。
实例
class ShoppinCar {
constructor(){
this.goods = [];
}
addGoods(good){
this.goods = [good];
}
getGoodsList(){
return this.goods;
}
}
class Settlement {
constructor(){
this.result = 0;
}
calculatePrice(list,key){
let allPrice = 0;
list.forEach((el) => {
allPrice += el[key];
})
this.result = allPrice;
}
getAllPrice(){
return this.result;
}
}
参考上述代码ShoppinCar,该类包含addGoods和getGoodsList两个方法,分别用于添加商品和获取商品列表。同时,Settlement类包含calculatePrice和getAllPrice两个方法,分别负责计算价格与获取总价。ShoppinCar与Settlement各自专注于自身业务。尽管添加商品与计算价格在业务逻辑上存在依赖,但在代码实现中通过两个独立的类来解耦,各自处理各自的事务。如此设计,任一类的修改均不会影响另一个类。
开闭原则
若类中暴露的方法发生变更,可能引发严重后果,导致其他依赖该方法且无需变更的业务模块大面积瘫痪。为解决此问题,可单独编写新方法,若该方法与原类中其他方法存在依赖关系,则需特殊处理。
解决办法:
- 将依赖的代码复制一份至新类中。
- 在新类中引用旧类的方法。
然而,上述两种方案均非最佳解决之道。
第一种方案会导致代码大量重复,第二种方案则会导致类与类之间产生紧密依赖。
什么是开闭原则
开闭原则指出:“软件中的对象(类、模块、函数等)应该对于扩展是开放的,但对于修改是封闭的。”这意味着允许在不更改源代码的前提下变更实体的行为。
开闭原则强调对扩展开放、对修改关闭,但这并不意味着完全禁止修改。底层模块的变更必然需要高层模块进行耦合,否则代码片段将孤立且无意义。开闭原则是最基础的原则,其余六个原则均为其具体形态,是指导设计的工具与方法,而开闭原则则是整个体系的精神领袖。
开闭原则好处
- 有利于开展单元测试。
- 提高代码的复用性。
- 提高系统的可维护性。
- 符合面向对象开发的要求。
实例
class Drag {
down(){
// ...
}
move(){
// ...
// 对拖拽没有做任何限制可以随意拖拽
}
up(){
// ...
}
}
class LimitDrag extends Drag {
move(){
// ...
// 重写该方法对拖拽进行限制处理
}
}
在LimitDrag中重写了move方法。若进行修改以满足两种需求:限制型拖拽与不限制型拖拽,即使其中一个发生更改,另一个仍可正常运行。
里氏替换
开发人员在使用他人组件时,只需了解组件对外暴露的接口,即其全部行为的集合,无需知晓内部实现细节。因此,使用者仅能通过接口实现预期行为。若组件接口提供的行为与使用者预期不符,错误便会产生。里氏替换原则旨在设计时避免派生类与基类行为不一致的问题。
什么是里氏替换
里氏替换原则:作为面向对象高层原则的OCP,主张使用“抽象(Abstraction)”和“多态(Polymorphism)”将设计中的静态结构改为动态结构,以维持设计的封闭性。“抽象”是语言提供的功能,“多态”由继承语义实现。
里氏替换好处
- 实现代码共享,减少创建类的工作量,每个子类均拥有父类的方法和属性。
- 提高代码的重用性。
- 子类可形似父类,但又异于父类。
- 提高代码的可扩展性,只需实现父类的方法即可。许多开源框架的扩展接口均通过继承父类来完成。
- 提高产品或项目的开放性。
实例
// 抽象枪类
class AbstractGun {
shoot(){
throw "Abstract methods cannot be called";
}
}
// 步枪
class Rifle extends AbstractGun {
shoot(){
console.log("步枪射击...");
}
}
// 狙击枪
class AUG extends Rifle {
zoomOut(){
console.log("通过放大镜观察");
}
shoot(){
console.log("AUG射击...");
}
}
// 士兵
class Soldier {
constructor(){
this.gun = null;
}
setGun(gun){
this.gun = gun;
}
killEnemy(){
if(!this.gun){
throw "需要给我一把枪";
return;
}
console.log("士兵开始射击...");
this.gun.shoot();
}
}
// 狙击手
class Snipper extends Soldier {
killEnemy(aug){
if(!this.gun){
throw "需要给我一把枪";
return;
}
this.gun.zoomOut();
this.gun.shoot();
}
}
let soldier = new Soldier();
soldier.setGun(new Rifle());
soldier.killEnemy();
let snipper = new Snipper();
// 分配狙击枪
snipper.setGun(new AUG());
snipper.killEnemy();
snipper.setGun(new Rifle());
// snipper.killEnemy(); // this.gun.zoomOut is not a function
从上述代码可以看出,子类和父类之间的关系表现为:子类方法的功能范围一定等于或大于父类方法。子类可能出现的情况,父类不一定能出现;但父类出现的地方,子类一定能够出现。
依赖倒置
若方法与方法之间或类与类之间存在过多依赖关系,将导致代码可读性与可维护性极差。依赖倒置原则能有效解决这些问题。
什么是依赖倒置
依赖倒置原则:程序应依赖于抽象接口,而非具体实现。简而言之,即要求对抽象进行编程,不要对实现进行编程,从而降低客户与实现模块间的耦合度。
- 高层模块不应该依赖低层模块,两者都应该依赖其抽象。
- 抽象不应该依赖细节。
- 细节应该依赖抽象。
依赖倒置好处
- 通过依赖于接口,隔离了具体实现类。
- 低一层的变动并不会导致高一层的变动。
- 提高了代码的容错性、扩展性和易于维护。
实例
// 抽象枪类
class AbstractGun {
shoot(){
throw "Abstract methods cannot be called";
}
}
// 步枪
class Rifle extends AbstractGun {
shoot(){
console.log("步枪射击...");
}
}
// 狙击枪
class AUG extends AbstractGun {
shoot(){
console.log("AUG射击...");
}
}
从上面的代码可以看出,步枪与狙击枪的shoot全部都是依赖于AbstractGun抽象的枪类,上述编程满足了依赖倒置原则。
接口隔离
什么是接口隔离
接口隔离:客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
接口隔离原则与单一职责原则的审视角度不相同。单一职责原则要求是类和接口的职责单一,注重的职责,这是业务逻辑上的划分。接口隔离原则要求接口的方法尽量少。
接口隔离好处
- 避免接口污染。
- 提高灵活性。
- 提供定制服务。
- 实现高内聚。
实例
function mix(...mixins) {
class Mix {}
for (let mixin of mixins) {
copyProperties(Mix, mixin);
copyProperties(Mix.prototype, mixin.prototype);
}
return Mix;
}
function copyProperties(target, source) {
for (let key of Reflect.ownKeys(source)) {
if ( key !== "constructor"&& key !== "prototype"&& key !== "name") {
let desc = Object.getOwnPropertyDescriptor(source, key);
Object.defineProperty(target, key, desc);
}
}
}
class Behavior {
eat(){
throw "Abstract methods cannot be used";
}
call(){
throw "Abstract methods cannot be used";
}
}
class Action {
climbTree(){
throw "Abstract methods cannot be used";
}
}
class Dog extends Behavior{
eat(food){
console.log(`狗正在吃${food}`);
}
hungry(){
console.log("汪汪汪,我饿了")
}
}
const CatMin = mix(Behavior,Action);
class Cat extends CatMin{
eat(food){
console.log(`猫正在吃${food}`);
}
hungry(){
console.log("喵喵喵,我饿了")
}
climbTree(){
console.log("爬树很开心哦~")
}
}
let dog = new Dog();
dog.eat("骨头");
dog.hungry();
let cat = new Cat();
cat.eat("鱼");
cat.hungry();
cat.climbTree();
大家一定要好好分析一下上面的代码,共有两个抽象类,分别对应不同的行为,Cat与Dog类拥有共同的行为,但是Cat又拥有其自己单独的行为,使用抽象(即接口)继承其方法,使用接口隔离使其完成各自的工作,各司其职。
迪米特法则
迪米特法则:最少知识原则(Least Knowledge Principle 简写LKP),就是说一个对象应当对其他对象有尽可能少的了解,不和陌生人说话。英文简写为: LoD.
迪米特法则的做法观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提高。一个类应该对其他对象保持最少的了解。通俗来讲,就是一个类对自己依赖的类知道的越少越好。因为类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
迪米特法则好处
- 减少对象之间的耦合性。
实例
class ISystem {
close(){
throw "Abstract methods cannot be used";
}
}
class System extends ISystem{
saveCurrentTask(){
console.log("saveCurrentTask")
}
closeService(){
console.log("closeService")
}
closeScreen(){
console.log("closeScreen")
}
closePower(){
console.log("closePower")
}
close(){
this.saveCurrentTask();
this.closeService();
this.closeScreen();
this.closePower();
}
}
class IContainer{
sendCloseCommand(){
throw "Abstract methods cannot be used";
}
}
class Container extends IContainer{
constructor(){
super()
this.system = new System();
}
sendCloseCommand(){
this.system.close();
}
}
class Person extends IContainer{
constructor(){
super();
this.container = new Container();
}
clickCloseButton(){
this.container.sendCloseCommand();
}
}
let person = new Person();
person.clickCloseButton();
上面代码中Container作为媒介,其调用类不知道其内部是如何实现,用户去触发按钮,Container把消息通知给计算机,计算机去执行相对应的命令。
组合/聚合复用原则
聚合(Aggregation)表示一种弱的‘拥有'关系,体现的是A对象可以包含B对象但B对象不是A对象的一部分。
合成(Composition)则是一种强的'拥有'关系,体现了严格的部分和整体关系,部分和整体的生命周期一样。
组合/聚合:是通过获得其他对象的引用,在运行时刻动态定义的,也就是在一个对象中保存其他对象的属性,这种方式要求对象有良好定义的接口,并且这个接口也不经常发生改变,而且对象只能通过接口来访问,这样我们并不破坏封装性,所以只要类型一致,运行时还可以通过一个对象替换另外一个对象。
优先使用对象的合成/聚合将有助于你保持每个类被封装,并被集中在单个任务上,这样类和类继承层次会保持较小规模,而且不太可能增长为不可控制的庞然大物。
组合/聚合复用原则好处
- 新的实现较为容易,因为超类的大部分功能可通过继承关系自动进入子类;
- 修改或扩展继承而来的实现较为容易。
实例
function mix(...mixins) {
class Mix {}
for (let mixin of mixins) {
copyProperties(Mix, mixin);
copyProperties(Mix.prototype, mixin.prototype);
}
return Mix;
}
function copyProperties(target, source) {
for (let key of Reflect.ownKeys(source)) {
if ( key !== "constructor"&& key !== "prototype"&& key !== "name") {
let desc = Object.getOwnPropertyDescriptor(source, key);
Object.defineProperty(target, key, desc);
}
}
}
class Savings {
saveMoney(){
console.log("存钱");
}
withdrawMoney(){
console.log("取钱");
}
}
class Credit {
overdraft(){
console.log("透支")
}
}
const CarMin = mix(Savings,Credit);
class UserCar extends CarMin {
constructor(num,carUserName){
super();
console.log()
this.carNum = num;
this.carUserName = carUserName;
}
getCarNum(){
return this.carNum;
}
getCarUserName(){
return this.carUserName;
}
}
let myCar = new UserCar(123456798,"Aaron");
console.log(myCar.getCarNum());
console.log(myCar.getCarUserName());
myCar.saveMoney();
myCar.withdrawMoney();
myCar.overdraft();
总结
这些原则在设计模式中体现的淋淋尽致,设计模式就是实现了这些原则,从而达到了代码复用、增强了系统的扩展性。所以设计模式被很多人奉为经典。我们可以通过好好的研究设计模式,来慢慢的体会这些设计原则。
感兴趣的朋友可以使用在线HTML/CSS/JavaScript代码运行工具:http://tools.jb51.net/code/HtmlJsRun测试上述代码运行效果。
更多关于JavaScript相关内容感兴趣的读者可查看本站专题:《javascript面向对象入门教程》、《JavaScript错误与调试技巧总结》、《JavaScript数据结构与算法技巧总结》、《JavaScript遍历算法与技巧总结》及《JavaScript数学运算用法总结》
希望本文所述对大家JavaScript程序设计有所帮助。