JavaScript 设计模式之设计原则

123 阅读18分钟

前言

什么是设计模式?为什么需要学习使用设计模式?

设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。

使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性、便于团队成员间协作以及后期的维护。

在正式开始了解设计模式之前,我们先来了解一下设计模式中所依据的原则-设计原则

设计模式原则

  • S – Single Responsibility Principle 单一职责原则

    • 一个程序只做好一件事
    • 如果功能过于复杂就拆分开,每个部分保持独立
  • O – OpenClosed Principle 开放/封闭原则

    • 对扩展开放,对修改封闭
    • 增加需求时,扩展新代码,而非修改已有代码
  • L – Liskov Substitution Principle 里氏替换原则

    • 子类能覆盖父类
    • 父类能出现的地方子类就能出现
  • I – Interface Segregation Principle 接口隔离原则

    • 保持接口的单一独立
    • 类似单一职责原则,这里更关注接口
  • D – Dependency Inversion Principle 依赖倒转原则

    • 面向接口编程,依赖于抽象而不依赖于具体
    • 使用方只关注接口而不关注具体类的实现

单一职责原则(以下简称SRP)

SRP的职责被定义为“引起变化的原因”。如果我们有两个动机去改写一个方法,那么这个方法就具有两个职责。每个职责都是变化的一个轴线,如果一个方法承担了过多的职责,那么在需求的变迁过中,需要改写这个方法的可能性就越大。

此时,这个方法通常是一个不稳定的方法,修改代码总是一件危险的事情,特别是当两个职 责耦合在一起的时候,一个职责发生变化可能会影响到其他职责的实现,造成意想不到的破坏, 这种耦合性得到的是低内聚和脆弱的设计。

因此,SRP 原则体现为:一个对象(方法)只做一件事情

我们看个(单例模式,后面会介绍到)例子(登录弹窗):

var createLoginLayer = (function(){ 
    var div;
    return function(){ 
        if ( !div ){
            div = document.createElement( 'div' ); 
            div.innerHTML = '登录浮窗'; 
            div.style.display = 'none'; 
            document.body.appendChild( div );
        }
        return div; 
    }
})();

可以看到, 管理单例的职责创建登录浮窗的职责都放到同一个方法中。有时候我们需要在不同的方法中调用创建弹窗的方法,此刻,这个方法就无法达到我们的预期目标。

现在我们把管理单例的职责和创建登录浮窗的职责分别封装在两个方法里

var getSingle = function( fn ){ // 获取单例 var result;
    return function(){
        return result || ( result = fn .apply(this, arguments ) );    
    }
};
var createLoginLayer = function(){ // 创建登录浮窗 
    var div = document.createElement( 'div' ); 
    div.innerHTML = '登录浮窗'; 
    document.body.appendChild( div );
    return div; 
};
var createSingleLoginLayer = getSingle( createLoginLayer );
var loginLayer1 = createSingleLoginLayer(); 
var loginLayer2 = createSingleLoginLayer();
alert ( loginLayer1 === loginLayer2 ); // 输出: true

这两个方法可以 独立变化而互不影响,当它们连接在一起的时候,就完成了创建唯一登录浮窗的功能 ,其他方法调用创建弹窗也不会影响到了。

总的来说,SRP 原则的优点是降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度, 这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责。 但SRP 原则也有一些缺点,最明显的是会增加编写代码的复杂度。当我们按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度。

开放/封闭原则(OCP)

有个场景,一个页面中有很多表单,我们需要封装一个公共的验证方法:

//checkType('165226226326','mobile')
//result:false
let checkType=function(str, type) {
    switch (type) {
        case 'email':
            return /^[\w-]+(.[\w-]+)*@[\w-]+(.[\w-]+)+$/.test(str)
        case 'mobile':
            return /^1[3|4|5|7|8][0-9]{9}$/.test(str);
        case 'tel':
            return /^(0\d{2,3}-\d{7,8})(-\d{1,4})?$/.test(str);
        default:
            return true;
    }
}

这边有以下两个问题:

  • 后期我们还需要验证身份证号怎么处理?难道每次都需要去这个公共的方法里面加个case?这样违反了开放-封闭原则(对扩展开放,对修改关闭)。而且这样也会导致整个 API 变得臃肿,难维护。
  • A页面需要添加一个金额的校验,B页面需要一个日期的校验,但是金额的校验只在A页面需要,日期的校验只在B页面需要。如果一直添加 case 。就是导致A页面把只在B页面需要的校验规则也添加进去,造成不必要的开销。B页面也同理。

