w字总结《JavaScript设计模式与开发实践》(设计模式)(下)

1,007 阅读17分钟

「这是我参与11月更文挑战的第3天,活动详情查看:2021最后一次更文挑战

系列文章

w字总结《JavaScript设计模式与开发实践》(基础篇)

w字总结《JavaScript设计模式与开发实践》(设计模式)(上)

w字总结《JavaScript设计模式与开发实践》(设计原则和编程技巧)

设计模式(下)

模板方法模式

定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。

模板方法模式由两部分组成,抽象父类和具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现公共方法和封装子类中方法的执行顺序,子类通过继承它,也继承了算法结构,且子类可选择则重写父类方法。

泡茶泡咖啡

首先我们先对比一下泡茶和泡咖啡的步骤

步骤咖啡
1烧水烧水
2泡茶叶冲泡咖啡
3倒入杯子倒入杯子
4加柠檬加糖加奶

可以看出它们只有在2和4上有着差别,这种情况可以使用模板方法模式。

我们可以将上述步骤抽象成四步:烧水,冲泡,倒入杯子,加调料。接下来用代码描述。

var Beverage = function(){}; 
Beverage.prototype.boilWater = function(){ 
    console.log( '把水煮沸' ); 
}; 
Beverage.prototype.brew = function(){}; // 空方法,应该由子类重写
Beverage.prototype.pourInCup = function(){}; // 空方法,应该由子类重写
Beverage.prototype.addCondiments = function(){}; // 空方法,应该由子类重写
Beverage.prototype.init = function(){ 
    this.boilWater(); 
    this.brew(); 
    this.pourInCup(); 
    this.addCondiments(); 
};

抽象类饮料定义完毕,接下来创建咖啡类,继承饮料类。

var Coffee = function(){}

Coffee.prototype = new Beverage()

Coffee.prototype.brew = function() {
    console.log('沸水冲泡☕')
}

Coffee.prototype.pourInCup = function() {
    console.log('把☕倒进杯子')
}

Coffee.prototype.addCondiments = function() {
    console.log('加糖加奶')
}

var myCoffee = new Coffee()
myCoffee.init()

myCoffee调用init时,由于myCoffee和Coffee原型上都没有对应方法,所以会顺着原型链委托给父类Beverage上的init方法,而Beverage.prototype.init中已经定义好了制作饮料的顺序。

同理。创建茶类

var Tea = function(){}; 
Tea.prototype = new Beverage(); 
Tea.prototype.brew = function(){ 
    console.log( '用沸水浸泡茶叶' ); 
}; 
Tea.prototype.pourInCup = function(){ 
    console.log( '把茶倒进杯子' ); 
}; 
Tea.prototype.addCondiments = function(){ 
    console.log( '加柠檬' ); 
}; 
var tea = new Tea(); 
tea.init();

Beverage.prototype.init方法就是模版方法,因为该方法中封装了子类的算法框架,指导子类以何种顺序去执行那些方法。

优化的模版方法模式

我们在Beverage.prototype.init中已经定义了四个方法的执行,如果子类忘记实现这四个方法中的一个或更多,子类会顺着原型链找到Beverage.prototype中对应的方法,而它是一个空方法,显然不是我们想要的。我们可以在父类的抽象方法中抛出异常,提醒编码人员。

Beverage.prototype.brew = function () {
    throw new Error('子类必须重写brew方法')
} // 空方法,子类重写
Beverage.prototype.pourInCup = function () {
    throw new Error('子类必须重写pourInCup方法')
}
Beverage.prototype.addCondiments = function () {
    throw new Error('子类必须重写addCondiments方法')
}

钩子方法

以冲咖啡为例,有些人不加调料,但是我们已经定义好了制作的步骤,如何才能不受这个约束呢?钩子函数可以解决这个问题,我们在父类中容易变化的地方放置钩子,使不使用由子类决定,钩子函数决定了后边的执行步骤即程序走向。

