03 设计模式(下)

本篇是《JavaScript 设计模式与开发实践》第二部分读书笔记,总结后 7 种设计模式:模板方法模式、享元模式、职责链模式、中介者模式、装饰者模式、状态模式、适配器模式。

模板方法模式

模板方法模式是一种只需使用继承就可以实现的非常简单的模式。

模板方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

Coffee or Tea

以下是一个冲泡咖啡或茶的例子,这里将咖啡与茶抽象为“饮料”,冲泡和浸泡抽象为“泡”,牛奶和柠檬抽象为“调料”。

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 Coffee = new Coffee()
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()

上面例子中,真正的模板方法是 Beverage.prototype.init,它被称为模板方法的原因是,该方法中封装了子类的算法框架,它作为一个算法的模板,指导子类以何种顺序去执行哪些方法。

抽象类

模板方法模式是一种严重依赖抽象类的设计模式。JavaScript 在语言层面并没有提供对抽象类的支持,也很难模拟抽象类的实现,这里以 Java 中的抽象类讨论。

在 Java 中,类分为两种,一种为具体类,另一种为抽象类。具体类可以被实例化,抽象类不能被实例化。抽象类和接口一样可以用于向上转型,把对象的真正类型隐藏在抽象类或者接口之后,这些对象才可以被互相替换使用,这可以让 Java 程序尽量遵守依赖倒置原则。

除了用于向上转型,抽象类也可以表示一种契约。继承了这个抽象类的所有子类都将拥有跟抽象类一致的接口方法,抽象类的主要作用就是为它的子类定义这些公共接口。

抽象方法被声明在抽象类中,抽象方法并没有具体的实现过程,是一些“哑”方法。除了抽象方法之外,如果每个子类中都有一些同样的具体实现方法,那这些方法也可以选择放在抽象类中,这可以节省代码以达到复用的效果,这些方法叫作具体方法。

JavaScript 并没有从语法层面提供对抽象类的支持。抽象类的第一个作用是隐藏对象的具体类型,由于 JavaScript 是一门“类型模糊”的语言,所以隐藏对象的类型在 JavaScript 中并不重要。另一方面,当在 JavaScript 中使用原型继承来模拟传统的类式继承时,并没有编译器进行任何形式的检查,没有办法保证子类会重写父类中的“抽象方法”。

下面提供两种变通的解决方案。

  • 第 1 种方案是用鸭子类型来模拟接口检查,以便确保子类中确实重写了父类的方法。但模拟接口检查会带来不必要的复杂性,而且要求程序员主动进行这些接口检查,这就要求在业务代码中添加一些跟业务逻辑无关的代码。

  • 第 2 种方案是让抽象方法方法直接抛出一个异常,如果因为粗心忘记编写子类方法,那么至少会在程序运行时得到一个错误,这种方式实现简单,缺点是得到错误信息的时间点太靠后。

钩子方法

通过模板方法模式,在父类中封装了子类的算法框架,但如果父类的算法框架并不适用全部子类,那便需要用到钩子方法,放置钩子是隔离变化的一种常见手段。

在父类中容易变化的地方放置钩子,钩子可以有一个默认的实现,究竟要不要“挂钩”,这由子类自行决定。钩子方法的返回结果决定了模板方法后面部分的执行步骤,也就是程序接下来的走向,这样一来,程序就拥有了变化的可能。

依旧以上面的 Coffee or Tea 代码为例,改写模板方法:

Beverage.prototype.customerWantsCondiments = function() {
  return true // 默认需要调料
}
Beverage.prototype.init = function() {
  this.boilWater()
  this.brew()
  this.pourInCup()
  if (this.customerWantsCondiments()) {
    this.addCondiments()
  }
}

// 省略其他代码...
Coffee.prototype.customerWantsCondiments = function() {
  return window.confirm('请问需要调料吗?')
}

好莱坞原则

好莱坞原则指的是,允许底层组件将自己挂钩到高层组件中,而高层组件会决定什么时候、以何种方式去使用这些底层组件,高层组件对待底层组件的方式,跟演艺公司对待新人演员一样,都是“别调用我们,我们会调用你”。