建议是给这个 API 增加一个扩展的接口:

let checkType=(function(){
    let rules={
        email(str){
            return /^[\w-]+(.[\w-]+)*@[\w-]+(.[\w-]+)+$/.test(str);
        },
        mobile(str){
            return /^1[3|4|5|7|8][0-9]{9}$/.test(str);
        }
    };
    //暴露接口
    return {
        //校验
        check(str, type){
            return rules[type]?rules[type](str):false;
        },
        //添加规则
        addRule(type,fn){
            rules[type]=fn;
        }
    }
})();
​
//调用方式
//使用mobile校验规则
console.log(checkType.check('188170239','mobile'));
//添加金额校验规则
checkType.addRule('money',function (str) {
    return /^[0-9]+(.[0-9]{2})?$/.test(str)
});
//使用金额校验规则
console.log(checkType.check('18.36','money'));

里氏替换原则(LSP)

通俗的说,子类对象可以替换父类对象,而不会引起程序的异常和错误

当然,反过来是不行的。

举个栗子:

定义一个 Employee 类,类里面有一个baseMoney的方法。直接返回3k

class Employee {
    /// 基本工资
    /// - Returns: 钱
    public baseMoney():int {
        return 3000
    }
}

接着定义一个程序猿的类,继承员工类,公司说程序猿必须多10块钱,好,那我们就复用baseMoney,加多10块。

//程序猿
class CXY extends Employee {
  
    public baseMoney():int {
        return 3000 + 10
    }
}
​

我们请求一下员工的工资 :

var employee = new Employee()
console.log("员工基本工资:", employee.baseMoney())
​
var cxy = new CXY()
console.log("程序猿工资:", cxy.baseMoney())
​

输出的结果是:

员工基本工资: 3000

程序猿工资: 3010

这么一看,我们是不是也能实现需求。

而已这么写就违背了里氏替换原则,明显看到,子类cxy替换父类employee,输出的结果已经发生了变化,这不是我们想要的效果。

程序猿的工资是不是没有问题,但我想问一下的是,程序猿的基本工资是多少呢,获取不了,因为子类重写了父类的baseMoney,我们就拿不到3k了。正常的流程cxy.baseMoney输出结果也要是3k才对。

所以,我们应该这么写

//程序猿
class CXY extends Employee {
    public cxymoney():int {
       return baseMoney() + 10
    }
}
​

我们输出一下代码

var employee = Employee()
console.log("员工基本工资:", employee.baseMoney())

var cxy = new CXY()
console.log("程序猿基本工资:", cxy.baseMoney())
console.log("程序猿实际工资:", cxy.cxymoney())

输出结果为:

员工基本工资: 3000

程序猿基本工资: 3000

程序猿实际工资: 3010

这就解决了程序猿既能获取基本工资,也能获取实际工资,子类替换父类,也不影响程序的结果

我们回想一下,为什么不要在cxy里面改baseMoney,因为我们定义的就是全体员工的基本工资就是3k,所以这里是不能动的,那里氏替换原则是不是就不能用重写,不能多态呢。

这种说法是错误的,对于这个例子,员工是有工资,那我们就可以再加一个员工的方法,然后子类再重写这个方法,这才是正确的重写方法。

class Employee {
    
    /// 基本工资
    /// - Returns: 钱
    public baseMoney():int {
        return 3000
    }
    
    public money():int {
        baseMoney()
    }
}

//程序猿
class CXY extends Employee {
    
    public money():int {
        super.money() + 10
    }
}

输出代码 :

var employee = new Employee()
console.log("员工基本工资:", employee.baseMoney())

var cxy = new CXY()
console.log("程序猿基本工资:", cxy.baseMoney())
console.log("程序猿实际工资:", cxy.money())

输出结果:

员工基本工资: 3000

程序员基本工资: 3000

程序员实际工资: 3010

这么做是不是也能解决需要。当然除了这种写法,我还有另外一种写法。

实际上不同的角色员工的工资是不同的结果的,那么money这个方法,输出是不确定的,我们就可以把它看做一个抽象方法

interface EmployeeProtocol {
    public money():int
}

class Employee implements EmployeeProtocol{
    
    /// 基本工资
    /// - Returns: 钱
    public baseMoney():int {
        return 3000
    }
}

//程序猿
class CXY extends Employee {
    
    public money():int {
        baseMoney() + 10
    }
}

//产品经理
class ProductManager extends Employee {
    