var Beverage = function () { }

Beverage.prototype.boilWater = function () {
    console.log('boil water');
}

Beverage.prototype.brew = function () {
    throw new Error('子类必须重写brew方法')
} // 空方法,子类重写
Beverage.prototype.pourInCup = function () {
    throw new Error('子类必须重写pourInCup方法')
}
Beverage.prototype.addCondiments = function () {
    throw new Error('子类必须重写addCondiments方法')
}
Beverage.prototype.customerWantsCondiments = function () {
    return true
}
/*
    Beverage.prototype.init是模板方法,其中封装了子类的算法框架,指导子类以何种顺序执行哪些方法。
*/
Beverage.prototype.init = function () {
    this.boilWater()
    this.brew()
    this.pourInCup()
    if (this.customerWantsCondiments()) {
        this.addCondiments()
    }
}

var CoffeeWithHook = function () { }
CoffeeWithHook.prototype = new Beverage()

CoffeeWithHook.prototype.brew = function () {
    console.log('沸水冲☕');
}
CoffeeWithHook.prototype.pourInCup = function () {
    console.log('☕倒进杯子');
}
CoffeeWithHook.prototype.addCondiments = function () {
    console.log('加糖和牛奶');
}
CoffeeWithHook.prototype.customerWantsCondiments = function () {
    // 交互操作是否需要调料
    return false
}
var coffee = new CoffeeWithHook()
coffee.init()

var Tea = function () { }
Tea.prototype = new Beverage()
Tea.prototype.brew = function () {
    console.log('沸水冲茶叶');
}
Tea.prototype.pourInCup = function () {
    console.log('茶倒进杯子');
}
Tea.prototype.addCondiments = function () {
    console.log('加🍋');
}
var tea = new Tea()
tea.init()

享元模式

运用共享技术有效地支持大量细粒度的对象。

享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量。

  • 内部状态存储于对象内部。
  • 内部状态可以被一些对象共享。
  • 内部状态独立于具体的场景,通常不会改变。
  • 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。

享元模式适用性

享元模式带来的好处很大程度上取决于如何使用以及何时使用,一般来说,以下情况发生时便可以使用享元模式。

  • 一个程序中使用了大量的相似对象。
  • 由于使用了大量对象,造成很大的内存开销。
  • 对象的大多数状态都可以变为外部状态。
  • 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。

职责链模式

将多个对象连成一条链,沿这个链传递请求,直到有一个对象处理它,同时传递过程也被终止。

线上售卖手机

情境:假设我们负责一个售卖手机的电商网站,经过分别交纳500元定金和200元定金的两轮预定后(订单已在此时生成),现在已经到了正式购买的阶段。公司针对支付过定金的用户有一定的优惠政策。在正式购买后,已经支付过500元定金的用户会收到100元的商城优惠券,200 元定金的用户可以收到50元的优惠券,而之前没有支付定金的用户只能进入普通购买模式,也就是没有优惠券,且在库存有限的情况下不一定保证能买到。

我们会收到几个字段:

  • orderType:订单类型(定金和普通购买),1代表500定金,2代表200定金,3代表普通用户
  • pay:是否已支付定金。值为true或者false,虽然用户已经下过500元定金的订单,但如果他一直没有支付定金,现在只能降级进入普通购买模式。
  • stock:普通购买的手机库存量,支付过定金的不受影响。

如果我们正常写业务代码,很有可能写成if-else if-else的形式,虽然得到了结果但代码可读性和可维护性很差,接下来用职责链模式重构代码。

职责链模式重构代码

首先把购买的三种情况变成三个函数,接收上边收到的三个字段,并约定如果节点不能处理请求,返回指定字符串nextSuccessor表示需要向后传递。

