Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端设计模式总结(二) #42

Open
MrErHu opened this issue Apr 21, 2019 · 0 comments
Open

前端设计模式总结(二) #42

MrErHu opened this issue Apr 21, 2019 · 0 comments
Labels
base Front End Basis

Comments

@MrErHu
Copy link
Owner

MrErHu commented Apr 21, 2019

  上次分享介绍了五种关于行为范例的设计模式,这次我们接着介绍其他的几种常见行为范例。

状态模式

  GoF对状态模式的定义如下:

允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。

  也就是说当内部状态发生时改变时,其行为模式也会发生改变。行为取决于状态。比如在下面的场景:

  对于一个按钮而言,当处于点亮状态时,触发开关时灯会被关闭。而在关闭状态时,触发开关灯会被开启,这是一个非常典型的相同行为会在不同状态下触发不同的行为。那上面的场景我们用代码可以表达为:

class Light {
    constructor(){
        this.state = "off";
    }
 
    buttonWasPressed(){
        if ( this.state === 'off' ){
            console.log( '开灯' );
            this.state = 'on';
        }else if ( this.state === 'on' ){
            console.log( '关灯' );
            this.state = 'off';
        }
    }
}
 
const light = new Light();
light.buttonWasPressed();  // 开灯
light.buttonWasPressed();  // 关灯

  上面的代码看起来非常简单,虽然if/else看起来并不是那么美观,好在状态也并不复杂。但是假设我们的灯有非常的多的状态,例如: 关灯、弱光、正常光、强关、超强光,那么我们改如何实现呢,首先我们能想到最简单的方式就是扩充buttonWasPressed

buttonWasPressed(){
    if ( this.state === 'off' ){
        console.log( '弱光' );
        this.state = 'weakLight';
    }else if ( this.state === 'weakLight' ){
        console.log( '正常光' );
        this.state = 'normalLight';
    }else if ( this.state === 'normalLight') {
        console.log( '强光' );
        this.state = 'strongLight';
    }else if ( this.state === 'strongLight' ){
        console.log( '超强光' );
        this.state = 'superLight';
    }else if( this.state === 'superLight') {
        console.log( '关灯' );
        this.state = 'off';
    }
}

  当然这样也能实现,但是这明显是违反了开闭原则,如果又有新的状态增加,我们不得不去深入函数buttonWasPressed的内部去修改逻辑状态。并且状态的切换非常的不明显,我们一眼并不能看出到底存在多少个状态,并且if/else带来的是代码难以阅读和难以维护。这种场景下我们可以考虑通过状态模式去改写场景。一般而言,我们对所指的封装多是指对象行为的封装,但是在状态模式下,封装是指对单个状态的封装,对象行为通过委托给不同的状态对象实现。为了方便叙述,我们假设仅存在三个状态: 关灯、开灯三种状态。

class OffLightState {
    buttonWasPressed(light){
        console.log("开灯");
        light.setState( light.onLightState );
    }
}
 
 
class OnLightState {
    buttonWasPressed(light){
        console.log("关闭");
        light.setState( light.offLightState );
    }
}
 
class Light {
    constructor(){
        this.offLightState = new OffLightState();
        this.onLightState = new OnLightState();
        // 设置当前状态
        this.currState = this.offLightState;   
    }
 
    setState(state){
        this.currState = state;
    }
 
    buttonWasPressed(){
        this.currState.buttonWasPressed(this);
    }
}
 