模板方法模式是好莱坞原则的一个典型使用场景,它与好莱坞原则的联系非常明显,当我们用模板方法模式编写一个程序时,就意味着子类放弃了对自己的控制权,而是改为父类通知子类,哪些方法应该在什么时候被调用。作为子类,只负责提供一些设计上的细节。

除此之外,好莱坞原则还常常应用于其他模式和场景,例如发布-订阅模式和回调函数。

在发布—订阅模式中,发布者会把消息推送给订阅者,取代了原先不断去 fetch 消息的形式。在回调函数中,如 ajax 请求,当数据返回之后,回调函数才被执行,取代了时刻轮询判断是否返回了数据。

真的需要“继承”吗

模板方法模式是基于继承的一种设计模式,父类封装了子类的算法框架和方法的执行顺序,子类继承父类之后,父类通知子类执行这些方法,好莱坞原则很好地诠释了这种设计技巧,即高层组件调用底层组件。

模板方法模式是为数不多的基于继承的设计模式,但 JavaScript 语言实际上没有提供真正的类式继承,继承是通过对象与对象之间的委托来实现的。

下面改写上面的 Coffee or Tea 的例子:

var Beverage = function(param) {
  var boilWater = function() {
    console.log('把水煮沸')
  }
  var brew =
    param.brew ||
    function() {
      throw new Error('必须传递 brew 方法')
    }
  var pourInCup =
    param.pourInCup ||
    function() {
      throw new Error('必须传递 pourInCup 方法')
    }
  var addCondiments =
    param.addCondiments ||
    function() {
      throw new Error('必须传递 addCondiments 方法')
    }

  var F = function() {}
  F.prototype.init = function() {
    boilWater()
    brew()
    pourInCup()
    addCondiments()
  }
  return F
}

var Coffee = Beverage({
  brew: function() {
    console.log('用沸水冲泡咖啡')
  },
  pourInCup: function() {
    console.log('把咖啡倒进杯子')
  },
  addCondiments: function() {
    console.log('加糖和牛奶')
  }
})

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

模板方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。但在 JavaScript 中,很多时候都不需要依样画瓢地去实现一个模版方法模式,高阶函数是更好的选择。

享元模式

享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象。

内部状态与外部状态

享元模式要求将对象的属性划分为内部状态与外部状态。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,下面的几条经验提供了一些指引:

  • 内部状态存储于对象内部。

  • 内部状态可以被一些对象共享。

  • 内部状态独立于具体的场景,通常不会改变。

  • 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。

这样一来,我们便可以把所有内部状态相同的对象都指定为同一个共享的对象。而外部状态可以从对象身上剥离出来,并储存在外部。剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象。虽然组装外部状态成一个完整对象需要时间,但可以大大减少系统中对象数量,所以说享元模式是一种时间换空间的优化模式。

通常来讲,内部状态有多少种组合,系统中便最多存在多少个对象。使用享元模式的关键是如何区别内部状态和外部状态。可以被对象共享的属性通常被划分为内部状态,而外部状态取决于具体的场景,并根据场景而变化。

这里以文件上传为例,文件上传方式为内部状态,文件名和文件大小为外部状态:

var Upload = function(uploadType) {
  this.uploadType = uploadType
}
Upload.prototype.delFile = function(id) {
  uploadManager.setExternalState(id, this) // 通过管理器设置外部状态
  if (window.confirm('确定要删除该文件吗? ' + this.fileName)) {
    return this.dom.parentNode.removeChild(this.dom)
  }
}

// 上传对象创建工厂,如果内部状态对象已被创建,则直接返回
var UploadFactory = (function() {
  var createdFlyWeightObjs = {}
  return {
    create: function(uploadType) {
      if (createdFlyWeightObjs[uploadType]) {
        return createdFlyWeightObjs[uploadType]
      }
      return (createdFlyWeightObjs[uploadType] = new Upload(uploadType))
    }
  }
})()