var order500 = function( orderType, pay, stock ){ 
    if ( orderType === 1 && pay === true ){ 
        console.log( '500 元定金预购,得到 100 优惠券' ); 
    }else{ 
        return 'nextSuccessor'; // 我不知道下一个节点是谁,反正把请求往后面传递
    } 
}; 
var order200 = function( orderType, pay, stock ){ 
    if ( orderType === 2 && pay === true ){ 
        console.log( '200 元定金预购,得到 50 优惠券' ); 
    }else{ 
        return 'nextSuccessor'; // 我不知道下一个节点是谁,反正把请求往后面传递
    } 
}; 
var orderNormal = function( orderType, pay, stock ){ 
    if ( stock > 0 ){ 
        console.log( '普通购买,无优惠券' ); 
    }else{ 
        console.log( '手机库存不足' ); 
    } 
};

然后将函数包装金职责链节点,定义一个构造函数,new Chain的时候传递的参数为需要包装的函数,同时拥有实例属性this.successor,表示在链中的下一个节点。

// Chain.prototype.setNextSuccessor 指定在链中的下一个节点
// Chain.prototype.passRequest 传递请求给某个节点
var Chain = function( fn ){ 
    this.fn = fn; 
    this.successor = null; 
}; 
Chain.prototype.setNextSuccessor = function( successor ){ 
    return this.successor = successor; 
};

Chain.prototype.passRequest = function() {
    var ret = this.fn.apply( this, arguments ); 
    if ( ret === 'nextSuccessor' ){ 
        return this.successor && this.successor.passRequest.apply( this.successor, arguments ); 
    } 
    return ret;
}

将三个订单函数包装成为职责链的节点,并定义顺序,最后把请求传递给第一个节点。

var chainOrder500 = new Chain( order500 ); 
var chainOrder200 = new Chain( order200 ); 
var chainOrderNormal = new Chain( orderNormal );

chainOrder500.setNextSuccessor( chainOrder200 ); 
chainOrder200.setNextSuccessor( chainOrderNormal );

chainOrder500.passRequest( 1, true, 500 ); // 输出:500 元定金预购,得到 100 优惠券
chainOrder500.passRequest( 2, true, 500 ); // 输出:200 元定金预购,得到 50 优惠券
chainOrder500.passRequest( 3, true, 500 ); // 输出:普通购买,无优惠券
chainOrder500.passRequest( 1, false, 0 ); // 输出:手机库存不足

上述代码完成一个灵活的职责链模式的实现,如果又推出了300元定金的活动,那我们只需要添加一个节点即可:

var order300 = function(){ 
 // 具体实现略 
}; 
chainOrder300= new Chain( order300 ); 
chainOrder500.setNextSuccessor( chainOrder300); 
chainOrder300.setNextSuccessor( chainOrder200);

异步的职责链

在业务场景中,经常会遇到异步的问题,比如我们要在节点中发起AJAX请求,请求返回的结果决定是否继续在职责链中passRequest。这时候需要我们暴露一个手动传递请求的方法。

Chain.prototype.next= function(){ 
    return this.successor && this.successor.passRequest.apply( this.successor, arguments ); 
};

举个栗子例子

var fn1 = new Chain(function(){ 
    console.log( 1 ); 
    return 'nextSuccessor'; 
}); 
var fn2 = new Chain(function(){ 
    console.log( 2 ); 
    var self = this; 
    setTimeout(function(){ 
        self.next(); 
    }, 1000 ); 
}); 
var fn3 = new Chain(function(){ 
    console.log( 3 ); 
}); 
fn1.setNextSuccessor( fn2 ).setNextSuccessor( fn3 ); 
fn1.passRequest();

用AOP实现职责链

在之前的职责链实现中,我们利用了一个Chain类来把普通函数包装成职责链的节点。其实利用JavaScript的函数式特性,有一种更加方便的方法来创建职责链。