const light = new Light();
light.buttonWasPressed(); // 开灯
light.buttonWasPressed(); // 关灯

  我们将状态封装成独立的类,并将请求委托给当前的状态对象,当对象的内部状态改变时,会带来不同的行为变化。我们解决之前提到的状态切换不明确的问题,通过用对象代替字符串来记录当前状态,使得状态的切换更加清晰。并且所有的状态变化都是通过setState函数完成,并且内部实现逻辑不会因为状态增多而导致代码膨胀,从而导致可读性和可维护性的下降。但是状态模式的缺点也非常的明显,状态对象的编写也是不小的工作量,其次各个状态的实现逻辑分布在不同的状态对象中,使得业务逻辑分散,无法更好的掌握整个状态机的转化逻辑。

  在上面的实现上,我们在Light对象创建时就分别创建了各个状态的对象,如果对象代价不大的情况下我们可以这样操作,但是如果状态对象的创建属于高成本操作,我们也可以将状态对象的创建置后到需要时才创建,避免那些永远不会出现的状态不必要的创建。状态对象的销毁也需要根据具体的业务逻辑具体处理,状态的改变很频繁,也没有必要销毁它们,因为可能很快将再次用到它们。

  上面的代码其实还是基于Java、C++等强类型编译语言,而对JavaScript而言,我们的对象并不一定要由类从而创建,因此对于JavaScript,我们的状态模式可以写成:

var FSM = {
    off: {
        buttonWasPressed: function(){
            console.log( '关灯' );
            this.setState(FSM.on);
        }
    },
    on: {
        buttonWasPressed: function(){
            console.log( '开灯' );
            this.setState(FSM.off);
        }
    }
};
 
class Light {
    constructor(){
        this.currState = FSM.off;   
    }
 
    setState(state){
        this.currState = state;
    }
 
    buttonWasPressed(){
        this.currState.buttonWasPressed.apply((this));
    }
}
 
const light = new Light();
light.buttonWasPressed(); // 开灯
light.buttonWasPressed(); // 关灯

  因为JavaScript属于弱类型语言,没有规定让状态对象一定要从类中创建,因此我们创建了一个字面量常量的FSM状态机,使用请求委托的方式实现。或者我们也可以采用采用闭包的方式替换之前面对对象的设计方式:

var delegate = function( client, delegation ){
    return {
        buttonWasPressed: function(){    // 将客户的操作委托给delegation对象
            return delegation.buttonWasPressed.apply( client, arguments );
        }
    }
};
class Light {
    constructor(){
        this.offLightState = delegate();
        this.onLightState = delegate();
    }
}

策略模式

  在程序设计中,实现某一个功能可能会存在多种方式可选,例如在加密中,我们可以用例如MD5的Hash算法,也可以用例如RSA等非对称加密,当然可以用类似DES等对称加密,各种实现方式灵活多样,都能达到最终的目的。所谓的策略模式是指:

定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

  借用常见的计算年终奖的例子,一般公司的年终奖都很绩效挂钩,假设我们将绩效分成A、B、C三个档次,A是六个月年终奖,B是三个月年终奖,而C仅有一个月的年终奖,因此我们可以看出,年终奖适合公司每个人的绩效和月薪资水平挂钩,因为我们可以轻松的写入下面的函数:

function calculateBonus( performanceLevel, salary ){
 
    if ( performanceLevel === 'A' ){
        return salary * 6;
    }
 
    if ( performanceLevel === 'B' ){
        return salary * 3;
    }
 
    if ( performanceLevel === 'C' ){
        return salary * 1;
    }
 
}
 
calculateBonus('A', 1000) // 6000
calculateBonus('B', 1000) // 3000
calculateBonus('C', 1000) // 1000

  calculateBonus函数确实实现了我们的需求,但是也存在非常多显而易见的缺点,首先,calculateBonus充斥着大量的if、else语句,阅读性和维护性很差,并且不易扩展,例如假设我们现在要增加一个S级的年终奖,用来表彰那种贡献卓越的员工12个月年终奖,那么我们不得不深入calculateBonus内部去修改,这明显是违反开闭原则的。并且如果现在我们想要调整A级的年终奖变成7个月,也不得不深入calculateBonus函数内部做修改,这个时候,我们就可以借用策略模式来重新我们的算法:

class performanceA {
    calculate(salary) {
        return salary * 6;
    }
}
 
class performanceB {
    calculate(salary) {
        return salary * 3;
    }
}
 