// 管理器负责管理外部状态
var uploadManager = (function() {
  var uploadDatabase = {} // 保存所有 uoload 对象外部状态
  return {
    add: function(id, uploadType, fileName, fileSize) {
      var flyWeightObj = UploadFactory.create(uploadType)
      var dom = document.createElement('div')
      dom.innerHTML =
        '<span>文件名称:' +
        fileName +
        ', 文件大小: ' +
        fileSize +
        '</span>' +
        '<button class="delFile">删除</button>'
      dom.querySelector('.delFile').onclick = function() {
        flyWeightObj.delFile(id)
      }
      document.body.appendChild(dom)
      uploadDatabase[id] = { fileName: fileName, fileSize: fileSize, dom: dom }
      return flyWeightObj
    },
    setExternalState: function(id, flyWeightObj) {
      var uploadData = uploadDatabase[id]
      for (var i in uploadData) {
        flyWeightObj[i] = uploadData[i]
      }
    }
  }
})()

var id = 0
window.startUpload = function(uploadType, files) {
  for (var i = 0, file; (file = files[i++]); ) {
    var uploadObj = uploadManager.add(++id, uploadType, file.fileName, file.fileSize)
  }
}

享元模式是一种很好的性能优化方案,但它也会带来一些复杂性的问题,上面代码的比较可以看到,使用了享元模式之后,需要分别多维护一个 factory 对象和一个 manager 对象,在大部分不必要使用享元模式的环境下,这些开销是可以避免的。

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

  • 一个程序中使用了大量的相似对象。

  • 由于使用了大量对象,造成很大的内存开销。

  • 对象的大多数状态都可以变为外部状态。

  • 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。

职责链模式

职责链模式的定义是:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

职责链模式的最大优点:请求发送者只需要知道链中的第一个节点,从而弱化了发送者和一组接收者之间的强联系。

灵活可拆分的职责链节点

这里以购物预付订金为例,根据不同订金获取不同优惠券,如果某个节点不能处理请求,则返回一个特定的字符串 '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('手机库存不足')
  }
}

var Chain = function(fn) {
  this.fn = fn
  this.successor = null
}
// Chain.prototype.setNextSuccessor  指定在链中的下一个节点
Chain.prototype.setNextSuccessor = function(successor) {
  return (this.successor = successor)
}
// Chain.prototype.passRequest  传递请求给某个节点
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(1, false, 0) // 输出:手机库存不足

异步的职责链

上一节的职责链模式中,每个节点函数同步返回一个特定的值 "nextSuccessor" 来表示是否把请求传递给下一个节点。而在现实开发中,经常会遇到一些异步的问题,比如要在节点函数中发起一个 ajax 异步请求,异步请求返回的结果才能决定是否继续在职责链中 passRequest。

这时候让节点函数同步返回 "nextSuccessor" 已经没有意义了,所以要给 Chain 类再增加一个原型方法 Chain.prototype.next,表示手动传递请求给职责链中的下一个节点:

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()

现在得到了一个特殊的链条,请求在链中的节点里传递,但节点有权利决定什么时候把请求交给下一个节点。

职责链模式的优缺点

职责链模式的最大优点就是解耦了请求发送者和 N 个接收者之间的复杂关系,由于不知道链中的哪个节点可以处理你发出的请求,所以你只需把请求传递给第一个节点即可。

其次,使用了职责链模式之后,链中的节点对象可以灵活地拆分重组。增加或者删除一个节点,或者改变节点在链中的位置都是轻而易举的事情。

职责链模式还有一个优点,那就是可以手动指定起始节点,请求并不是非得从链中的第一个节点开始传递。

但这种模式也并非没有弊端,首先我们不能保证某个请求一定会被链中的节点处理。

另外,职责链模式使得程序中多了一些节点对象,可能在某一次的请求传递过程中,大部分节点并没有起到实质性的作用,它们的作用仅仅是让请求传递下去,从性能方面考虑,我们要避免过长的职责链带来的性能损耗。

AOP 实现职责链

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

实现一个 Function.prototype.after 函数,使得第一个函数返回 '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优惠券

中介者模式

面向对象设计鼓励将行为分布到各个对象中,把对象划分成更小的粒度,有助于增强对象的可复用性,但由于这些细粒度对象之间的联系激增,又有可能会反过来降低它们的可复用性。