下面我们改写一下高阶函数实现AOP中的Function.prototype.after函数,使得第一个函数返回'nextSuccessor'时,将请求继续传递给下一个函数,无论是返回字符串'nextSuccessor'或者false都只是一个约定,当然在这里我们也可以让函数返回false表示传递请求,选择'nextSuccessor'字符串是因为它看起来更能表达我们的目的,代码如下:

Function.prototype.after = function( fn ){
    var self = this;
    return function(){
        var ret = self.apply( this, arguments );
        if ( ret === 'nextSuccessor' ){
            return fn.apply( this, arguments );
        }
        return ret;
    }
};
var order = order500yuan.after( order200yuan ).after( orderNormal );
order( 1, true, 500 ); // 输出:500元定金预购,得到100优惠券
order( 2, true, 500 ); // 输出:200元定金预购,得到50优惠券
order( 1, false, 500 ); // 输出:普通购买,无优惠券

用AOP来实现职责链既简单又巧妙,但这种把函数叠在一起的方式,同时也叠加了函数的作用域,如果链条太长的话,也会对性能有较大的影响。

中介者模式

对象和对象之间借助第三方中介者进行通信。

什么是中介者模式

用一个中介对象来封装一系列的对象交互,中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。主要解决对象与对象之间存在大量的关联关系,会导致系统的结构变得很复杂,同时若一个对象发生改变,我们也需要跟踪与之相关联的对象,同时做出相应的处理。在多个类相互耦合形成网状结构时,可使用该模式将其分离为星型结构。它降低了类的复杂度,但中介者会变得复杂且难以维护。

泡泡堂游戏

利用中介者模式实现该游戏,玩家与中介者的关系如图所示。

image

我们定义Player构造函数和player对象,在player中不执行具体逻辑,将操作交给中介者对象playerDirector。而中介者对象playerDirector的实现一般有两种方式:

  • 发布订阅模式:将playerDirector实现为订阅者,player为发布者,player状态改变会推送消息给playerDirector,playerDirector处理后将反馈发送给其他player。
  • 在 playerDirector 中开放一些接收消息的接口,各player可以直接调用该接口来给playerDirector发送消息,player只需传递一个参数给 playerDirector,这个参数的目的是使 playerDirector 可以识别发送者。同样,playerDirector 接收到消息之后会将处理结 果反馈给其他 player。

这里采用第二种方式:

/* 
    playerDirector开放一个对外暴露的接口receiveMessage,负责接收player对象发送的消息。
    player发送消息时将自身this传给其,以便识别消息来自于哪个玩家对象。
 */

var playerDirector = (function () {
    var players = {} // 保存所有玩家
    var operations = {} // 中介者可以执行的操作

    operations.addPlayer = function (player) {
        var teamColor = player.teamColor
        players[teamColor] = players[teamColor] || []
        players[teamColor].push(player)
    }

    operations.removePlayer = function (player) {
        var teamColor = player.teamColor
        var teamPlayers = players[teamColor] || []
        for (var i = teamPlayers.length - 1; i >= 0; i--) {
            if (teamPlayers[i] === player) {
                teamPlayers.splice(i, 1)
            }
        }
    }

    operations.changeTeam = function (player, newTeamColor) {
        operations.removePlayer(player)
        player.teamColor = newTeamColor
        console.log(player);
        operations.addPlayer(player)
    }

    operations.playerDead = function (player) {
        var teamColor = player.teamColor
        var teamPlayers = players[teamColor]
        var all_dead = true
        for (var i = 0, player; player = teamPlayers[i++];) {
            if (player.state !== 'dead') {
                all_dead = false
                break
            }
        }

        if (all_dead) {
            for (var i = 0, player; player = teamPlayers[i++];) {
                player.lose()
            }

            for (var color in players) {
                if (color !== teamColor) {
                    var teamPlayers = players[color]
                    for (var i = 0, player; player = teamPlayers[i++];) {
                        player.win()
                    }
                }
            }
        }
    }

    var receiveMessage = function () {
        var message = Array.prototype.shift.call(arguments)
        operations[message].apply(this, arguments)
    }

    return {
        receiveMessage,
        players
    }
})()