class performanceC {
    calculate(salary) {
        return salary * 1;
    }
}
 
class Bonus {
    constructor () {
        this.salary = null;      // 原始工资
        this.strategy = null;    // 绩效等级对应的策略对象
    }
 
    setSalary(salary){
        this.salary = salary;
    }
 
    setStrategy(strategy){
        this.strategy = strategy;
    }
 
    getBonus(){
        return this.strategy.calculate( this.salary );
    }
}
 
const bonus = new Bonus();
bonus.setSalary( 1000 );
bonus.setStrategy( new performanceA() );
bonus.getBonus(); // 6000

  我们可以看到,在上面的例子中我们将各个计算奖金算法封装成一个个策略类,Bonus的所有请求都最终被委托给具体的策略类去处理,可以看到通过策略模式重构之后,代码变得更加清晰,各个类的职责更加鲜明。但实际上我们知道上面的类似也是基于强类型语言,对于JavaScript这样灵活的语言,我们并不需要这么麻烦。

const strategies = {
    "A": function( salary ){
        return salary * 6;
    },
    "B": function( salary ){
        return salary * 3;
    },
    "C": function( salary ){
        return salary * 1;
    }
};
function calculateBonus ( level, salary ){
    return strategies[ level ]( salary );
};
 
console.log( calculateBonus( 'A', 1000 ) );     // 6000

  甚至因为JavaScript中函数作为一等公民,我们整个的程序可以退化为:

var A = function( salary ){
   return salary * 6;
};
 
 
var B = function( salary ){
   return salary * 3;
};
 
var C = function( salary ){
    return salary * 1;
 };
 
var calculateBonus = function( func, salary ){
   return func( salary );
};
 
calculateBonus( A, 1000  );    // 6000

  接下来,我们举一个更为常见的场景,我们可以利用策略模式重构表单校验,例如:

function validate (text){
    if ( text === '' ){
        alert ( '用户名不能为空' );
        return false;
    }
 
 
    if ( text < 6 ){
        alert ( '密码长度不能少于6位' );
        return false;
    }
    if ( !/(^1[3|5|8][0-9]{9}$)/.test( text ) ){
        alert ( '手机号码格式不正确' );
        return false;
    }
}

  上面也存在我们之前所提到的各种各样的问题,例如扩展性差,维护性差,我们尝试使用策略模式重构:

const strategies = {
    isNonEmpty: function( value, errorMsg ){    // 不为空
        if ( value === '' ){
            return errorMsg ;
        }
    },
    minLength: function( value, length, errorMsg ){    // 限制最小长度
        if ( value.length < length ){
            return errorMsg;
        }
    },
    isMobile: function( value, errorMsg ){    // 手机号码格式
        if ( !/(^1[3|5|8][0-9]{9}$)/.test( value ) ){
            return errorMsg;
        }
    }
};
 
 
class Validator {
 
    constructor(){
        this.cache = [];
    }
 
    add(value, rules){
        var self = this;
        for ( var i = 0, rule; rule = rules[ i++ ]; ){
            var strategyAry = rule.strategy.split( ':' );
            var errorMsg = rule.errorMsg;
     
            self.cache.push(function(){
                var strategy = strategyAry.shift();
                strategyAry.unshift( value );
                strategyAry.push( errorMsg );
                return strategies[ strategy ].apply( value, strategyAry );
            });
        }
 
    }
 
    start = function(){
        for ( var i = 0, validatorFunc; validatorFunc = this.cache[ i++ ]; ){
            var msg = validatorFunc();    // 开始校验,并取得校验后的返回信息
            if ( msg ){     // 如果有确切的返回值,说明校验没有通过
                return msg;
            }
        }
    };
}
 