中介者模式的作用就是解除对象与对象之间的紧耦合关系。增加一个中介者对象后,所有的相关对象都通过中介者对象来通信,而不是互相引用,所以当一个对象发生改变时,只需要通知中介者对象即可。中介者使各对象之间耦合松散,而且可以独立地改变它们之间的交互。中介者模式使网状的多对多关系变成了相对简单的一对多关系。

这里以多人组队游戏为例,设置一个中介者管理状态:

function Player(name, teamColor) {
  this.name = name // 角色名字
  this.teamColor = teamColor // 队伍颜色
  this.state = 'alive' // 玩家生存状态
}
Player.prototype.win = function() {
  console.log(this.name + ' won ')
}
Player.prototype.lose = function() {
  console.log(this.name + ' lost')
}
Player.prototype.die = function() {
  this.state = 'dead'
  playerDirector.ReceiveMessage('playerDead', this) // 给中介者发送消息,玩家死亡
}
Player.prototype.remove = function() {
  playerDirector.ReceiveMessage('removePlayer', this) // 给中介者发送消息,移除一个玩家
}
Player.prototype.changeTeam = function(color) {
  playerDirector.ReceiveMessage('changeTeam', this, color) // 给中介者发送消息,玩家换队
}

// 玩家创建工厂
var playerFactory = function(name, teamColor) {
  var newPlayer = new Player(name, teamColor) // 创造一个新的玩家对象
  playerDirector.ReceiveMessage('addPlayer', newPlayer) // 给中介者发送消息,新增玩家
  return newPlayer
}

// 中介者
var playerDirector = (function() {
  var players = {}, // 保存所有玩家
    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, // 玩家的队伍颜色
      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 // 改变队伍颜色
    operations.addPlayer(player) // 增加到新队伍中
  }
  operations.playerDead = function(player) {
    // 玩家死亡
    var teamColor = player.teamColor,
      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 === true) {
      // 全部死亡
      for (var i = 0, player; (player = teamPlayers[i++]); ) {
        player.lose() // 本队所有玩家lose
      }
      for (var color in players) {
        if (color !== teamColor) {
          var teamPlayers = players[color] // 其他队伍的玩家
          for (var i = 0, player; (player = teamPlayers[i++]); ) {
            player.win() // 其他队伍所有玩家win
          }
        }
      }
    }
  }
  var ReceiveMessage = function() {
    // arguments的第一个参数为消息名称
    var message = Array.prototype.shift.call(arguments)
    operations[message].apply(this, arguments)
  }
  return { ReceiveMessage: ReceiveMessage }
})()

中介者模式是迎合迪米特法则的一种实现。迪米特法则也叫最少知识原则,是指一个对象应该尽可能少地了解另外的对象(类似不和陌生人说话)。如果对象之间的耦合性太高,一个对象发生改变之后,难免会影响到其他的对象,而在中介者模式里,对象之间几乎不知道彼此的存在,它们只能通过中介者对象来互相影响对方。

因此,中介者模式使各个对象之间得以解耦,以中介者和对象之间的一对多关系取代了对象之间的网状多对多关系。各个对象只需关注自身功能的实现,对象之间的交互关系交给了中介者对象来实现和维护。

不过,中介者模式也存在一些缺点。其中,最大的缺点是系统中会新增一个中介者对象,因为对象之间交互的复杂性,转移成了中介者对象的复杂性,使得中介者对象经常是巨大的。中介者对象自身往往就是一个难以维护的对象。

装饰者模式

装饰者模式可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。

在传统的面向对象语言中,给对象添加功能常常使用继承的方式,但是继承的方式并不灵活,还会带来许多问题:一方面会导致超类和子类之间存在强耦合性,当超类改变时,子类也会随之改变;另一方面,继承这种功能复用方式通常被称为“白箱复用”,“白箱”是相对可见性而言的,在继承方式中,超类的内部细节是对子类可见的,继承常常被认为破坏了封装性。

使用继承还会带来另外一个问题,在完成一些功能复用的同时,有可能创建出大量的子类,使子类的数量呈爆炸性增长。