装饰者模式

在不改变原对象的基础上,通过对其添加属性或方法来进行包装拓展,使得原有对象可以动态具有更多功能。

装修房子

我们在毛坯房建好后,都会对房子进行装修和添置家具,让房子逐渐美观舒适,但这些并没有影响房子本身的功能,这就是装饰。

function OriginHouse() { }

OriginHouse.prototype.getDesc = function () {
    console.log("空房子");
}

function Furniture(house) {
    this.house = house;
}
Furniture.prototype.getDesc = function () {
    this.house.getDesc();
    console.log("搬入家具");
}

function Painting(house) {
    this.house = house;
}

Painting.prototype.getDesc = function () {
    this.house.getDesc();
    console.log("刷房子")
}
let house = new OriginHouse()
house = new Furniture(house)
house = new Painting(house)

// house.getDesc()

var originHouse = {
    getDesc() {
        console.log("origin house");
    }
}

function furniture() {
    console.log("furniture");
}

function painting() {
    console.log("painting");
}
originHouse.getDesc = function () {
    var getDesc = originHouse.getDesc;
    return function () {
        getDesc();
        furniture();
        painting();
    }
}()
originHouse.getDesc();

装饰者模式和代理模式

从结构上看两者非常像,都是描述了怎么为对象提供一定程度的间接引用,都保留了对另一个对象的引用并向其发送请求。

两者最大的区别在他们的设计和意图,代理模式的目的是,当直接访问本体不方便或和需要不符时,为其提供一个代替者,本体只提供关键功能,代理负责提供或拒绝对它的访问或在访问本体之前做一些额外的工作。装饰者模式的作用是对象动态加入行为。

代理模式更强调代理与实体之间的关系,这种关系在一开始就可以被确定,通常只有一层代理-本体的引用。装饰者模式则用于一开始无法确定对象的全部功能,后续逐步添加装饰,可能会形成一条装饰链。

状态模式

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

将状态封装成独立的类,并将请求委托给当前状态对象,对象内部状态改变时会有不同的行为变化。

举两个例子说明一下

电灯程序

首先定义了Light类,Light类在这里也被称为上下文(Context)。随后在Light的构造函数中,我们要创建每一个状态类的实例对象,Context将持有这些状态对象的引用,以便把请求委托给状态对象。用户的请求,即点击button的动作也是实现在Context中。

var Light = function(){ 
    this.offLightState = new OffLightState( this ); // 持有状态对象的引用
    this.weakLightState = new WeakLightState( this ); 
    this.strongLightState = new StrongLightState( this ); 
    this.superStrongLightState = new SuperStrongLightState( this ); 
    this.button = null; 
};

Light.prototype.init = function(){ 
    var button = document.createElement( 'button' ),
    self = this; 
 
    this.button = document.body.appendChild( button ); 
    this.button.innerHTML = '开关'; 
    this.currState = this.offLightState; // 设置默认初始状态
    this.button.onclick = function(){ // 定义用户的请求动作
        self.currState.buttonWasPressed(); 
    } 
};

// 编写各种状态类,light对象被传入状态类的构造函数,状态对象也需要持有light对象的引用,以便调用light中的方法或者直接操作light对象。

var OffLightState = function( light ){ 
    this.light = light; 
}; 
OffLightState.prototype.buttonWasPressed = function(){ 
    console.log( '弱光' ); 
    this.light.setState( this.light.weakLightState ); 
};

超级玛丽

超级玛丽拥有多个状态比如 跳跃、移动、蹲下、射击,如果对它们意义判断,需要多个if-else结构或switch结构,单个动作尚且可以实现,如果遇到了组合动作,实现会更加复杂,使用状态模式可以简单实现。