    public money():int {
        baseMoney() + 15
    }
}

输出代码:

var employee = new Employee()
console.log("员工基本工资:", employee.baseMoney())

var cxy = new CXY()
console.log("程序猿基本工资:", cxy.baseMoney())
console.log("程序猿实际工资:", cxy.money())

var productManager = new CXY()
console.log("产品经理基本工资:", productManager.baseMoney())
console.log("产品经理实际工资:", productManager.money())

输出结果:

员工基本工资: 3000

程序员基本工资: 3000

程序员实际工资: 3010

产品经理基本工资: 3000

产品经理实际工资: 3015

看,是不是也能实现效果呢,鹅且更加灵活了。

接口隔离原则(ISP)

定义

接口隔离原则:指明客户不应该被迫依赖于对其而言无用的方法或功能。一个类对另一个类的依赖应该建立在最小的接口上。

也就会说接口隔离原则要求程序员尽量将臃肿庞大的接口拆分成更小的和更具体的接口,让接口中只包含客户感兴趣的方法。每个类都有自己的专用的接口,不要视图建立一个很庞大的接口提供给所有依赖它的类去实现

重点

接口隔离原则和单一职责原则是有点类似,都是为了提高类的内聚性、降低它们之间的耦合性。区别的是单一职责原则重点的是职责,约束的是类,每个类都有自己的职责,而接口隔离原则重点的是接口,更注重的是对接口依赖的隔离。

接口隔离原则目的是系统解开耦合,从而容易重构,更改和重新部署。

那我们应该怎么做呢

  1. 每个接口只服务于相应的模块和业务。
  2. 只提供调用者需要的方法,不需要的方法不提供。
  3. 当一个接口太大时,我们需要将它分割成一些更细小的接口。
  4. 每个接口需承担相对独立的角色,不要把不同的角色都交给1个接口。
  5. 每个项目或产品都有选定的环境因素,环境不同,接口拆分的标准就不同。
优点
  1. 将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
  2. 接口隔离提高了系统的内聚性,减少了对外交互,降低了系统的耦合性。
  3. 如果接口的粒度大小定义合理,能够保证系统的稳定性;但是,如果定义过小,则会造成接口数量过多,使设计复杂化;如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。
  4. 使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。
  5. 能减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。

看个栗子:

/// IT协议
interface ITProtocol {
    // 写代码
    writeCode():void
    // 写文档
    writeDocument():void
    // 管理
    manage():void
}

我们有一个码农的类,和项目经理的类,都遵循IT协议。

//码农
class Programmer extends ITProtocol {
    public writeCode():void {
        console.log("writeCode")
    }
    
    public writeDocument():void {
        console.log("writeDocument")
    }
    
    public manage():void {
        console.log("not manage")
    }
}

//项目经理
class ProjectManager extends ITProtocol {
    public writeCode():void {
        console.log("not writeCode")
    }
    
    public writeDocument():void {
        console.log("writeDocument")
    }
    
    public manage():void {
        console.log("manage")
    }
}

这时候我们有一个公司的类,公司想聘用员工,就通过ITProtocol协议来聘用。

/// 公司
class Company {
    
    /// 招聘
    /// - Parameter it: 具备某种技能的人才
    public employ(it: ITProtocol) {
        it.writeCode()
        it.writeDocument()
        it.manage()
    }
}

公司想聘用一个码农,调用输出代码:

var company = new Company()
var programmer = new Programmer()
company.employ(it: programmer)

输出结果:

writeCode

writeDocument

not manage

公司想聘用一个项目经理,调用输出代码:

var company = new Company()
var projectManager = new ProjectManager()
company.employ(it: projectManager)

输出结果:

not writeCode

writeDocument

manage

表面上我们是实现了功能,但是我们又感觉有些类,输出了一些不想要的方法。公司雇佣的码农类Programmer是不需要管理manage()方法的,项目经理类ProjectManager是不需要写代码writeCode()的。

那在我们OC中,我们可以用optional来让哪些方法是可选的,这是一种做法。但要是方法很多,我们还是需要对协议进行拆分。

我们开发协议的就是要写代码功能的。管理协议就是要管理功能的。

我们就可以这么设计:

/// IT协议
interface ITProtocol {
    /// 写文档
    public writeDocument():void
}

/// 开发协议
interface DevelopmentProtocol extends ITProtocol {
    /// 写代码
    public writeCode():void
}

/// 管理协议
interface ManageProtocol extends ITProtocol {
    /// 管理
    public manage():void
}