为了解决上述问题,可以给对象动态地增加职责,这种方式称为装饰者(decorator)模式。装饰者模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责。

传统面向对象语言的装饰者模式

作为一门解释执行的语言,给 JavaScript 中的对象动态添加或者改变职责是一件再简单不过的事情,虽然这种做法改动了对象自身,跟传统定义中的装饰者模式并不一样,但这无疑更符合 JavaScript 的语言特色。

var Plane = function() {}
Plane.prototype.fire = function() {
  console.log('发射普通子弹')
}

var MissileDecorator = function(plane) {
  this.plane = plane
}
MissileDecorator.prototype.fire = function() {
  this.plane.fire()
  console.log('发射导弹')
}

var plane = new Plane()
plane = new MissileDecorator(plane)
plane.fire() // 分别输出:发射普通子弹、发射导弹

这种给对象动态增加职责的方式,并没有真正地改动对象自身,而是将对象放入另一个对象之中,这些对象以一条链的方式进行引用,形成一个聚合对象。这些对象都拥有相同的接口(fire 方法),当请求达到链中的某个对象时,这个对象会执行自身的操作,随后把请求转发给链中的下一个对象。

因为装饰者对象和它所装饰的对象拥有一致的接口,所以它们对使用该对象的客户来说是透明的,被装饰的对象也并不需要了解它曾经被装饰过,这种透明性使得我们可以递归地嵌套任意多个装饰者对象。

JavaScript 的装饰者模式

JavaScript 语言动态改变对象相当容易,我们可以直接改写对象或者对象的某个方法,并不需要使用“类”来实现装饰者模式:

var plane = {
  fire: function() {
    console.log('发射普通子弹')
  }
}
var missileDecorator = function() {
  console.log('发射导弹')
}
var fire1 = plane.fire
plane.fire = function() {
  fire1()
  missileDecorator()
}
plane.fire() // 分别输出:发射普通子弹、发射导弹

装饰者也是包装器

在《设计模式》成书之前,GoF 原想把装饰者(decorator)模式称为包装器(wrapper)模式。从功能上而言,decorator 能很好地描述这个模式,但从结构上看,wrapper 的说法更加贴切。装饰者模式将一个对象嵌入另一个对象之中,实际上相当于这个对象被另一个对象包装起来,形成一条包装链。请求随着这条链依次传递到所有的对象,每个对象都有处理这条请求的机会。

装饰函数

在 JavaScript 中,几乎一切都是对象,其中函数又被称为一等对象。在 JavaScript 中可以很方便地给某个对象扩展属性和方法,但却很难在不改动某个函数源代码的情况下,给该函数添加一些额外的功能。

一般可以通过保存原引用的方式就可以改写某个函数:

window.onload = function() {
  alert(1)
}
var _onload = window.onload || function() {}
window.onload = function() {
  _onload()
  alert(2)
}

但是这种方式存在以下两个问题:

  • 必须维护中间变量,如果函数的装饰链较长,或者需要装饰的函数变多,这些中间变量的数量也会越来越多

  • this 容易被劫持的问题,如果把 window.onload 换成 document.getElementById 会报错

通过使用 AOP,可以提供一种完美的方法给函数动态添加功能。

Function.prototype.before = function(beforefn) {
  var __self = this // 保存原函数的引用
  return function() {
    // 返回包含了原函数和新函数的"代理"函数
    beforefn.apply(this, arguments)
    return __self.apply(this, arguments) // 执行原函数并返回原函数的执行结果,并且保证 this 不被劫持
  }
}
Function.prototype.after = function(afterfn) {
  var __self = this
  return function() {
    var ret = __self.apply(this, arguments)
    afterfn.apply(this, arguments)
    return ret
  }
}

Function.prototype.before 接受一个函数当作参数,这个函数即为新添加的函数,它装载了新添加的功能代码。接下来把当前的 this 保存起来,这个 this 指向原函数,然后返回一个“代理”函数,这个“代理”函数只是结构上像代理而已,并不承担代理的职责(比如控制对象的访问等)。它的工作是把请求分别转发给新添加的函数和原函数,且负责保证它们的执行顺序,让新添加的函数在原函数之前执行(前置装饰),这样就实现了动态装饰的效果。通过 Function.prototype.apply 来动态传入正确的 this,保证了函数在被装饰之后,this 不会被劫持。