首先创建一个状态对象数组,内部对状态进行保存,封装好每种动作对应的状态,暴露一个接口对象,它可以对内部状态修改或调用。

class SuperMarry {
  constructor() {
    this._currentState = []
    this.states = {
      jump() {console.log('跳跃!')},
      move() {console.log('移动!')},
      shoot() {console.log('射击!')},
      squat() {console.log('蹲下!')}
    }
  }
  
  change(arr) {  // 更改当前动作
    this._currentState = arr
    return this
  }
  
  go() {
    console.log('触发动作')
    this._currentState.forEach(T => this.states[T] && this.states[T]())
    return this
  }
}

new SuperMarry()
    .change(['jump', 'shoot'])
    .go()                    // 触发动作  跳跃!  射击!
    .go()                    // 触发动作  跳跃!  射击!
    .change(['squat'])
    .go()                    // 触发动作  蹲下!

状态模式优缺点

优点:

  • 状态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换。
  • 避免 Context 无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了 Context 中原本过多的条件分支。
  • 用对象代替字符串来记录当前状态,使得状态的切换更加一目了然。
  • Context 中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响。

缺点:系统中定义许多状态类,编写20个状态类是一项枯燥乏味的工作,而且系统中会因此而增加不少对象。另外,由于逻辑分散在状态类中,虽然避开了不受欢迎的条件分支语句,但也造成了逻辑分散的问题,我们无法在一个地方就看出整个状态机的逻辑。

与策略模式的关系

策略模式和状态模式的相同点是,它们都有一个上下文、一些策略或者状态类,上下文把请求委托给这些类来执行。

它们之间的区别是策略模式中的各个策略类之间是平等又平行的,它们之间没有任何联系,所以客户必须熟知这些策略类的作用,以便客户可以随时主动切换算法;而在状态模式中,状态和状态对应的行为是早已被封装好的,状态之间的切换也早被规定完成,“改变行为”这件事情发生在状态模式内部。对客户来说,并不需要了解这些细节。这正是状态模式的作用所在。

适配器模式

将一个类(对象)的接口(方法或属性)转化成客户希望的另外一个接口(方法或属性),适配器模式使得原本由于接口不兼容而不能一起工作的那些类(对象)可以一些工作。

适配器模式是一种“亡羊补牢”模式,没有人会在程序设计之初使用,因为无法预料到未来的改动,在未来的某天也许我们需要用适配器模式将旧接口包装成新接口,保证其可用性。

数据处理

前端在处理数据的时候其实就使用了适配器模式。我们需要将现有数据构造成我们需要的格式:

const arr = ['Javascript', 'book', '设计模式', '1月1日']
function arr2objAdapter(arr) {    // 转化成我们需要的数据结构
  return {
    name: arr[0],
    type: arr[1],
    title: arr[2],
    time: arr[3]
  }
}

const adapterData = arr2objAdapter(arr)

适用范围

  • 使用一个现有对象,但其属性或方法不符合你的使用要求。
  • 想创建一个可复用的对象,该对象可以与其它不相关的对象或不可见对象(即接口方法或属性不兼容的对象)协同工作。
  • 想使用现有对象,但不能对每一个都进行原型继承,对象适配器可以适配它的父类接口方法或属性。

与其他模式的区别

  • 适配器模式主要用来解决两个已有接口之间不匹配的问题,它不考虑这些接口是怎样实现的,也不考虑它们将来可能会如何演化。适配器模式不需要改变已有的接口,就能够使它们协同作用。
  • 装饰者模式和代理模式也不会改变原有对象的接口,但装饰者模式的作用是为了给对象增加功能。装饰者模式常常形成一条长的装饰链,而适配器模式通常只包装一次。代理模式是为了控制对对象的访问,通常也只包装一次。
  • 外观模式的作用倒是和适配器比较相似,有人把外观模式看成一组对象的适配器,但外观模式最显著的特点是定义了一个新的接口。