弄了3个协议,抽象父类IT协议,开发协议,管理协议。我们如果是仅有开发需要的功能就在开发协议里面实现,仅有管理需要功能就在管理里面实现,如果开发管理都用到的,我们就在IT协议实现。

这时候,我们只要让码农类Programmer遵循DevelopmentProtocol协议,项目经理类ProjectManager遵循ManageProtocol协议,就可以保证只需要实现自己的相关的功能即可。

//码农
class Programmer extends DevelopmentProtocol {
    public writeCode():void {
        console.log("writeCode")
    }
    
    public writeDocument():void {
        console.log("writeDocument")
    }
}


//项目经理
class ProjectManager extends ManageProtocol {
    poblic writeDocument():void {
        console.log("writeDocument")
    }
    
    poblic manage():void {
        console.log("manage")
    }
}

我们调用代码:

var company = new Company()
var programmer = new Programmer()
company.employ(devIt: programmer)

var projectManager = new ProjectManager()
company.employ(manageIt: projectManager)

输出结果:

writeDocument

writeCode

writeDocument

manage

这样子,我们就相当于自己类关联自己的方法,无关的方法就不会再关联了。通过划分更细的协议来实现,这就是接口隔离原则。

当然,这里还有继续优化的地方,这里写文档功能是如果都是一样做的,我们还可以这么写

interface ITProtocol {
    /// 写文档
    public writeDocument():void {
        console.log("writeDocument")
    }
}

这取决于功能的输出内容,如果写文档功能虽然大家都有,但是每个实现这个功能的是有区别的,我们还是在相应的类实现就好。

写到这里,我觉得还是再补充一个小例子优化吧,毕竟项目中经常会遇到,假设有一个TestClass类,里面有testA,testB,testC方法,我们有另外一个类,调取TestClass对象,需要testA为true,再调取testB,testB为true,再调取testC来返回结果。

class TestClass {
    public testA():boolean  { return true }
    public testB():boolean  { return true }
    public testC():string { return "成功"}
}




function test() {
        let t = new TestClass()
        if t.testA() {
            if t.testB() {
                t.testC()
            }
        }
    }

我们这么实现有没有问题,好像是没有问题,实际上我这个test方法,只想要一个结果,那对于testA,testB,testC是不在乎的,那我们完全就可以封装在TestClass实现。

class TestClass {
    private func testA():boolean  { return true }
    private func testB():boolean  { return true }
    private func testC():string { return "成功"}
    
    public testABC ():string {
        if testA() {
            if testB() {
               return testC()
            }
        }
        
        return ""
    }
}


  function test() {
        let t = TestClass()
        t.testABC()
    }

这样子我们的testA,testB,testC是不是就不用暴露出去了呢,好处的是后面testA,testB,testC有要改动的,我仅需要检查testABC()这个方法否有受到影响就行。

这就是提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。

总结

我们虽然说要拆分接口尽量小和具体,但是也是有限度的。对接口进行细化可以提高程序设计灵活性,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。

依赖倒转原则(DIP)

定义

依赖倒置原则:程序要依赖于抽象接口,不要依赖于具体的实现。

也可以说:高层模块不应该依赖底层模块,两者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖抽象。

虽然可以看起来绕口一点,说白了对于java来言就是要面向接口编程,而不是面向实现编程

重点

依赖倒置原则的目的是通过面向接口的编程来降低类间的耦合性。那我们怎么去面向接口编程呢?

在软件设计中,细节是具有多变性,而抽象是相对稳定的,接口就是一种抽象,接口的实现就是细节,我们随意调用接口,而对于接口的内部实现是不用关心的,因为接口实现是一种具体操作,只有把这任务交给实现类去完成就好。

那我们应该怎么去做呢

  1. 我们每个类都尽量提供接口或抽象类,或者两者都具备。
  2. 变量的声明类型尽量是接口或者抽象类。
  3. 任何类都不应该从具体类派生。
  4. 使用继承时尽量遵循里氏替换原则。
优点
  • 降低类间的耦合性,依赖通过抽象发生,就能实现类之间不直接发生依赖关系
  • 提高系统的稳定性,只要接口是稳定的,任何一个更改都不用担心其它受到影响
  • 减少并行开发引起的风险
  • 提高代码的可读性和维护性

举个栗子:

就拿我国庆去玩订酒店作个例子,这次的例子比较简单,就是面向协议编程的简单运用。