如此一来便可以给 document.getElementById 添加新功能并不会被劫持 this:

document.getElementById = document.getElementById.before(function() {
  alert(1)
})
var button = document.getElementById('button')

window.onload = function() {
  alert(1)
}
window.onload = (window.onload || function() {})
  .after(function() {
    alert(2)
  })
  .after(function() {
    alert(3)
  })
  .after(function() {
    alert(4)
  })

上面的 AOP 实现是在 Function.prototype 上添加 before 和 after 方法,如果不喜欢这种污染原型的方式,那么可以做一些变通,把原函数和新函数都作为参数传入 be-fore 或者 after 方法:

var before = function(fn, beforefn) {
  return function() {
    beforefn.apply(this, arguments)
    return fn.apply(this, arguments)
  }
}
var a = before(
  function() {
    alert(3)
  },
  function() {
    alert(2)
  }
)
a = before(a, function() {
  alert(1)
})
a()

AOP 应用

用 AOP 装饰函数的技巧在实际开发中非常有用。不论是业务代码的编写,还是在框架层面,都可以把行为依照职责分成粒度更细的函数,随后通过装饰把它们合并到一起,这有助于我们编写一个松耦合和高复用性的系统。

回顾上面 AOP 函数可以发现 beforefn 和原函数 _self 共用一组参数列表 arguments,当在 beforefn 的函数体内改变 arguments 的时候,原函数 _self 接收的参数列表自然也会变化。

var func = function(param) {
  console.log(param) // 输出:{a: "a", b: "b"}
}
func = func.before(function(param) {
  param.b = 'b'
})
func({ a: 'a' })

借此启发,可以使用此特性为网络请求动态设置 token:

var getToken = function() {
  return 'Token'
}
ajax = ajax.before(function(type, url, param) {
  param.Token = getToken()
})
ajax('get', 'http:// xxx.com/userinfo', { name: 'sven' })

用 AOP 的方式给 ajax 函数动态装饰上 Token 参数,保证了 ajax 函数是一个相对纯净的函数,提高了 ajax 函数的可复用性,它在被迁往其他项目的时候,不需要做任何修改。

同理,也可以使用 AOP 在表单上传前做表单验证:

Function.prototype.before = function(beforefn) {
  var __self = this
  return function() {
    // beforefn 返回 false 的情况直接 return,不再执行后面的原函数
    if (beforefn.apply(this, arguments) === false) {
      return
    }
    return __self.apply(this, arguments)
  }
}
var validata = function() {
  if (username.value === '') {
    alert('用户名不能为空')
    return false
  }
  if (password.value === '') {
    alert('密码不能为空')
    return false
  }
}
var formSubmit = function() {
  var param = {
    username: username.value,
    password: password.value
  }
  ajax('http:// xxx.com/login', param)
}
formSubmit = formSubmit.before(validata)
submitBtn.onclick = function() {
  formSubmit()
}

值得注意的是,因为函数通过 Function.prototype.before 或者 Function.prototype.after 被装饰之后,返回的实际上是一个新的函数,如果在原函数上保存了一些属性,那么这些属性会丢失:

var func = function() {
  alert(1)
}
func.a = 'a'
func = func.after(function() {
  alert(2)
})
alert(func.a) // 输出:undefined

另外,这种装饰方式也叠加了函数的作用域,如果装饰的链条过长,性能上也会受到一些影响。

装饰者模式和代理模式

装饰者模式和代理模式的结构看起来非常相像,这两种模式都描述了怎样为对象提供一定程度上的间接引用,它们的实现部分都保留了对另外一个对象的引用,并且向那个对象发送请求。