let validator = new Validator();
validator.add( "MrErHu", [{
    strategy: 'isNonEmpty',
    errorMsg: '用户名不能为空'
}, {
    strategy: 'minLength:10',
    errorMsg: '用户名长度不能小于10位'
}]);
validator.start();

  策略模式可以有效的避免多重条件选择语句,并且策略模式将算法封装在具体的策略类中,易于扩展和复用。策略模式主要使用的组合和委托的方式使得客户在不实现具体算法的情况下,可以实现具体的功能。但是策略模式也存在一些缺点,首先程序中增加了较多的策略类和策略对象,
其次使用者需要必须掌握所有策略算法,能才各种策略算法中找到最合适的策略算法,这是违反知识最少原则的。

  策略模式和状态模式其实具有相同之处,它们都封装了各自的算法,采用委托的形式实现具体的逻辑功能。但是二者模式却有明显的区别。对于策略模式,各个策略之间是相互平等的,用户需要熟悉各种策略,以便找到最合适的算法主动替换。而在状态模式中,状态之间是相互联系的,并且不同的状态之间的转化关系已经提前确定,用户其实并不需要了解各个状态之间的转化关系,是符合知识最少原则的。

命令模式

  首先我们举一个快餐店的例子,当某位客人点餐或者打来订餐电话后,我会把他的需求都写在清单上,然后交给厨房,客人不用关心是哪些厨师帮他炒菜。我们餐厅还可以满足客人需要的定时服务,比如客人可能当前正在回家的路上,要求1个小时后才开始炒他的菜,只要订单还在,厨师就不会忘记。客人也可以很方便地打电话来撤销订单。另外如果有太多的客人点餐,厨房可以按照订单的顺序排队炒菜。这些记录着订餐信息的清单,便是命令模式中的命令对象。

命令模式是指,请求以命令的形式包裹在对象中,并传给调用对象。调用对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执行命令。

  所谓命令是指执行某一特定功能的指令,在命令模式中,我们可能并不知道命令的执行者是谁,可能也并不知道命令的发出者是谁,命令的发送者和执行者是不存在耦合关系。甚至命令的生命周期是长于发送者和执行者的,拥有更长的生命周期。除此之外,命令模式还支持撤销、排队等操作。

  假设我们存一个一个用户界面,界面上存在各式各样的按钮,我们将界面绘制分配给一个程序员,而且按钮触发后的逻辑分配给另外一个程序员,对于界面绘制的程序员而言,其实他可能压根就不知道这个按钮将来会做什么,他所需要专注的是如何准确地还原视觉图,而对于编写逻辑代码的程序而言,他并不知道那个按钮会触发对应的逻辑,那么如何能桥接界面与具体业务逻辑呢?

  如果采用命令模式之后,点击了按钮之后,必须向某些负责具体行为的对象发送请求,这些对象就是请求的接收者。

<!--分别对应功能: 刷新菜单目录、增加子菜单、删除子菜单-->
<!--编写用户界面的程序员上传的代码-->
<body>
    <button id="button1">点击按钮1</button>
    <button id="button2">点击按钮2</button>
    <button id="button3">点击按钮3</button>
</body>
 
<script>
    var button1 = document.getElementById( 'button1' ),
    var button2 = document.getElementById( 'button2' ),
    var button3 = document.getElementById( 'button3' );
</script>
// 编写业务逻辑程序员上传的代码
var MenuBar = {
    refresh: function(){
        console.log( '刷新菜单目录' );
    }
};
 
var SubMenu = {
    add: function(){
        console.log( '增加子菜单' );
    },
    del: function(){
        console.log( '删除子菜单' );
    }
};
class RefreshMenuBarCommand {
    constructor(receiver){
        this.receiver = receiver;
    }
 
 
    execute(){
        this.receiver.refresh();
    }
}
 
class AddSubMenuCommand {
    constructor(receiver){
        this.receiver = receiver;
    }
 
    execute(){
        this.receiver.add();
    }
}
 