今年国庆刚好去韶关的丹霞山玩,去丹霞山呢门票有效期是48个小时的,所以我作为旅客就决定在那里住一晚。既然去玩,那肯定得住的舒服一点,所以我就直接预订丹霞山星级酒店来住一晚。

需求也算是明确的,那我们就弄一个StarHotel的类,方法是live来居住。

/// 星级酒店
class StarHotel {
    public live():void {
        console.log("StarHotel live")
    }
}

旅客就弄一个Traveler的类,预订就用方法为booking。

/// 旅客
class Traveler {
    public booking(starHotel: StarHotel):void {
        starHotel.live()
    }
}

好,我们输出一下代码

var traveler = new Traveler()
var starHotel = new StarHotel()
traveler.booking(starHotel)

输出结果是:

StarHotel live

这显然没有问题。可是当要预订的时候,发现星级酒店是在丹霞山入口售票处外面,进去里面还有一段很长的路才到景区景点入口,景区里面有2个村,村里面还有不少民宿客栈。民宿客栈入住的话,第二天早上一早去看日出或者去景点入口就方便很多的,而且里面有个大停车场,吃的东西也不少。

这么一想,我觉得我还是订名宿客栈好了。毕竟方便很多。虽然平时100的房,变成400,也就4倍而已是吧。

需求变更了,那我们就弄Homestay类为民宿,也写一个live的方法。

/// 名宿
class Homestay {
    public live():void {
        console.log("Homestay live")
    }
}

作为旅客,也添加一个booking民宿的方法。

/// 旅客
class Traveler {
    public booking(_ starHotel: StarHotel):void {
        starHotel.live()
    }
    
    public booking(_ homeStey: Homestay):void {
        homeStey.live()
    }
}

调用代码:

var traveler = new Traveler()
var starHotel = new StarHotel()
var homestay = new Homestay()
traveler.booking(starHotel)
traveler.booking(homestay)

调用结果:

StarHotel live

Homestay live

可以看到,我们名宿也可以居住了。功能是可以实现了,但这里提出一个疑问,如果又有其它类型酒店可以提供,我们是不是又得在旅客Traveler这个类,再添加一个酒店的预订方法。显然这样我们就违背了开闭原则的实现。

而且我们耦合性太强了,旅客类Traveler和星级酒店StarHotel,名宿Homestay都有相互依赖,这完全就是面向实现编程了。

既然我们学了依赖倒置原则,那我们就尽可能使用面向协议编程。让细节依赖抽象。

这里我们先弄一个协议,也可以说是抽象。HotelProtocol协议,里面有居住live的协议方法。

abstract class HotelProtocol {
    /// 居住
    public live():void
}

不管是民宿也好,星级酒店也好,都遵循这个抽象方法。

/// 名宿
class Homestay extends HotelProtocol {
    public live():void {
        console.log("Homestay live")
    }
}


/// 星级酒店
class StarHotel extends HotelProtocol {
    public live():void {
        console.log("StarHotel live")
    }
}

我们的旅客类Traveler,就预订这个酒店的协议HotelProtocol就好。

/// 旅客
class Traveler {
public
    func booking(_ hotel: HotelProtocol):void {
        hotel.live()
    }
}

这样子的好处是什么呢,我们完全不用管实现方法要居住的是哪个酒店,只要是遵循这个协议的酒店,我们作为旅客,都可以预订。

输出调用代码:

var traveler = new Traveler()
var starHotel = new StarHotel()
var homestay = new Homestay()
traveler.booking(starHotel)
traveler.booking(homestay)

输出结果:

StarHotel live

Homestay live

现在我们来捋一捋他们的关系,Traveler类依赖于协议HotelProtocol,而StarHotol和Homestay是对协议HotelProtocol的实现。这样一来耦合性是不是就大大降低了呢,而且再有什么酒店,遵循这个协议,怎么写都不会影响到Traveler。

总结

很多时候我们遵守一个原则,基本上也能做到遵守到其它原则,每个酒店都有自己的live方法,是不是就满足单一职责原则开闭原则的实现就是依赖倒置原则,如果把HotelProtocol换成Hotel的父类,也就是抽象类,是不是也能满足里氏替换原则

感谢

本次分享到这里就结束了,感谢您的阅读,如果本文对您有什么帮助,别忘了动动手指点个赞 ❤️ 和关注。

更多内容欢迎关注访问下面二维码或搜索公众号:修行全栈,获取更多实用内容。

qrcode_for_gh_0a9d846748ac_344.jpg