代理模式和装饰者模式最重要的区别在于它们的意图和设计目的。代理模式的目的是,当直接访问本体不方便或者不符合需要时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情。装饰者模式的作用就是为对象动态加入行为。换句话说,代理模式强调一种关系(Proxy 与它的实体之间的关系),这种关系可以静态的表达,也就是说,这种关系在一开始就可以被确定。而装饰者模式用于一开始不能确定对象的全部功能时。代理模式通常只有一层代理-本体的引用,而装饰者模式经常会形成一条长长的装饰链。

在虚拟代理实现图片预加载的例子中,本体负责设置 img 节点的 src,代理则提供了预加载的功能,这看起来也是“加入行为”的一种方式,但这种加入行为的方式和装饰者模式的偏重点是不一样的。装饰者模式是实实在在的为对象增加新的职责和行为,而代理做的事情还是跟本体一样,最终都是设置 src。但代理可以加入一些“聪明”的功能,比如在图片真正加载好之前,先使用一张占位的 loading 图片反馈给客户。

状态模式

状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变。

通常谈到封装,一般都会优先封装对象的行为,而不是对象的状态。但在状态模式中刚好相反,状态模式的关键是把事物的每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部。同时还可以把状态的切换规则事先分布在状态类中,这样就有效地消除了原本存在的大量条件分支语句。

以电灯开关为例,在不使用状态模式情况下:

Light.prototype.init = function() {
  var button = document.createElement('button'),
    self = this
  button.innerHTML = '开关'
  this.button = document.body.appendChild(button)
  this.button.onclick = function() {
    self.buttonWasPressed()
  }
}

Light.prototype.buttonWasPressed = function() {
  if (this.state === 'off') {
    console.log('弱光')
    this.state = 'weakLight'
  } else if (this.state === 'weakLight') {
    console.log('强光')
    this.state = 'strongLight'
  } else if (this.state === 'strongLight') {
    console.log('关灯')
    this.state = 'off'
  }
}

var light = new Light()
light.init()

使用了状态模式之后:

var OffLightState = function(light) {
  this.light = light
}
OffLightState.prototype.buttonWasPressed = function() {
  console.log('弱光') // offLightState 对应的行为
  this.light.setState(this.light.weakLightState) // 切换状态到 weakLightState
}

var WeakLightState = function(light) {
  this.light = light
}
WeakLightState.prototype.buttonWasPressed = function() {
  console.log('强光') // weakLightState 对应的行为
  this.light.setState(this.light.strongLightState) // 切换状态到 strongLightState
}

var StrongLightState = function(light) {
  this.light = light
}
StrongLightState.prototype.buttonWasPressed = function() {
  console.log('关灯') // strongLightState 对应的行为
  this.light.setState(this.light.offLightState) // 切换状态到 offLightState
}