class DelSubMenuCommand {
    constructor(receiver){
        this.receiver = receiver;
    }
    execute(){
        this.receiver.del()
    }
}
 
 
const refreshMenuBarCommand = new RefreshMenuBarCommand( MenuBar );
const addSubMenuCommand = new AddSubMenuCommand( SubMenu );
const delSubMenuCommand = new DelSubMenuCommand( SubMenu );
 
setCommand( button1, refreshMenuBarCommand );
setCommand( button2, addSubMenuCommand );
setCommand( button3, delSubMenuCommand );

  我们创建了类似于各种各样的命令类: RefreshMenuBarCommand、AddSubMenuCommand、DelSubMenuCommand。其中每个命令类中都存在一个execute实例方法和receiver实例属性,receiver则是最终命令的执行者。然后我们命令类创建了各种各种的命令对象,利用setCommand方法将按钮于命令相结合。其实上面的代码对于前端而言非常的诡异,因为在JavaScript从来没有人会这么做,事实上,receiver和command的引入,反而使得结构变得不清晰:

var bindClick = function( button, func ){
    button.onclick = func;
};
 
 
var MenuBar = {
    refresh: function(){
        console.log( '刷新菜单界面' );
    }
};
 
var SubMenu = {
    add: function(){
        console.log( '增加子菜单' );
    },
    del: function(){
        console.log( '删除子菜单' );
    }
};
 
bindClick( button1, MenuBar.refresh );
bindClick( button2, SubMenu.add );
bindClick( button3, SubMenu.del );

  为什么会出现这种情况,这就是JavaScript这种函数作为一等公民的语言和传统的面对对象的编程语言的区别,在传统的面对对象的语言中,命令模式将过程式的请求调用封装在command对象的execute方法里,通过封装方法调用,我们可以把运算块包装成形。command对象可以被四处传递,所以在调用命令的时候,客户不需要关心事情是如何进行的。因此,可以认为命令模式的由来,其实是回调函数的一个面向对象的替代品。

  我们之前提过,命令模式还可以实现命令的撤销和重做。对于撤销而言,命令对象中可能会存在一个unexecude或者undo方法,比如我们存在一个小球运动的场景,execute会使得小球正向运动,而unexecude则会使用小球反向运动,当时具体的实现策略是多种的,我们不再详细展开叙述。而命令的重做是指,在某些场景下,已经执行了A、B、C等十个命令,如果我们想要返回到第三步C的状态,如果采用反向撤销,可能需要unexecude七个步骤,更重要的是可能某些操作压根就没法撤销,因此我们可以利用一个历史列表堆栈办到。记录命令日志,然后重复执行它们。对于命令模式的命令队列是指,命令一旦发出其生命周期可以认为是永久的,因此我们可以在程序运行的任何时刻执行,因此在某些特殊的情况下,我们可以将命令对象放入命令队列中,使得各个命令可以依次执行。

基本设计原则

单一职责原

  单一职责原则(Single Responsibility Principle)指的是引起变化的原因只有一种,其实简单的说就是一个类或者一个方法只负责一件事。如果某一个方法或者类是由多个因素导致的,那么就相当于其存在多个职责。如果一个方法承担了过多的职责,那么在需求的变迁过程中,需要改写这个方法的可能性就越大,也就是这个方法越不稳定,这种耦合性得到的是低内聚和脆弱的设计。

  单一职责原则在非常多的设计模式中都有体现,例如:

  • 代理模式
  • 迭代器模式

  单一职责原则原理非常的简单但实际运用却有一定难度,并不是所有的职责都需要分类,随着需求的变化,职责总是同步变化,那么就没有必要分离。而且即使两个职责相互耦合,但是目前阶段并没有变化的预兆时,并不一定需要提前主动分离他们。毕竟过早优化是万恶之源。