var Light = function() {
  this.offLightState = new OffLightState(this)
  this.weakLightState = new WeakLightState(this)
  this.strongLightState = new StrongLightState(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.prototype.setState = function(newState) {
  this.currState = newState
}

var light = new Light()
light.init()

通过状态模式,状态之间的切换都被分布在状态类内部,这使得我们无需编写过多的 if、else 条件分支语言来控制状态之间的转换。

状态模式的定义

状态模式定义:允许一个对象在其内部状态改变时改变它的行为。意思是将状态封装成独立的类,并将请求委托给当前的状态对象,当对象的内部状态改变时,会带来不同的行为变化。这个对象看起来是从不同的类中实例化而来的,实际上这是使用了委托的效果。

状态模式的优缺点

状态模式的优点如下:

  • 状态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换。

  • 避免 Context 无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了 Context 中原本过多的条件分支。

  • 用对象代替字符串来记录当前状态,使得状态的切换更加一目了然。

  • Context 中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响。

状态模式的缺点是会在系统中定义许多状态类,而且系统中会因此而增加不少对象。另外由于逻辑分散在状态类中,虽然避开了条件分支语句,但也造成了逻辑分散的问题,无法在一个地方就看出整个状态机的逻辑。

状态模式与策略模式

状态模式与策略模式类似,它们的类图看起来几乎一模一样,但在意图上有很大不同,因此它们是两种迥然不同的模式。策略模式和状态模式的相同点是,它们都有一个上下文、一些策略或者状态类,上下文把请求委托给这些类来执行。

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

Javascript 状态机

前面示例是模拟传统面向对象语言的状态模式实现,为每种状态都定义一个状态子类,然后在 Context 中持有这些状态对象的引用,以便把 currState 设置为当前的状态对象。

状态模式是状态机的实现之一,但在 JavaScript 这种“无类”语言中,没有规定让状态对象一定要从类中创建而来。另外一点,JavaScript 可以非常方便地使用委托技术,并不需要事先让一个对象持有另一个对象。

var Light = function() {
  this.currState = FSM.off // 设置当前状态
  this.button = null
}
Light.prototype.init = function() {
  var button = document.createElement('button'),
    self = this
  button.innerHTML = '已关灯'
  this.button = document.body.appendChild(button)
  this.button.onclick = function() {
    self.currState.buttonWasPressed.call(self) // 把请求委托给 FSM 状态机
  }
}
var FSM = {
  off: {
    buttonWasPressed: function() {
      console.log('关灯')
      this.button.innerHTML = '下一次按我是开灯'
      this.currState = FSM.on
    }
  },
  on: {
    buttonWasPressed: function() {
      console.log('开灯')
      this.button.innerHTML = '下一次按我是关灯'
      this.currState = FSM.off
    }
  }
}
var light = new Light()
light.init()

也可以尝试另一种方法,即利用下面的 delegate 函数来完成这个状态机编写。这是面向对象设计和闭包互换的一个例子,前者把变量保存为对象的属性,而后者把变量封闭在闭包形成的环境中:

var delegate = function(client, delegation) {
  return {
    buttonWasPressed: function() {
      // 将客户的操作委托给delegation对象
      return delegation.buttonWasPressed.apply(client, arguments)
    }
  }
}
var FSM = {
  off: {
    buttonWasPressed: function() {
      console.log('关灯')
      this.button.innerHTML = '下一次按我是开灯'
      this.currState = this.onState
    }
  },
  on: {
    buttonWasPressed: function() {
      console.log('开灯')
      this.button.innerHTML = '下一次按我是关灯'
      this.currState = this.offState
    }
  }
}
var Light = function() {
  this.offState = delegate(this, FSM.off)
  this.onState = delegate(this, FSM.on)
  this.currState = this.offState // 设置初始状态为关闭状态
  this.button = null
}
Light.prototype.init = function() {
  var button = document.createElement('button'),
    self = this
  button.innerHTML = '已关灯'
  this.button = document.body.appendChild(button)
  this.button.onclick = function() {
    self.currState.buttonWasPressed()
  }
}
var light = new Light()
light.init()

适配器模式

适配器模式的作用是解决两个软件实体间的接口不兼容的问题。使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体可以一起工作。

var googleMap = {
  show: function() {
    console.log('开始渲染谷歌地图')
  }
}
var baiduMap = {
  display: function() {
    console.log('开始渲染百度地图')
  }
}
var baiduMapAdapter = {
  show: function() {
    return baiduMap.display()
  }
}
renderMap(googleMap) // 输出:开始渲染谷歌地图
renderMap(baiduMapAdapter) // 输出:开始渲染百度地图

适配器模式是一对相对简单的模式。有一些模式跟适配器模式的结构非常相似,比如装饰者模式、代理模式和外观模式。这几种模式都属于“包装模式”,都是由一个对象来包装另一个对象。区别它们的关键仍然是模式的意图。

  • 适配器模式主要用来解决两个已有接口之间不匹配的问题,它不考虑这些接口是怎样实现的,也不考虑它们将来可能会如何演化。适配器模式不需要改变已有的接口,就能够使它们协同作用。

  • 装饰者模式和代理模式也不会改变原有对象的接口,但装饰者模式的作用是为了给对象增加功能。装饰者模式常常形成一条长的装饰链,而适配器模式通常只包装一次。代理模式是为了控制对对象的访问,通常也只包装一次。

  • 外观模式的作用倒是和适配器比较相似,有人把外观模式看成一组对象的适配器,但外观模式最显著的特点是定义了一个新的接口。

最后更新于