最少知识原则

  最小知识原则(Principle of Least Knowledge,PLK,也叫迪米特法则)核心思想是不和陌生人说话,通俗之意是一个对象对自己需要耦合关联调用的类应该知道的更少。这样会导致类之间的耦合度降低,每个类都尽量减少对其他类的依赖,因此,这也很容易使得系统的功能模块相互独立,之间不存在很强的依赖关系。单一职责原则指导我们将对象划分成比较小的颗粒,提高对象的可复用程度,但是过多的对象若是相互联系,则会造成各个对象之间耦合过深。最少知识原则要求我们在设计程序时,应当尽量减少对象之间的交互。如果两个对象之间不必彼此直接通信,那么这两个对象就不要发生直接的相互联系。常见的做法是引入一个第三者对象,来承担这些对象之间的通信作用。如果一些对象需要向另一些对象发起请求,可以通过第三者对象来转发这些请求。

  最小知识原则在模式设计中非常常见,比较典型的就是中介者模式。最小只是原则在实际编码中更多体现是的封装的思想。封装主要的思想就是将模块或者对象的内部细节隐藏起来,只暴露必要的API供外界调用,毕竟对象之间难免互相影响,只暴露必要的接口。最大程度上将对象之前的联系限制在最小范围内。

开放闭合原则

  开闭原则(Open Closed Principle,OCP)是面向对象设计中“可复用设计”的基石原则,是面向对象设计中最重要的原则之一。所谓的开闭原则只是的对拓展开放,对修改关闭,对拓展开放,重点在于当有新的或变化的需求时,可以通过对现有代码的拓展来实现,而不需要该变原有程序的结构与内容。对修改关闭,重点在于程序设计一旦完成,其预定功能即按照既定独立工作,在不可对其做修改操作。在面对对象的思维中,开闭原则的精髓在于面对抽象。让类依赖于固定的抽象对象,即可以达到封闭修改的目的,而通过面向对象的继承和多态机制,又可以实现对抽象类的继承,通过复写其方法来改变固有的行为操作,也可以实现新的拓展方法,即可以达到开放的目的。

  对于开闭原则,比较常见的场景是,在过多的使用分支条件是绝对违反开闭原则的,当然仅仅只是将if分支语句变成switch,其本质也是换汤不换药,并不能从根本上解决问题。这个时候其实从面对对象的角度去看,就可以考虑利用多态的方式,举一个例子:

var makeSound = function( animal ){
    if ( animal instanceof Duck ){
        console.log( '嘎嘎嘎' );
    }else if ( animal instanceof Chicken ){
        console.log( '咯咯咯' );
    }else if ( animal instanceof Dog ){
       console.log('汪汪汪' );
   }
};
 
var Dog = function(){};
makeSound( new Dog() );

  上面的例子就是非常多的分支语句,就是典型的不符合开闭原则的代码,如果我们还想增加一种猫的类型,那么就不得不深入函数内部去修改。

class Duck {
    sound(){
        console.log( '嘎嘎嘎' );
    }
}
 
class Chicken {
    sound(){
        console.log( '咯咯咯' );
    }
}
 
class Dog {
    sound(){
        console.log( '汪汪汪' );
    }
}
 
var makeSound = function( animal ){
    animal.sound();
};
 
makeSound( new Duck() );   
makeSound( new Chicken() );
makeSound( new Dog() );

  总结的说,开闭规则的要点在于找出程序中的变化的部分,通过封装变化的方式,可以把系统中稳定不变的部分和容易变化的部分隔离开来。在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经被封装好的,那么替换起来也相对容易。而变化部分之外的就是稳定的部分。在系统的演变过程中,稳定的部分是不需要改变的。除了多态的方式,其他比较常见的方式是使用Hook,例如各个前端库中组件的生命周期函数,实质上也是解决这个问题。

  日常中我们常见的符合开闭原则的模式有:

  • 模板方法模式
  • 策略模式

  备注:上面代码来源于曾探的《JavaScript设计模式与开发实践》一书,描述的非常生动形象,非常推荐大家阅读,上面仅作为是本人的学习总结,望共同进步。

@MrErHu MrErHu added the base Front End Basis label Apr 21, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
base Front End Basis
Projects
None yet
Development

No branches or pull requests

1 participant