JavaScript设计模式

设计模式

一、单例模式

definition保证一个类仅有一个实例,并提供一个访问它的全局访问点。

普通单例模式

var Singleton = function(name) {
    this.name = name
}
Singleton.instance = null
Singleton.prototype.getName = function() {
    return this.name
}
Singleton.getInstance = function(name) {
    if (Singleton.instance) return this.instance
    return Singleton.instance = new Singleton(name)
}

var a = Singleton.getInstance('ev')
var b = Singleton.getInstance('av')

缺点

  • 缺少透明性
  • 调用方式不易理解

透明的单例模式

const CreateSometing = (function() {
    var instance;

    var Creater = function() {
        if (instance) return instance

        this.init()
        return instance = this
    }

    Creater.prototype.init = function() {
        // do something
    }
    return Creater
})();

const c = new CreateSometing()
const d = new CreateSometing()
console.log( c === d) // true

缺点:增加了一些程序的复杂度,不利于阅读。

使用代理实现单例模式

const Create = function() {
    this.init()
}
Create.prototype.init = function(){}
// 代理
const ProxySingleton = (function () {
    var instance;
    return function() {
        if (instance) return instance

        return instance = new Create()
    }
})()

const e = new ProxySingleton()
const f = new ProxySingleton()
console.log(e === f)

同样完成了一个单例模式的编写,把负责管理单例的逻辑移到了代理proxySingleton中,这一依赖 Create 就变成了一个普通的类,与 代理组合起来就可以达到单例模式的效果


JavaScript是一门无类(class-free)的语言,生搬单例模式的概念并无意义。单例模式的核心是确保只有一个实例,并提供全局访问。

  • 使用命名空间

动态的创建命名空间

let MyApp = {}
MyApp.namespace = function(name) {
    const parts = name.split('.')
    let current = MyApp 
    for(let i in parts) {
        if (!current[ parts[i] ]) current[ parts[i] ] = {}
        current = current[parts[i]] // 替换&嵌套
    }
}

MyApp.namespace('event')
MyApp.namespace('dom.style')
/*
MyApp: {
  event: {}
  dom: {
    style: {}
  }
}
*/
  • 闭包封装私有变量

把一些私有变量封装在闭包的内部,只暴露一些接口来跟外界通信

const user = {
  let __name = 'sven'
  let __age = 18
  return {
    getUserInfo() {
        return __name + __age
    }
  }
}

  • 惰性单例

惰性单例指的是在需要的时候才创建对象实例。惰性单例是 单例模式的重点

const getSingle = function( fn ) {
  let result;
  return function() {
    return result || (result = fn.apply(this, arguments))
  }
}

const bindEvent = getSingle(function() {
  document.getElementsByTagName('div')[0].addEventListener = function() {
    console.log('click')
  }
})

const render = function() {
  console.log(`render`)
  bindEvent()
}
render()
render()
render()

render 和 bindEvent 方法分别调用了三次,但是 时间绑定只执行了依次

二、策略模式

策略模式的定义:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。将不变的部分和变化的部分隔开是每个设计模式的主题。策略模式也可以用来封装一系列的“业务规则”。

以表单验证的需求为例

常见的编码方式:

;(function() {
  var registerForm = document.getElementById('form')

  registerForm.onsubmit = function() {
    if (registerForm.userName.value === '' ) {
      alert('用户名不能为空')
      return false
    }
    if (registerForm.password.value.length < 8 ) {
      alert('密码长度不能低于8位')
      return false
    }
    if (!/^1[3|5|8][0-9]{9}$/.test( registerForm.phoneNumber.value) {
      alert('手机号码有误')
      return false
    }
  }
})()

显见的缺点:

  • registerForm.onsubmit 函数庞大,包含过多的 if-else 语句,这些语句需要覆盖所有的效验规则
  • registerForm.onsubmit 函数缺乏弹性,如果增加了一种新的校验规则,或者想修改之前的校验规则,必修修改 函数内部,这违反开放-封闭原则
  • 复用性差

使用策略模式重构表单

  const strategies = {
    isNotEmpty: ( value, errorMsg ) => {
      if (value === '' ) return errorMsg
    },
    minLenght: (value, length, errorMsg) => {
      if (value.length < length) return errorMsg
    },
    isMobile: (value, errorMsg) => {
      if (!/^1[3|5|8][0-9]{9}$/.test(value)) return errorMsg
    }
  }

  // Validator
  const Validator = function() {
    this.cache = []
  }
  Validator.prototype.add = function(val, rule, errorMsg) {
    const args = rule.split(':')
    this.cache.push(function() {
      const strategy = args.shift() // 获取验证方式
      args.unShift(val) // args: val, ...rule
      args.push(errorMsg) // args 对应接受参数
      return strategies[ strategy ].apply(null, args)
    })
  }
  Validator.prototype.start = function() {
    for ( let i = 0, validatorFunc; validataFunc = this.cache[i++]; ) {
      const msg = validataFunc() // 效验
      if (msg) return msg
    }
  }

  const validataFunc = function() {

    const validator = new Validator()

    validator.add( registerForm.userName.value, 'isNotEmpty', '用户名不能为空')
    validator.add( registerForm.password.value, 'minLength:6', '密码长度不能少于6位')
    validator.add ( registerForm.phoneNumber.value, 'isMobile', '手机号码格式不正确')

    const errorMsg = validator.start() // 获取效验结果
    return errorMsg
  }


  // 使用
  const registerForm = document.getElementById('registerForm')
  registerForm.onsubmit = () => {
    const errorMsg = validataFunc()
    if (errorMsg) {
      alert(errorMsg)
      return false // 阻止提交
    }
  }

添加多个效验规则

  // strategies ...

  const Validator = function() {
    this.cache = []
  }

  Validator.prototype.add = function( dom, rules ) {
    const _self = this

    for (let i = 0, rule; rule = rules[i++]; ) {
      const strategyAry = rule.strategy.split(':')
      const errorMsg = rule.errorMsg

      this.cache.push(function() {
        const strategy = strategyAry.shift()
        strategyAry.unShift(dom.value)
        strategyAry.push(errorMsg)
        return strategies [ strategy ].apply(dom, strategyAry)
      })
    }
  }
  // Validator.prototype.start = function() {} ...


  // 调用
  const validataFunc = function() {
    const validator = new Validator()

    validator.add( registerForm.userName, [{
      strategy: 'isNotEmpty',
      errorMsg: '用户名不能为空'
    }, {
      strategy: 'minLength:8',
      errorMsg: '用户名不能小于8位'
    }])

    return validator.start()
  }

  registerForm.onsubmit = function() {
    const errorMsg = validataFunc()
    if (errorMsg) {
      alert(errorMsg)
      return false
    }
  }

策略模式的优缺点

  • 优点:
    • 策略模式利用组合,委托和多态等技术思想,可以有效避免多重条件选择语句;
    • 策略模式提供了对开放-封闭原则的完美支持,将算法封装在独立的strategy中,将使它们易于切换,易于理解,易于扩展
    • 策略模式中的算法也可以复用在系统的其他地方,从而避免许多重复的复制粘贴工作。
    • 在策略模式中利用组合和委托来让 context 拥有执行算法的能力,这也是继承的一种更轻便的替代方案。
  • 缺点:
    • 使用策略模式会在程序中增加许多策略类或策略对象(但实际上这比把它们负责的逻辑堆砌在 Context 中要好)
    • 使用策略模式,必须了解所有的 strategy,了解他们之间的不同点,这样才能选择一个合适的 strategy。此时strategy 要向客户暴露它的所有实现,这是违反最少知识原则的。

Peter Norving 在他的演讲中曾经所过:“在函数作为一等对徐的语言中,策略模式是隐形的”

在Javascript语言的策略模式中,策略类往往被函数所代替,这是策略模式就成为一种“隐形”的模式。

三、代理模式

代理模式:为一个对象提供一个代用品或占位符,以便控制对它的访问

例如有一个小明送花的例子:

;(function() {
  const Flower = function() {}

  const xiaoMing = {
    sendFlower(target) {
      var flower = new Flower()
      target.receiveFlower(flower)
    }
  }

  const A = {
    receiveFlower(flower) {
      console.log(`收到花 ${flower}`)
    }
  }

  xiaoMing.sendFlower(A)
})()

添加代理:

  const Flower = function() {}

  const xiaoMing = {
    sendFlower(targte) {
      const flower = new Flower()
      target.receiveFlower(flower)
    }
  }

  const B = {
    receiveFlower(flower) {
      A.receiveFlower(flower)
    }
  }

  const A = {
    receiveFlower( flower ) {
      console.log(`收到花 + ${flower}`)
    }
  }

  xiaoMing.sendFlower(B)

此时B则为一个代理对象,负责帮助小明将花转交给A。但此除的代理模式并无什么作用。

现在又这么一个场景,A在开心时收到花更好,B可以监听A的心情变化,在A心情好的时候再把花转交给小明。

  const Flower = function() {}

  const xiaoMing = {
    sendFlower(targte) {
      const flower = new Flower()
      target.receiveFlower(flower)
    }
  }

  const B = {
    receiveFlower(flower) {
      A.listenGoodMood(function() { // 心情好
        A.receiveFlower(flower)
      })
    }
  }

  const A = {
    receiveFlower( flower ) {
      console.log(`收到花 + ${flower}`)
    },
    listenGoodMood(fn) { // 10s 后心情变好
      setTimeout(fn, 10000)
    }
  }

  xiaoMing.sendFlower(B)
  • 保护代理代理A可以帮助代理B过滤掉一些请求,这种代理叫作保护代理。
  • 虚拟代理假设 new Flower() 时一个代价昂贵的操作,代理B会选择在A心情好的时候再执行 new Flower,这是代理模式的另一种请求,叫作虚拟代理。虚拟代理把一些开销很大的对象,延迟到真正需要它的时候采取创建。

虚拟代理实习图片预加载

图片加载是一种常用技术,直接给某个 img 标签节点设置 src 属性,由于图片过大或者网络不佳,图片的位置往往有段时间会是一片空白。常见的做法是先用一张 loading图片占位,然后用以部的方式加载图片,等图片加载好了之后把他填充到 img 节点中去,这种场景就很适合虚拟代理。

const myImage = (function() {
  const imgNode = document.createElement('img')
  document.body.appendChild(imgNode)

  return {
    setSrc(src) {
      imgNode.src = src
    }
  }
})()

const proxyImage = (function() {
  const img = new Image;
  img.onload = function() {
    myImage.setSrc(this.src)
  }
  return {
    setSrc(src) {
      myImage.setSrc('file://....') // loading 图片
      img.src = src
    }
  }
})()

proxyImage.setSrc('https://...') // 真实图片地址

代理的意义

面向对象的设计原则——单一职责原则

单一职责原则指的是,就一个类(通常也包括对象和函数等)而言,应该仅有一个引起它变化的原因。如果一个对象承担了多项职责,就意味着这个对象将变得巨大,引起它变化的原因可能会有多个。面向对象设计鼓励将进行分布到细粒度的对象之中,如果一个对象承担的职责过多,等于把这些职责耦合到了一起,这种耦合会导致脆弱和低内聚的设计。

纵观整个程序,我们并没有改变或者增加 MyImage的接口,但是通过代理对象,实际上给系统添加了新的行为。

代理和本体接口的一致性

关键在于代理对象和本体都对外提供了 相同的方法,在客户看来,代理对象和本体是一致的,代理接手请求的过程对用户来说是透明的,用户并不清楚代理和本体的区别,这样做有两个好处

  • 用户可以放心地请求代理,他只关心是否能得到想要的结果
  • 在任何十有本体的地方都可以替换成使用代理

在 Java 等语言中,代理和本体都需要显式的实现同一个接口

虚拟代理合并 HTTP 请求

在 web 开发中,也许最大的开销就是网络请求,假设我们在做一个文件同步的功能,当我们选中一个 checkbox 的时候,它对应的文件就会被同步到另外一台备用服务器上面。但是用法触发 选择的操作是频繁的,一秒钟点击四次则会发送四次请求,我们可以通过一个代理函数,来手机一段时间之内的请求最后一次性发送给服务器。

const proxySynchronousFile = (function() {
   const cache = [];
   let timer;

   return function( id ) {
     cache.push(id)

     if (timer) return // 等待期间

     timer = setTimeout(() => {
       synchronousFile(cache.join(',')) // 需要请求的id集合
       clearTimeout(timer)
       timer = null
       cache.length = 0; // 清空集合ID
     }, 2000)
   }
 })()

缓存代理

缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果。

其他代理模式

代理模式的变化种类非常多

  • 防火墙代理:控制网络资源的访问,保护主机不让“坏人”接近
  • 远程代理:为一个对象在不同的地址空间提供局部代表,在 Java 中,远程代理可以是另一个虚拟机中的对象。
  • 保护代理:用于对象应该有不同访问权限的情况
  • 智能引用代理:取代了简单的指针,他在访问对象时执行一些附加操作,比如计算一个对象被引用的次数。
  • 写时复制对象:通常用于复制一个庞大对象的情况,写时复制代理延迟了复制的过程,当对象被真正修改时。才对他进行复制操作,写时复制代理时虚拟代理的一种变体。

四、迭代器模式

迭代器模式:指提供一种方法顺序访问一个聚合对象中的哥哥元素,而又不需要暴露该对象的内部表示。迭代器模式可以把得开的过程从业务逻辑中分离出来,使用迭代器模式之后,即不关心对象的内部构造,也可以按照顺序访问其中的每个元素。

迭代器模式无非就是访问聚合对象中的各个元素:

// jQuery 中的迭代器模式
$.each([1, 2, 3], function(i, n) {
 console.log('当前下标为:' + i)
 console.log('当前的值为:' + n)
})

迭代器可以分为内部迭代器和外部迭代器

  • 内部迭代器上面的的 each 函数属于内部迭代器,内部迭代器在内部已经定义好了迭代规则,使用的时候非常方便,外界不用关心迭代器内部的实现,跟迭代器的交互也仅仅是一次初始调用。也这是缺点所在,不够灵活
  • 外部迭代器外部迭代器必须显示的请求迭代下一个元素。外部迭代器增加了一些调用的复杂度,但相对也增加了迭代器的灵活性,我们可以手工控制迭代的过程或顺序

外部迭代器

const Iterator = function(obj) {
  let current = 0;

  const next = function() {
    current += 1
  }

  const isDone = function() {
    return current >= obj.length
  }

  const getCurrentItem = function() {
    return obj[ current ]
  }

  return {
    next,
    isDone,
    getCurrentItem,
    length: obj.length
  }
}

// 比较两个数组的元素是否相等
const compare = function(iterator1, iterator2) {
  if (iterator1.length !== iterator2.length) return false

  whilte(!iterator1.isDone() && !iterator2.isDone()) {
    if (iterator1.getCurrentItem() !== iterator2.getCurrentItem()) return false

    // 迭代
    iterator1.next()
    iterator2.next()  
  }

  // 相等
  return true
}

迭代类数组对象和字面量对象:无论是内部迭代器还是外部迭代器,只要迭代的聚合对象拥有length 属性而且可以用下标访问,那它就可以被迭代。

// jQuery 中的each
$.each = function(obj, callback) {

  let value, i = 0, length = obj.length, isArray = isArrayLike(obj)

  if (isArray) { // 迭代数组
    for(; i < length; i++) {
      value = callback.call(obj[i], i, obj[i]) // callback(i, item)

      if (value === false) break; // return false, 终止迭代
    }
  } else { // 迭代 object 对象
    for (i in obj) {
      value = callback.call(obj[i], i, obj[i])
      if (value === false) break;
    }

  }
}

倒叙迭代器

// 倒叙迭代器
const reverseEach = function(ary, callback) {
  for (let i = ary.length - 1; i >= 0; i--) {
    callback(i, ary[i])
  }
}

终止迭代器:即可以提供一种跳出循环的方法,例如上面 $.each 中的 break

迭代器模式的应用:在实际开发环境中,经常会遇到能力测试的场景,如何方案较多可能会嵌套多个 if 分支语句。如何后续有新的方案,则需要在原先代码中添加新的分支。

以文件上传为例:

// 拆分各种方案
const getActivieUploadObj = function() {}
const getFlashUploadObj = function() {}
const getFormUploadObj = function() {}
const getWebkitUploadObj = function() {}
const getH5UploadObj = function() {}

// 迭代器
const iterator = function() {
    for (let i = 0, fn; fn = arguments[i++]; ) {
        const uploadObj = fn()
        // 能力测试
        if (uploadObj !== false) return uploadObj
    }
}

// 使用
const realUploadObj = iteratorUploadObj(
    getActivieUploadObj, 
    getFlashUploadObj,
    getFormUploadObj,
    getWebkitUploadObj,
    getH5UploadObj
)

迭代器模式是一种相对简单的模式,简单到很多时候我们都不认为它是一种设计模式。大部分语言都内置有迭代器模式。

五、发布-订阅模式

发布订阅模式: 又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所依赖于它的对象都将得到通知,在 JavaScript 开发中,我们一般用事件模型来代替传统的发布-订阅模式。

  • 发布订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。
  • 可以取代对象之间硬编码的通知机制,发布订阅让连个对象松耦合的联系在一起,虽然不太清除彼此的细节,但这不影响他们之间相互通信。

如何实现一个发布-订阅模式

  • 指定好谁充当发布者

  • 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者

  • 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数。

    我们还可以往回调函数里填入一些参数,订阅者可以接受这些参数。

const salesOffices = {
    clientList: [], // 队列
    listen(fn) { // 订阅
      this.clientList.push(fn)
    },
    trigger() { // 发布
      for (let i = 0, fn; fn = this.clientList[i++]; ) {
        fn.apply(this, arguments)
      }
    }
  } 

订阅指定消息,添加 key 生成对应缓存队列

const modal = {
    clientList: {}, // 缓存列表
    listen(key) { // 订阅
      if (!this.clientList[key]) this.clientList[key] = [] // 指定消息队列列表

      this.clientList[key].push(fn) // 添加到队列  
    },
    trigger = function() {
      const key = Array.prototype.shift.call(arguments) // 获取第一个参数,key
      const fns = this.clientList[key] // 获取对应的消息列表

      if (!fns || fns.length === 0) return false // 未订阅消息

      // 发布
      for (let i = 0, fn; fn = fns[i++]; ) {
        fn.apply(this, arguments)
      }
    }
  }

应用消息队列的场景很多,可以将功能提取出来,达到让每个对象都拥有发布订阅模式

const installEvent = function(obj) {
    for (let i in modal) {
        obj[i] = event[i]
    }
}

const ob = {}
installEvent(ob)

取消订阅

  const event = {
    // ...
    remove(key, fn) {
      const fns = this.clientList[key]

      if (!fns || fns.length === 0) return false;

      if (!fn)  return fns && (fns.length = 0) // 位传入具体的回调函数,表示需要取消key对应消息的所有订阅

      // 对消对应的 fn
      for(let i = 0, _fn; _fn = fns[i++]; ) {
        if (_fn === fn) fns.splice(i, 1) // 删除对应订阅事件
      }  
    }
  }

在程序中,发布订阅模式可以用一个全局的Event对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event作为一个类似“中介者” 的角色,把订阅者和发布者联系起来。

// 全局的发布订阅对象
const Event = (function() {
  const clientList = []

  const listen = function(key, fn) {
    if (!clientList[key]) clientList[key] = [];

    clientList[key].push(fn)
  }

  const trigger = function() {
    const key = Array.prototype.shift.call(arguments) // 获取 key
    const fns = clientList[key]
    if (!fns || fns.length === 0) return false;
    for ( let i = 0, fn; fn = fns[i++]; ) {
      fn.apply(this, arguments)
    }
  }

  const remove = function(key, fn) {
    const fns = clientList[ key ]
    if (!fns) return false;

    if (!fn) fns && (fns.length === 0);

    for (let i = 0, _fn; _fn = fns[i++]; ) {
      if (_fn === fn) fns.splice(i, 1)
    }
  }

  return {
    listen,
    trigger,
    remove
  }
})()

Event.listen('squareMeter100', function() {})
Event.trigger('squareMeter100', 100, 'address')

必须先发布再订阅吗?

在某些情况下,我们需要先将这条消息保存下来,等到有对象来订阅它的时候,再重新把消息发布给订阅者。如同QQ中的离线消息一样,离线消息被保存在服务器中,接受下次登录上线之后,可以重新接受这条信息

为此需要建立一个存放离线事件的堆栈,把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈,离线事件的生命周期只应有一次。

全局事件的命名冲突

全局的发布-订阅对对象只有一个 clientList 来存放消息和回调函数,难免会出现事件名冲突的情况,可以给 Event 提供创建命名空间的功能

// 订阅
Event.create('namespace1').listen('click', () => {})

// 发布
Event.create('namespace1').trigger('click', 1)

// 参考 P121.

发布订阅模式的优点非常明显 :

  • 时间上的解耦
  • 对象之间的解耦

缺点:

  • 创建订阅者本身要消耗一定的时间和内存,当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。
  • 过渡使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。

六、命令模式

命令模式是最简单和最优雅的模式之一,命令模式中的命令(command) 指的是一个执行某些特定事情的指令。

命令模式最常见的场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接受者能够消除彼此之间的耦合关系。

设计模式的主题总是把不变的事物和变化的事物分离开来

// JavaScript中的命令模式
const bindClick = function(button, func) {
  button.onclick = func
}

const MenuBar = {
  refersh: function() {
    console.log('刷新菜单界面')
  }
}

const SubMenu = {
  add: function() {
    console.log('增加子菜单')
  },
  del: function() {
    console.log('删除子菜单')
  }
}

bindClick(button1, MenuBar.refersh)
bindClick(button2, SubMenu.add)
bindClick(button3, SubMenu.del)

模拟传统面向对象语言命令模式实现。命令模式将过程式的请求调用封装在 command 对象的 execute 方法里,通过封装方法调用,我们可以把运算包装成形。在调用命令的时候,客户(client) 不需要关心事情是如何进行的。

命令模式的由来,其实是回调(callback)函数的一个面向对象的替代品

在使用闭包的命令模式实现中,接受者被封闭在闭包产生的环境中,执行命令的操作可以更加简单,仅仅执行回调函数即可。

const setCommand = function(button, func) {
  button.onclick = function() {
    func()
  }
}

const MenuBar = {
  refresh: function() {
    console.log('刷新菜单界面')
  }
}

const RefreshMenuBarCommand = function(receiver) { // 闭包
  return function() {
    receiver.refersh()
  }
}

const refreshMenuBarCommand = RefreshMenuBarCommand( MenuBar )

setCommand(button1, refreshMenuBarCommand)

撤销命令:命令模式的作用不仅是封装运算块,而且可以很方便地给命令对象增加撤销操作。

撤销操作的实现一般是给命令对象增加一个名为 unexecude 或者 undo 的方法,在该方法里执行 execute 的反向操作。在 command.execute 方法执行之前做对应的处理,在unexecude 或者 undo 操作中回复命令的操作.

  const ball = document.getElementById('ball')
  const pos = document.getElementById('pos')
  const moveBtn = document.getElementById('moveBtn')
  const cacelBtn = document.getElementById('cancelBtn')

  const MoveCommand = function(receiver, pos) {
    this.receiver = receiver
    this.pos = pos
    this.oldPos = null
  }

  MoveCommand.prototype.execute = function() {
    this.receiver.start('left', this.pos, 1000, 'strongEaseOut')
    // 记录小球开始移动前的位置
    this.oldPos = this.receiver.dom.getBoundingClientRect()[this.receiver.propertyName]
  }

  MoveCommand.prototype.undo = function() {
    // 回到小球移动前的位置
    this.receiver.start('left', this.oldPos, 1000, 'strongEaseOut')
  }

  let moveCommand = null;
  moveBtn.onclick = function() {
    const animate = new Animate( ball )
    moveCommand = new MoveCommand( animate, pos.value)
    // 移动
    moveCommand.execute()
  }

  canceBtn.onclick = function() {
    moveCommand && moveCommand.undo()
  }

撤销和重做:可以把所有执行过的命令储存在一个历史列表中,然后倒叙循环来依次执行这些命令的 undo 操作,直到循环执行到第几个命令位置

录像功能:把用户在键盘的输入都封装成命令,执行过的命令将被存放到堆栈中。播放录像的时候只需要从头开开始依次执行这些命令便可。

播放录像

  const command = {
    attack() {
      console.log('攻击')
    },
    defense() {
      console.log('防御')
    },
    jump() {
      console.log('跳跃')
    },
    crouch() {
      console.log('蹲下')
    }
  }

  const makeCommand = function(receiver, state) {
    return function() {
      receiver[state]()
    }
  }

  const keyboardCommands = {
    '119': 'jump', // w
    '115': 'crouch', // s
    '97': 'defense', // a
    '100': 'attack', // d
  }

  const commandStack = []

  document.onkeypress = function(ev) {
    const keyCode = ev.keyCode
    const command = makeCommand(Ryu, keyboardCommands[keyCode])

    if (command) {
      // 执行命令
      command()
      // 保存历史
      commandStack.push(command)
    }
  }

  // 播放录像
  document.getElementById('reply').onclick = function() {
    let command;
    while(command = commandStack.shift()) {
      command()
    }
  }

命令队列

命令过多的情况下,让命令进行排队处理。我们可以把 div 的这些运动过程都封装成命令对象,再把它们压进一个队列堆栈,当动画执行完,也就是当前 command 对象的职责完成之后,会主动通知队列,此时取出正在队列中等待的第一个命令对象,并且执行它

一个动画完成后如何通知队列通常可以使用回调函数来通知队列,除了回调函数之外,还可以选择发布-订阅模式。即在一个动画结束后发布一个消息,订阅者接收这个消息之后,便开始执行队列里的下一个动画。

宏命令

宏命令是一组命令的集合,通过执行宏命令的方式,可以一次执行一批命令。

  const closeDoorCommand = {
    execute: function() {
      console.log('关门')
    }
  }

  const openPcCommand = {
    execute: function() {
      console.log('开电脑')
    }
  }

  const openQQCommand = {
    execute: function() {
      console.log('登录QQ')
    }
  }

  // 宏命令
  const MacroCommand = function() {
    return {
      commandList: [],
      add(command) {
        this.commandList.push(command)
      },
      execute() {
        for(let i = 0, command; command = this.commandList[i++]; ) {
          // 执行命令
          command.execute()
        }
      }

    }
  }

  const macroCommand = MacroCommand()
  macroCommand.add( closeDoorCommand )
  macroCommand.add( openPcCommand )
  // 执行宏命令
  macroCommand.execute()

傻瓜命令与智能命令:命令模式都会在 command 对象中保存一个接受者来负责真正执行客户端的请求,这种情况下命令对象是 “傻瓜式”的,它只负责把客户端的请求转交给接受者来执行,这种模式的好处是请求者和发起者之间尽可能得到了解耦

也可以定义一些“聪明”的命令对象,“聪明”的命令对象可以直接实现请求,这样一来就不再需要接受者的存在,这种“聪明”的命令对象也叫智能命令。没有接受者的只能命令,退化到和策略模式非常相近。智能命令模式指向的问题域更广,command对象解决的目标更具发散性。命令模式还可以完成撤销,排队等功能。

七、组合模式

组合模式就是用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的“孙对象”构成的

组合模式将对象组合成树形结构,以表示“整体——部分”的层次结构。通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性。

  • 表示树形结构:提供了一种遍历树形结构的方案,通过调用组合对象的 execute 方法,程序会递归调用组合对象下面的叶对象的 execute 方法。组合模式可以非常方便地表述对象部分-整体层次结构。
  • 利用对象多态性统一对待组合对象和单个对象。

更强大的宏命令

  const MacroCommand = function() {
    return {
      commandList: [],
      add(command) {
        this.commandList.push( command )
      },
      execute() {
        for(let i = 0, command; command = this.commandList[i++]; ) {
          command.execute()
        }
      }
    }
  }

  const openAcCommand = {
    execute() {
      console.log('打开空调')
    }
  }

  /* 电视和音响连接在一起, 所以可以用一个宏命令来组合打开电视和打开音响的命令 */
  const openTvCommand = {
    execute() {
      console.log('打开电视')
    }
  }

  const openSoundCommand = {
    execute() {
      console.log('打开音响')
    }
  }

  const macroCommand1 = MacroCommand()
  macroCommand1.add(openTvCommand)
  macroCommand1.add(openSoundCommand)

  /* 开门、打开电脑、登录QQ */
  const closeDoorCommand = {
    execute() {
      console.log('关门')
    }
  }

  const openPcCommand = {
    execute() {
      console.log('打开电视')
    }
  }

  const openQQCommand = {
    execute() {
      console.log('打开qq')
    }
  }

  const macroCommand2 = MacroCommand()
  macroCommand2.add(closeDoorCommand)
  macroCommand2.add(openPcCommand)
  macroCommand2.add(openQQCommand)

  /* 组合所以的命令 */
  const macroCommand = MacroCommand()
  macroCommand.add(openAcCommand)
  macroCommand.add(macroCommand1)
  macroCommand.add(macroCommand2)

  // 执行命令
  macroCommand.execute()

基本对象可以被组合成更复杂的组合对象,组合对象又可以被组合这样不断递归下去,这棵树的结构可以支持任意多的复杂度。组合模式最大的优点在于可以一致地对待组合对象和基本对象。

JavaScript 中实现组合模式的难点在于要保证组合对象和叶对象拥有同样的方法,这通常需要用 鸭子类型的思想对他们进行接口检查。

在JavaScript中实现组合模式,看起来缺乏一些严谨性,我们的代码算不上安全,但能更快速和自由地开发,这既是 JavaScript 的缺点,也是它的优点。

主动抛出异常

// ...
const openTvCommand = {
    execute() {
        console.log('打开电视')
    },
    add() {
        throw new Error('叶对象不能添加子节点')
    }
}
// ...
openTvCommand.add(commadn) // Uncaught error

扫描文件夹

  /************** Folder ***************/
  const Folder = function(name) {
    this.name = name;
    this.files = []
  }

  Folder.prototype.add = function(file) {
    this.files.push(file)
  }

  Folder.prototype.scan = function() {
    console.log('开始扫描文件夹:' + this.name)
    for(let i = 0, file; file = this.files[i++]; ) {
      file.scan() // 叶对象
    }
  }

  /************************** File **********************/
  const File = function(name) {
    this.name = name;
  }
  File.prototype.add = function() {
    throw new Error('文件下面不能再添加文件')
  }
  File.prototype.scan = function() {
    console.log('开始扫描文件:' + this.name)
  }

  const folder = new Folder('学习资料')
  const folder1 = new Folder('JavaScript')
  const folder2 = new Folder('jQuery')

  const file1 = new File('JavaScript 设计模式与开发实践')
  const file2 = new File('精通 jQuery')
  const file3 = new File('重构与模式')

  folder1.add(file1)
  folder2.add(file2)

  folder.add(folder1)
  folder.add(folder2)
  folder.add(file3)
  folder.scan()

可以很方便的改变数结构,增肌新数据,却不用谢盖任何一句原有代码,这是符合开放-封闭原则的。

tips:

  • 组合模式不是父子关系:组合模式是一种 HAS-A(聚合) 关系,而不是 IS-A,组合对象把请求委托给它所包含的所有叶对象,它们能够合作的关键是拥有相同的接口
  • 对叶对象操作的一致性:组合模式除了要求组合对象和也对象拥有相同的接口之外,还有一个必要条件,就是对一组叶对象的操作必须具有一致性,只有用一直的方式对待列表中的每个叶对象的时候,才适合使用组合模式。
  • 双向映射关系:对象之间的关系并不是严格意义上的层此结构,在这种情况下,是不适合使用组合模式的。此时必须给父节点和子节点建立双向映射关系,但是这种相互间的引用相当复杂,而且对象之间产生了过多的耦合性,此时可以引入中介者模式来管理这些对象。
  • 用责任链模式提高组合模式性能:在组合模式中,如果树的结构比较复杂,节点数量很多在遍历的过程中,性能方面也许表现得不够理想。此时可以借助责任链模式,手动设置链条。组合模式中,父对象和子对象之间实际上形成了天然的责任链。

引用父对象

// ...
const Folder = function() {
    this.parent = null // 增加 this.parent 属性
}
Folder.prototype.add = function(file) {
    file.parent = this // 设置父对象
    this.files.push(file)
}
// 移除子节点
Folder.prototype.remove = function() {
    if (!this.parent) return; // 根节点或者树外节点
    
    // 遍历父节点 this.parent.files, 移除当前
    for (let files = this.parent.files, i = files.length-1; i >= 0; i--) {
        const file = files[i]
        if (file === this) files.splice(i, 1)
    }
}

// ...
const File = function(name) {
    this.parent = null
}
File.prototype.remove = function() {
    if (!this.parent) return;
    
    for(let files = this.parent.files, i = files.length - 1; i >= 0; i--) {
        const file = files[i]
        if (file === this) files.splice(i, 1)
    }
}

当我们删除某个文件的时候,实际上是从这个文件所在的上层文件中删除该文件。

何时使用组合模式:一般来说组合模式适用以下两种情况

  • 表示对象的部分-整体层次结构。
  • 希望统一对待树中的所有对象。

组合模式并不是完美的,他可能会产生一个这样的系统:系统中的每个对象看起来都和其他对象差不多,他们的区别只有在运行的时候会呈现出来,这会使代码难以理解,此外如果通过组合模式创建了太多的对象,那么这些对象可能会让系统负担不起。

八、模板方法模式

在 JavaScript 开发中用到继承的场景其实并不是很多,可以通过原型 prototype 来变相地实现继承。

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

模板方法模式由两部分结构组成

  • 抽象父类
  • 实现之类

通常在抽象父类中封装了之类的算法框架,包含实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承整个算法结构,并且可以选择重写父类的方法。

相同的行为可以被搬移到另一个单一的地方,模板方法模式就是为解决这个问题而生的,在模板方法模式中。之类实现中的相同部分被上移到父类中。而将不同的部分留待之类来实现,这也很好的体现了泛化的思想

​ 泡茶与冲泡咖啡

分离共同点

  • 原料不同
  • 方式不同
  • 加入的“调料不同”
  // 抽象父类
  const 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()
  } 


  /* 实现子类 */

  // 咖啡
  const Coffee = function() {}
  // 继承
  Coffee.prototype = new Beverage()
  // 重写抽象方法
  Coffee.prototype.brew = function() {
    console.log('用沸水冲泡咖啡')
  } 
  Coffee.prototype.pourInCup = function() {
    console.log('把咖啡倒进杯中')
  }
  Coffee.prototype.addCondiments = function() {
    console.log('加糖&牛奶')
  }

  // 茶
  const Tea = function() {}
  Tea.prototype = new Beverage()
  Tea.prototype.brew = function() {
    console.log('用沸水冲泡茶叶')
  }
  Tea.prototype.pourInCup = function() {
    console.log('把茶倒进杯子')
  }
  Tea.prototype.addCondiments = function() {
    console.log('加柠檬')
  }


  const coffee = new Coffee()
  const tea = new Tea()
  tea.init()
  coffee.init()

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

抽象类

模板方法模式是一种严重依赖抽象类的设计模式。JavaScript 在语言层面并没有提供抽象类的支持,我们也很难模拟抽象类的实现。在Java编译器中会保证之类会重写父类中的抽象方法,但在JavaScript中却没有进行这些检查工作

两种变通的方法:

  • 使用鸭子类型来模拟接口检查
  • 抛出异常

相对来说第二种方案的优点是实现简单,付出的额外代价很少;缺点是我们得到错误信息的时间点太靠后(编写代码时 --> 鸭子类型检查 --> 抛出错误)。

模板方法模式的使用场景

模板方法模式常被架构师用于搭建项目的框架,架构师定好了框架的骨架,程序员继承框架的结构之后,负责往里面填空

钩子方法

通过模板方法模式,我们在父类中封装了之类的算法框架,这些算法框架在正常状态下是使用大多数子类,但如果有一些特别“个性”的子类。

钩子方法(hook)可以用来解决这个问题,放置钩子是隔离变化的一种常见手段,我们在父类中容易变化的地方放置钩子,钩子可以有一个默认的实现,究竟要不要“挂钩”,这由子类自行决定。钩子方法的返回结果决定模板后面部分的执行顺序

好莱坞原则

允许底层组件将自己挂钩到高层组件中,而高层组件会决定什么时候,以何种方法使用这些底层组件,高层组件对待底层组件的方式:“别调用我们, 我们会调用你。”

当我们用模板方法模式编写一个程序时,就一位着子类放弃了对自己的控制权,而是改为父类通知之类,哪些方法应该在什么时候被调用,作为之类,只复制提供一些设计上的细节好莱坞原则还常常应用于其他模式和昌吉,例如 发布-订阅 和 回调函数

真正需要“继承”吗

模板方法模式是基于继承的一种设计模式,父类封装了之类的算法框架和方法执行的顺序,之类继承父类之后,父类通知之类执行这些方法。即高层组件调用底层组件。模板方法模式是位数不多的基于继承的设计模式,在 JavaScript中则更加灵活。

  const Beverage = function(params) {
    const boilWater = function() {
      console.log('把水煮沸')
    }

    const brew = params.brew || function() {
      throw new Error('brew')
    }

    const pourInCup = params.pourInCup || function() {
      throw new Error('pourInCup')
    }

    const addCondiments = params.addCondiments || function() {
      throw new Error('必须传递 addCondiments 方法')
    }

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

    return F
  }


  const Coffee = Beverage({
    brew: function() {
      console.log('冲咖啡')
    },
    pourInCup: function() {
      console.log('把咖啡倒进杯子')
    },
    addCondiments: function() {
      console.log('加糖')
    }
  })
  const coffee = new Coffee()
  coffee.init()

模板方法模式是一种典型的通过封装变化提高系统扩展性的设计模式,之类的方法种类和执行顺序都是不变的,把这部分逻辑抽象到父类的模板方法里面,而之类的方法具体怎么实现则是可变的。符合开放-封闭原则。

在 JavaScript 中,高阶函数是更好的选择。

九、享元模式

享元(flyweight)模式是一种用于性能优化的模式,享元模式的核心是运用共享技术来有效支持大量细粒度的对象。享元模式要求将对象的属性划分为内部状态和外部状态(通常指属性),享元模式的目标是尽量减少共享对象的数量。

划分内部与外部状态:

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

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

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

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

    把所有内部状态相同的对象都指定为同一个共享对象,而外部状态可以从对象身上剥离出来并储存在外面。

剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组成一个完整的对象,享元模式是一种用时间换空间的优化模式

  const Upload = function(uploadType) {
    this.uploadType = uploadType
  }

  Upload.prototype.delFile = function(id) {
    uploadManager.setExternalState(id, this)

    if (this.fileSize < 3000) {
      return this.dom.parentNode.removeChild(this.dom)
    }

    if (window.confirm('确定要删除该文件么?' + this.fileName)) {
      return this.dom.parentNode.removeChild(this.dom)
    }
  }


  // 工厂进行对象实例化
  const UploadFactory = (function() {
    const createFlyWeightObjs = {}

    return {
      create(uploadType) {
        if (createFlyWeightObjs[uploadType]) return createFlyWeightObjs[uploadType]

        return createFlyWeightObjs[uploadType] = new Upload(uploadType)  
      }
    }
  })()

  // 管理器封装外部状态
  const uploadManager = (function() {
    const uploadDatabase = {};

    return {
      add(id, uploadType, fileName, fileSize) {
        const flyWeightObj = UploadFactory.create(uploadType)

        const dom = document.createElement('div')
        dom.innerHTML = `文件名称:${fileName}, 文件大小: ${fileSize}  `
        // 删除
        dom.querySelector('button').onclick = function() {
          flyWeightObj.delFile(id)
        }

        document.body.appendChild(dom)

        uploadDatabase[ id ] = {
          fileName,
          fileSize,
          dom
        }

        return flyWeightObj
      },
      setExternalState(id, flyWeightObj) {
        const uploadData = uploadDatabase[id]
        for( let i in uploadData) {
          flyWeightObj[i] = uploadData[i]
        }
      }
    }
  })()

  let id = 0
  window.startUpload = function( uploadType, files ) {
    console.log('ef')
    for (let i = 0, file; file = files[i++]; ) {
      uploadManager.add(++id, uploadType, file.fileName, file.fileSize)
    }
  }

  startUpload('plugin', [
    {
      fileName: '1.txt',
      fileSize: 200
    },
    {
      fileName: '2.html',
      fileSize: 100,
    },
    {
      fileName: 'main.js',
      fileSize: 4000
    }
  ])

享元模式是一种很好的性能优化方案,但它也会带来一些复杂性的问题,享元模式带来的好处很大程度上取决于如何使用以及何时使用。

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

内部状态与外部状态

实现享元模式的关键是吧内部状态和外部状态分离开来,有多少种内部状态的组合,系统中便最多存在多个共享对象,而外部状态存储在共享对象的外部,在必要时被传入共享对象来组成一个完整的对象。

  • 当对象没有内部状态:生成共享对象的工厂实际上变成了一个单例工厂。虽然这时候的共享对象没有内部状态的区分,但还是有剥离外部状态的过程吗,我们依然倾向于称之为享元模式。
  • 没有外部状态的享元:享元模式的关键是区别内部状态和外部状态,并把外部状态保存在其他地方,在合适的时刻再把外部状态组装进共享对象,所以如果没有外部状态的分离,即使使用了共享的技术,但也不是一个存储的享元模式

对象池

  const toolTpiFactory = (function() {
    const toolTipPool = [] // toolTop 对象池

    return {
      create() {
        if (toolTipPool.length === 0) { // 对象池为空
          const div = document.createElement('div') // 创建一个 dom
          document.body.appendChild(div)
        } else { // 对象池不为空
          return toolTipPool.shift()
        }
      },
      recover(tooltipDom) {
        return toolTipPool.push(tooltipDom) // 对象池回收dom
      }
    }
  })()

  const ary = []
  for(let i = 0, str; str = ['A', 'B'][i++]; ) {
    const toolTip = toolTpiFactory.create()
    toolTip.innerHTML = str
    ary.push(toolTip)
  }


  // 回收
  for (let i = 0, toolTip; toolTip = ary[i++]; ) {
    toolTpiFactory.recover(toolTip)
  }

  // 再次创建
  for (let i = 0, str; str = ['A', 'B', 'C', 'D', 'E', 'F'][i++]; ) {
    const toolTop = toolTpiFactory.create()
    toolTip.innerHTML = str
  }

通用的对象池

  const objectPoolFactory = function(createObjFn) {
    const objectPool = []

    return {
      create() {
        const obj = objectPool.length === 0 ?
                      createObjFn.apply(this, arguments) :
                      objectPool.shift()
        return obj                       
      },
      recover(obj) {
        objectPool.push(obj)
      }
    }
  }

  // iframe 对象池
  const iframeFactory = objectPoolFactory( function() {
    const iframe = document.createElement('iframe')
    document.body.appendChild(iframe)

    iframe.onload = function() {
      iframe.onload = null; // 防止 iframe 重复加载

      // 加载完成之后 回收节点
      iframeFactory.recover(iframe)
    }

    return iframe
  });


  const iframe1 = iframeFactory.create()
  iframe1.src = 'http://baidu.com'

  const iframe2 = iframeFactory.create()
  iframe2.src = 'http://QQ.com'

  setTimeout(() => {
    const iframe3 = iframeFactory.create();
    iframe3.src = 'http://163.com'
  }, 3000)

对象池是另外一种性能优化方案,他跟享元模式有一些相识之处,但没有分离内部状态和外部状态这个过程。享元模式是为解决性能问题而生的模式,这跟大部分模式的诞生原因都不一样。在一个存在大量相识对象的系统中,享元模式可以很好的解决大量对象带来的性能问题。

十、责任链模式

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

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

如下场景:

  • orderType:表示订单类型
    • 1:500元定金用户
    • 2:200元定金用户
    • 3:普通购买用户
  • pay:表示是否支持定金,如果一直没有支付定金,将降级进入普通购买模式
  • stock:库存,已经支付过500元或者200元定金的用户不受此限制
  const order = function(orderType, pay, stock) {
    if (orderType === 1) { // 500
      if (pay) console.log('500元定金预购,得到100优惠卷')
      else { // 未支付定金
        if (stock > 0) console.log('普通购买,无优惠卷')
        else console.log('手机库存不足')
      }
    } else if (orderType === 2) { // 200 元
      if (pay) console.log('200元定金预购,得到50优惠卷')
      else {
        if (stock > 0) console.log('普通购买,无优惠卷')
        else console.log('手机库存不足')  
      }  
    } else if (orderType === 3) { // 普通购买
      if (stock > 0) console.log('普通购买,无优惠卷')
      else console.log('手机库存不足')
    }
  }

  order(1, true, 500)

算不上一段值得夸奖的代码

使用责任链重构代码:

  • 把 三种购买模式分成三个函数
  • orderType、pay、stock 这三个字段当做参数传递给500元订单函数,如果该函数不符合处理条件,则把这个请求传递给后面的200元订单函数。如果200元订单函数依然不能处理该请求,则继续传递请求给普通购买函数。
  const order500 = function(orderType, pay, stock) {
    if (orderType === 1 && pay) {
      console.log('500元定金预购,得到100优惠卷')
    } else {
      order200(orderType, pay, stock) // 将请求传递给200元订单
    }
  }

  const order200 = function(orderType, pay, stock) {
    if (orderType === 2 && pay) {
      console.log('200元定金预购,得到50优惠卷')
    } else {
      orderNormal(orderType, pay, stock) // 普通订单
    }
  }

  const orderNormal = function(orderType, pay, stock) {
    if (stock > 0) console.log('普通购买,无优惠卷')
    else console.log('手机库存不足')
  }

缺点:在链条传递中的的顺序非常僵硬。并且违反开放-封闭原则

灵活可拆分的责任链节点

例如我们约定,如果某个节点不能处理请求,则返回一个特定的字符串“nextSuccessor”来表示该请求需要继续往后面传递。

  const order500 = function(orderType, pay, stock) {
    if (orderType === 1 && pay) console.log('500元定金预购,得到 100 元优惠卷')
    else return 'nextSuccessor'
  }

  const order200 = function(orderType, pay, stock) {
    if (orderType === 2 && pay) console.log('200元定金预购, 的带50优惠卷')
    else return 'nextSuccessor'
  }

  const orderNoraml = function(orderType, pay, stock) {
    if (orderType === 3 && pay) console.log('普通购买,无优惠卷')
    else console.log('手机库存不足')
  }

  // 责任链
  const Chain = function(fn) {
    this.fn = fn
    this.successor = null
  }

  Chain.prototype.setNextSuccessor = function( successor ) {
    return this.successor = successor // 返回 successor 方便链式调用
  }

  Chain.prototype.passRequest = function() {
    const ret = this.fn.apply(this, arguments) // 执行当前节点

    if (ret === 'nextSuccessor') { // 调用下一个节点
      return this.successor && this.successor.passRequest.apply(this.successor, arguments)
    }
    // return ret
  }

  const chainOrder500 = new Chain(order500)
  const chainOrder200 = new Chain(order200)
  const chainOrderNormal = new Chain(orderNoraml)

  // 指定责任链中的顺序
  chainOrder500.setNextSuccessor(chainOrder200)
  chainOrder200.setNextSuccessor(chainOrderNormal)

  // 将请求传递给第一个节点
  chainOrder500.passRequest(1, true, 500)
  chainOrder500.passRequest(2, true, 500)
  chainOrder500.passRequest(3, true, 500)
  chainOrder500.passRequest(1, false, 0)

异步的责任链模式

指定一个 next 方法,用于手动传递请求给责任链中的下一个节点。

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

 const fn1 = new Chain(function() {
   console.log(1)
   return 'nextSuccessor'
 })

 const fn2 = new Chain(function() {
   console.log(2)
   const _self = this

   setTimeout(function() {
     _self.next()
   }, 2000)
 })

 const fn3 = new Chain(function() {
   console.log(3)
 })

 fn1.setNextSuccessor(fn2).setNextSuccessor(fn3)
 fn1.passRequest() // 1 2 ... 3

如此可以很方便地创建一个异步 ajax 队列库

责任链模式的优缺点

优点:

  • 解耦了请求发送者和N个接受者之间的复杂关系。
  • 链中的节点对象可以灵活的拆分重组
  • 可以手动指定起始节点,这样可以减少请求在链路中的传递次数,更快地找到合适的请求接受者

弊端

  • 不能保证某个请求一定会被链中的节点处理。这种情况下,我们可以在链为增加一个保底的接受者节点来处理这种即将离开链尾的请求。
  • 责任链模式使得程序中多了一些节点对象,从性能方面考虑,我们要避免过程的责任链带来的性能损耗

使用 AOP 实现责任链

AOP(面向切面编程)

aop是指在编译期间,运行期间,动态的生成代码放到你的类里面。oop则是所有需要的类都在编译前已经写好了

创建一个可以生成代码的代码段叫做切面,创建一个可以被切入代码的代码段叫做切点。合起来就叫面向切面。

优点:代码更加简洁,如果再加上时间的控制就会变得更加灵活。

缺点:使用灵活则会导致如果没有一个好的规范,可读性会很差,而且难以维护。

利用 JavaScript 的函数式特性,可以更加方便的来创建责任链。

  Function.prototype.after = function(fn) {
    const _self = this
    return function() {
      const ret = _self.apply(this, arguments)
      if (ret === 'nextSuccessor') return fn.apply(this, arguments)
    }
  }

  const order = order500.after(order200).after(orderNormal)

在JavaScript 开发中, 责任链模式可以很好地帮助我们管理代码,降低发起请求的对象和处理请求的对象之间的耦合性。职责链中的节点数量和顺序是可以自由变化的,我们可以在运行时决定链中包含哪些节点。

十一、中介者模式

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

中介者模式的作用就是解除对象与对象之间的耦合关系。增加一个中介者对象后,所有的相关对象都通过中介者对象来通信,而不是互相引用,所以当一个对象发生改变时,只需要通知中介者对象即可。

1604759204007.png
  function Player( name ) {
    this.name = name
    this.enemy = null
  }

  Player.prototype.win = function() {
    console.log(this.name + 'won')
  }

  Player.prototype.lose = function() {
    console.log(this.name + 'lost')
  }

  Player.prototype.die = function() {
    this.lose()
    this.enemy.win()
  }

  // 创建玩家对象
  const player1 = new Player('剑无极')
  const player2 = new Player('雪山银燕')

  // 互相设置敌人
  player1.enemy = player2
  player2.enemy = player1

  player1.die()

如上是两个人的游戏,当人数增多后,失败和胜利的判断将会变得复杂。正常的想法我们可能会分别用两个集合存储双方的友军和敌人,当玩家死亡后回去判断友军时候全部阵亡从而来判断这场游戏的走向。

由此带来的问题:

  • 每个玩家和其他玩家都是紧紧耦合在一起的。
  • 每个玩家对象需要都有两个属性, this.partners 和 this.enemies
  • 当人数变多后,一个玩家掉线,则必须从所有其他玩家的队友列表和敌人列表中移除这个玩家。

使用中介者模式

  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() {
    playerDirector.ReceiveMessage('changeTeam', this) // 通知中介者
  }


  // 创建玩家工厂模式
  const playFactory = function(name, teamColor) {
    const newPlayer = new Player(name, teamColor) // 创建玩家
    playerDirector.ReceiveMessage('addPlayer', newPlayer) // 通知中介者

    return newPlayer
  }

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

    /************** 新增玩家 **************/
    operations.addPlayer = function(player) {
      const teamColor = player.teamColor

      // 获取当前队伍
      players[teamColor] = players[teamColor] || []
      // 添加玩家进队伍
      players[teamColor].push(player)
    }

    /************* 移除一个玩家 *************/
    operations.removePlayer = function(player) {
      const teamColor = player.teamColor
      const teamPlayers = players[teamColor] || []

      // 遍历删除,倒叙
      for(let i = teamPlayers.length - 1; i >= 0; i--) {
        if (teamPlayers[i] === player) {
          teamPlayers.splice(i, 1)
          break;
        }
      }      
    }

    /************** 玩家换队 *****************/
    operations.changeTeam = function(player, newTeamColor) {
      operations.removePlayer(player) // 从原先队伍删除
      player.teamColor = newTeamColor // 换队色
      operations.addPlayer(player) // 添加队伍
    }

    /*************** 玩家死亡 *****************/
    operations.playerDead = function(player) {
      const teamColor = player.teamColor
      const teamPlayers = players[teamColor]

      let all_dead = true
      // 是否全部阵亡
      for(let i = 0, player; player = teamPlayers[i++]; ) {
        if (player.state !== 'dead') {
          all_dead = false
          break;
        }
      }

      if (all_dead === true) { // 全部死亡
        for (let i = 0, player; player = teamPlayers[i++]; ) {
          player.lose() // 本队所有玩家 lose
        }

        for (let color in players) {
          if (color !== teamColor) { // 其他队伍
            const teamPlayers = players[color]
            for (let i = 0, player; player = teamPlayers[i++]; ) {
              player.win() // 通知胜利
            }
          }
        }
      } // end if
    }

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

    return {
      ReceiveMessage
    }
  })()

  // 测试
  const player1 = playFactory('银燕', 'red'),
        player2 = playFactory('剑无极', 'red'),
        player3 = playFactory('独眼龙', 'red'),
        player4 = playFactory('俏如来', 'red'),
        player5 = playFactory('梁皇无忌', 'red')

  const player6 = playFactory('北竞王', 'blue'),
        player7 = playFactory('千雪孤鸣', 'blue'),
        player8 = playFactory('撼天阙', 'blue'),
        player9 = playFactory('令狐千里', 'blue'),
        player10 = playFactory('苍狼', 'blue') 
/*
  player1.die()           
  player2.die()           
  player3.die()           
  player4.die()           
  player5.die()      
*/
  player1.die()           
  player2.changeTeam('blue')           
  player3.remove()           
  player4.remove()           
  player5.die()     

中介者模式是迎合迪米特法则的一种实现。迪米特法则也叫最少知识圆锥,是指一个对象应该尽可能少了解另外的对象。如果对象之间的耦合性太高每一个对象发生改变之后,难免会影响到其他的对象。

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

缺点:系统中会增加一个中介者对象,因为对象之间交互的复杂性,转移成了中介者对象的复杂性,使得中介者对象经常是巨大的,中介者对象自身往往就是一个难以维护的对象。

十二、装饰者模式

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

在面向对象语言中,给对象添加功能常常使用继承的方式,这会带来很多问题:

  • 超类和子类之间存在强耦合性,当超类改变时,之类也会随之改变

  • 继承这种功能复用方式通常被称为“白箱复用”

    “白箱”是相对可见性而言的,在继承方式中,超类的内部细节是对子类可见的,继承常常被认为破坏了封装性。

装饰器模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责,相对继承来说,装饰者是一种更轻便灵活的做法。

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

  const Plane = function() {}

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

  // 装饰类
   
  /************** 导弹 *******************/
  const MissileDecorator = function(plane) {
    this.plane = plane
  }
  
  MissileDecorator.prototype.fire = function() {
    this.plane.fire()
    console.log('发射导弹')
  }

  /************** 核弹 ********************/
  const NucleDecorator = function(plane) {
    this.plane = plane
  }

  NucleDecorator.prototype.fire = function() {
    this.plane.fire()
    console.log('发射核弹')
  }

  let plane = new Plane()
  plane = new MissileDecorator(plane)
  plane = new NucleDecorator(plane)
  plane.fire()

装饰者也是包装器

装饰者模式将一个对象嵌入另一个对象之中,实际上相当于这个对象被另一个对象包装起来,形成一个包装链,请求随着这条链一次传递到所有的对象,每个对象都有处理这条请求的机会。

JavaScript的装饰者

  const plane = {
    fire() {
      console.log('发射子弹')
    }
  }

  const missileDecorator = function() {
    console.log('发射导弹')
  }

  const nucleDecorator = function() {
    console.log('发射核弹')
  }

  const fire1 = plane.fire

  plane.fire = function() { // 包装
    fire1()
    missileDecorator()
  }

  const fire2 = plane.fire
  plane.fire = function() { // 包装
    fire2()
    nucleDecorator()
  }

  plane.fire()

AOP装饰函数

  Function.prototype.before = function(beforefn) {
    const _self = this

    return function() {
      beforefn.apply(this, arguments) // 调用装饰函数
      return _self.apply(this, arguments) // 调用原函数,保留 this 指向
    }
  }

  Function.prototype.after = function(afterFn) {
    const _self = this

    return function() {
      const ret = _self.apply(this, arguments)
      afterFn.apply(this, arguments)
      return ret
    }
  }

  const a = function() {
    console.log('a')
  }

  // 装饰
  const fn = a.before(function() {
    console.log('not')
  })

  fn()

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

  const before = function(fn, beforefn) {
    return function() {
      beforefn.apply(this, arguments)
      return fn.apply(this, arguments)
    }
  }

  const a = before(
    function() { console.log('20') },
    function() { console.log('10') }
  )

AOP的应用实例

  • 数据统计上报:执行某个操作之后进行数据统计
  • 动态改变函数的参数
  • 插件式表单验证

改变函数的参数

装饰器函数和原函数共用一组参数列表 arguments,当我们在 beforefn 的函数体内改变 arguments 的时候,原函数 _self 接受的参数列表自然也会变化。比如可以给 ajax 请求加上一个 token

  Function.prototype.before = function(beforefn) {
    const _self = this
    return function() {
      beforefn.apply(this, arguments)
      return _self.apply(this, arguments)
    }
  }

  const func = function(param) {
    console.log(param) // { a: 'a', b: 'b' }
  }

  const f = func.before(function(param) {
    param.b = 'b'
  })

  f({a: 'a'})

插件式的表单验证

 const validata = function() {
   if (username.value === '') return false
   if (password.value === '') return false  
 }

 const formSubmit = function() {
   if (validata() === false) return;

   const param = {
     username: username.value,
     password: password.value
   }
   ajax('http://...', param)
 }

 submitBtn.onclick = formSubmit.before(validata)

需要注意的是,函数通过Function.prototype.before 或者 Function.prototype.after 被装饰后,返回的实际上是一个新的函数,瑞国在原函数上保存了一些属性,那么这些属性会丢失。此外,这种装饰方法也叠加了函数的作用域,如果装饰的链条过长,性能上也能受到一些影响。

装饰者模式和代理模

代理模式和装饰者模式最重要的区别在于他们的意图和设计目的。

  • 代理模式的目的是,当直接访问本体不方便或者不符合需要时,为证本体提供一个替代者。本地定义了关键功能,而代理提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情。
  • 装饰者模式的作用就是为对象动态加入行为。

  • 代理模式通常只有一层代理——本体的引用,
  • 装饰者模式经常会形成一条长长的装饰链

  • 装饰者模式实实在在的为对象增加新的职责和行为
  • 代理做的事情还是跟本体一样,最终都是设置修改操作

十三、状态模式

状态模式是一种非同寻常的优秀模式,它也许是解决某些需求场景的最好方法。状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变。

  const Light = function() {
    this.state = 'off'
    this.button = null 
  }

  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'
    }
  }

缺点:

  • 违反开放-封闭原则
  • 所有的状态相关的行为,都被封装在 buttonWasPressed 方法里面,随着业务的复杂度,内容会越发的膨胀
  • 状态的切换非常不明显,仅仅表现为对 state 变量的复制
  • 状态切换僵硬

使用状态模式

状态模式的关键是吧事物的每种状态都封装成单独的类,跟这种状态有关的行为都被封装在这个类的内部。


  const OffLightState = function(light) {
    this.light = light
  }

  OffLightState.prototype.buttonWasPressed = function() { // 下一个状态
    console.log('弱光')
    this.light.setState(this.light.weakLightState) 
  }

  // 弱光
  const WeakLightState = function(light) {
    this.light = light
  }
  WeakLightState.prototype.buttonWasPressed = function() { // 下一个状态
    console.log('强光')
    this.light.setState(this.light.strongLightState)
  }

  // 强光
  const StrongLightState = function(light) {
    this.light = light
  }

  StrongLightState.prototype.buttonWasPressed = function() { // 下一个状态
    console.log('关灯')
    this.light.setState(this.light.offLightState)
  }

  const Light = function() {
    this.offLightState = new OffLightState(this)
    this.weakLightState = new WeakLightState(this)
    this.strongLightState = new StrongLightState(this)
    this.button = null
  }

  Light.prototype.init = function() {
    const button = document.createElement('button')
    const _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(state) {
    this.currState = state
  }

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

使每一种状态和它对应的行为之间的关系局部化,这些行为被分散和封装在各自对应的状态类之中,便于阅读和管理代码

状态模式的定义

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

  • 第一部分的意思是将状态封装成独立的类,并将请求委托给当前的状态对象,当对象的内部状态改变时,会带来不同的行为变化。
  • 从客户的角度来看,我们使用的对象,在不同的状态下具有截然不同的行为,这个对象看起来是从不同的类中实例化而来的,实际上这是使用了委托的思想。

状态模式的通用架构

Light类在这里也被称为上下文(Context)。随后在 Light 的构造函数中,我们要创建每一个状态类的实例对象,Context将持有这些状态对象的引用。以便把请求委托给状态对象。随后,编写各种状态类,light 对象 被传入状态类的构造函数,状态对象也需要持有light对象的引用,以便调用 light 中的方法或者直接操作light对象。

缺少抽象类的变态方式

解决方案跟 模板方法模式 中的一致,让抽象父类的抽象方法直接抛出一个异常,这个异常至少会在程序运行期间被发现

  const State = function() {}

  State.prototype.buttonWasPressed = function() {
    throw new Error('未重写 buttonWasPressed 方法')
  }

  const SuperStrongLightState = function(light) {
    this.light = light
  }
  SuperStrongLightState.prototype = new State() // 继承抽象父类
  SuperStrongLightState.prototype.buttonWasPressed = function() { // override
      console.log('关灯')
      this.light.setState(this.light.offLightState)
  }

状态模式的优缺点

优点:

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

缺点:

会在系统中定义许多状态类,系统中会因此增加不少对象;逻辑分散的问题,无法在一个地方就看出整个状态机的逻辑

性能优化点

  • 仅当state对象被需要时才创建并随后销毁

  • 一开始就创建好所有的状态对象,并且始终不销毁他们

    根据自身业务来选择

状态模式和策略模式

相同点:

都有一个上下文,一些策略或者状态类,上下文把请求委托给这些类来执行

区别:

策略模式中的各个策略之间是平等又平行的,他们之间没有任何联系,所有客户必须熟知这些策略类的作用,以便客户可以随时主动切换算法;状态模式中,状态和状态对应的行为是早已被封装号的,状态之间的切换也早被规定完成,“改变行为” 这件事情发生在状态模式内部

JavaScript 版本的状态机

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

  const Light = function() {
    this.currState = FSM.off // 设置当前状态
    this.button = null
  }

  Light.prototype.init = function() {
    const button = document.createElement('button')
    const self = this

    button.innerHTML = '已关灯'
    this.button = document.body.appendChild(button)

    this.button.onclick = function() {
      self.currState.buttonWasPressed.call(self) // 委托给 FSM
    }
  }

  const FSM = {
    off: {
      buttonWasPressed() {
        console.log('关灯')
        this.button.innerHTML = '开灯'
        this.currState = FSM.on
      }
    },
    on: {
      buttonWasPressed() {
        console.log('开灯')
        this.button.innerHTML = '关灯'
        this.currState = FSM.off
      }
    }
  }

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

面向对象设计和闭包互换

  const delegate = function(client, delegation) {
    return {
      buttonWasPressed: function() { // 将操作委托给 delegation 对象
        return delegation.buttonWasPressed.apply(client, arguments)
      }
    }
  }
  
  const 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
      }
    }
  }
  
  const 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() {
    const button = document.createElement('button')
    const self = this
    
    button.innerHTML = '已关灯'
    this.button = document.body.appendChild(button)
    this.button.onclick = function() {
      self.currState.buttonWasPressed()
    }
  }
  
  const light = new Light()
  light.init()

表驱动的有限状态机:这种方法的核心是基于表驱动的,在表中可以很清楚地看到下一个状态是由当前状态和行为共同决定的。这样一来骂我们可以在表中查找状态,而不必定义很多条件分支。

十四、适配器模式

适配器模式的作用是解决两个软件实体间的接口不兼容的问题,适配器的别名是包装器(wrapper)。适配器模式是一种“亡羊补牢”的模式,没有人会在程序的设计之初就使用它。

调用api不同的功能库

const gooleMap = {
  show: function() {
    console.log('开始渲染谷歌地图')
  }
}

const baiduMap = {
  render: function() {
    console.log('开始渲染百度地图')
  }
}

const baiduMapAdapter = {
  show: function() {
    return baiduMap.render()
  }
}

const renderMap = function( map ) {
  if (map.show instanceof Function) {
    map.show()
  }
}

renderMap(gooleMap)
renderMap(baiduMapAdapter)

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

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

设计原则与最佳实践

一、单一职责原则

就一个类而言,应该仅有一个引起它变化的原因。在 JavaScript 中,单一职责原则更多地是被运用在对象或者方法级别上。

单一职责原则(SRP)的职责被定义为“引起变化的原因”。如果我们有两个动机去改写一个方法,这个方法就具有两个职责。每个职责都是变化的一个轴线,如果一个方法承担了过多的职责,那么在需求的变迁过程中,需要改写这个方法的可能性就越大。此时这个方法通常是一个不稳定的方法,这种耦合性得到的是低内聚和脆弱的设计。

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

代理模式中的SRP原则

  const myImage = (function() {
    const imgNode = document.createElement('img')
    document.body.appendChild( imgNode )

    return {
      setSrc(src) {
        imgNode.src = src;
      }
    }
  })()

  const proxyImage = (function() {
    const img = new Image;
    img.onload = function() { // 图片资源加载完毕
      myImage.setSrc(this.src) // 设置真实资源
    }
    return {
      setSrc(src) {
        myImage.setSrc('./loading.gif') // 设置loading.gif 等待加载
        img.src = src
      }
    }
  })()

  proxyImage.setSrc('http://xxxx.com/xx/xx.png')

将添加 img 标签的功能和预加载图片的职责分开放到两个对象中,这两个对象各自都有一个被修改的动机。在他们各自发生改变的时候,也不会影响另外的对象。

SRP原则在很多设计模式中都有着广泛的运用,例如 代理模式迭代器模式单例模式装饰器模式

何时应该分离职责

SRP原则是所有原则中最简单也是最难正确运用的原则之一,并不是所有的职责都应该一一分离。

  • 如果随着需求的变化,有两个职责总是同时变化,那就不必分离他们。
  • 另一方面,职责的变化轴线仅当他们确定会发生变化时才具有意义,即使两个职责已经被耦合在一起,但他们还没有发生改变的预兆。那么也许没有必要主动分离他们。

违反SRP原则

未必要在任何时候都一成不变地遵守原则。例如jQuery中的方法,大部分都有取值和赋值双向操作,这对于jQuery的维护者来说,会带来一些困难,但对于jQuery的用户来说,却简化了用户的使用

SRP原则的优缺点

SRP 原则的优点是降低了单个类或对象的复杂度,按照职责把对象分解成更小的粒度,这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责。

SRP的缺点最明显的是最增加编写代码的复杂度,当我们按照职责把对象分解成更小的粒度之后,实际上增大了这些对象相互联系的困难。

二、最少知识原则

最少知识原则(LKP)也叫迪米特法则(Law of Demeter)一个软件实体应当尽可能少地与其他实体发生相互作用。

减少对象之间的联系

单一职责原则指导我们把对象划分成较小的粒度,可以提高对象的可复用性。但越来越多的对象之间可能会产生错综复杂的联系,如果修改了其中一个对象,很可能会影响到跟它相互引用的其他对象。

最少知识原则要求我们在设计程序时,应当尽量减少对象之间的交互。如果两个对象之间不必彼此直接通向,那么这两个对象就不要发生直接的相互联系。常见的做法是引入一个第三者对象,来承担这些对象之间的通信作用。

中介模式中最少知识原则

中介模式很好地体现了最少知识原则,通过增加一个中介者模式,让所有的相关对象都通过中介者对象来通信,而不是互相引用。当一个对象发生改变时,只需要通知中介者对象即可。

封装在最少知识原则中的体现

封装在很大程度上表达的是数据的隐藏。一个模块或者对象可以将内部的数据或者实现细节隐藏起来,只暴露必要的接口API供外界访问。

封装也用来限制变量的作用域,把变量的可见性限制在一个尽可能小的范围内,这个变量对其他不相关模块的影响就越小,变量被改写和发生冲突的机会也越小。这也是广义的最少知识原则的一种体现。

  const mult = (function() {
    const cache = {}

    return function() {
      const args = Array.prototype.join.call(arguments, ',')
      if (cache[ agrs ]) return cache[args];

      let a = 1
      for (let i = 0, l = arguments.length; i < l; i++) {
        a = a * arguments[i]
      }
      return cache[args] = a
    }
  })()

  mult(1, 2, 3) // 6

计算阶乘,把 cache 对象放在 mult 形成的闭包中,比把它放在全局作用域中更加合适

三、开放-封闭原则

在面向对象的程序设计中,开放-封闭原则(OCP)是最重要的一条原则。定义:软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改装饰器模式是一个很好的体现

开放和封闭

开发和-封闭的思想:当需要改变一个程序的功能或者给这个程序增加功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码

用对象的多态性消除条件分支

过多的天健分支语句是造成程序违反开放-封闭原则的一个常见原因。把 if 缓存 switch-case 是没有用的,利用对象的多态性来重构他们。

不符合开放-封闭

  const makeSound = function(animal) {
    if (animal instanceof Duck) console.log('嘎嘎嘎')
    else if (animal instanceof Chicken) console.log('咯咯咯')
    else if (animal instanceof Cat) console.log('喵喵喵')
  }
  const Cat = function() {}
  makeSound(new Cat())

这段代码最大的问题在于如果后续有新的动物类型添加,那么就必须要去修改 makeSound 函数。

开放-封闭

  const makeSound = function(animal) {
    animal.sound()
  }

  const Cat = function() {}
  Cat.prototype.sound = function() {
    console.log('喵喵喵')
  }
  makeSound(new Cat())

利用多台的思想,把程序中不变的部分隔离出来,然后把可变的部分封装起来,这样一来程序就具有了可扩展性。

找出变化的地方

开放-封闭原则是一个看起来比较虚幻的原则,并没有实际的模板教导我们怎样亦步亦趋地来实现。但还是能找到一些程序尽量准守开放-封闭原则的规律,最明显的就是找出程序中将要发生变化的地方,然后把变化封装起来。通过封装变化的方式,可以把系统中稳定不变的部分和容易变化的部分隔离开来。

遵守开发-封闭的其他方式

  • 放置挂钩(hook)

    放置挂钩也是分离变化的一种方式,在程序有可能发生变化的地方放置一个挂钩,挂钩的返回结果决定了程序的下一步走向。这样一来,原本的代码执行路径上就出现了一个分叉路口,程序未来的执行方向被预埋下多种可能性。

  • 使用回调函数

    在 JavaScript 中,函数可以作为参数传递另一个函数,这是高阶函数的意义之一,回调函数是一种特殊的挂钩,可以把一部分已与变化的逻辑封装在回调函数里,然后把回调函数当做参数传入一个稳定和封闭的函数中,当回调函数执行的时候,程序就可以因为回调函数的内部逻辑不同,而产生不同的结果。例如:ajax异步请求

开发-封闭原则的体现

  • 发布-订阅模式

    用来降低多个对象之间的依赖关系,它可以取代对象之间硬编码的通知机制,一个对象不再显示地调用另一个对象的某个接口。当有新的订阅者出现时,发布者的代码不需要进行任何修改;同样当发布者需要改变时,也不会影响之前的订阅者

  • 模板方法模式

    在运用模板发放模式的程序中,之类的方法种类和执行顺序都是不变的,所以可以把这部分逻辑抽出来放到父类的模板方法里面;而之类的方法具体怎么实现则是可变的,于是把这部分变化的逻辑封装到之类中。

  • 策略模式

    模板方法模式基于继承的思想,而策略模式则偏重于组合和委托。策略模式将各种算法都封装成单独的策略类,这些策略类可以被交换使用。策略和使用策略的客户代码可以分别独立进行修改而互不影响

此外还有代理模式,责任链模式 等等

开放-封闭原则的相对性

让程序保持完全封闭是不容易做到的。就算技术上做得到,也需要花费大多的时间和精力。而且让程序符合开发-封闭原则的代价是引入更多的抽象层次,更多的抽象有可能会增大代码的复杂度。
有一些代码是无论如何也不能完全封闭的,总会存在一些无法对其封闭的变化。为此可以做到下面两点

  • 挑选出最容易发生变化的地方,然后构造抽象来封闭这些变化。
  • 在不可避免发生修改的时候,尽量修改哪些相对容易修改的地方。哪一个开源库来说,修改它提供的配置文件,总比修改它的源码来得简单。

一开始就尽量遵守开放-封闭原则,并不是一件很容易的事情。在最初编写代码的时候,先假设变化永远不会发生,这有利于我们迅速完成需求。当变化发生并且对我们接下来的工作造成影响的时候,可以再回头来封装这些变化的部分。然后确保我们不会掉进同一个坑里。

四、面向接口编程

面向接口编程主要使用到 抽象类和interface。抽象类和 interface的作用主要有以下两点

  • 通过向上转型来因此对象的真正类型,以表现对象的多态性。
  • 约定类与类之间的一些契约行为。
    JavaScript是一门动态类型语言,类型本身在 JavaScript 中是一个 相对模糊的概念。除了number、string、boolean 等基本数据类型之外,其他的对象都可以被看成“天生”被“向上转型成了 Object类型,在动态语言类型中,对象的多态性是与生俱来的,但在另外一些静态类型语言中,对象类型之间的解耦非常重要,甚至有一些设计模式的主要目的就是专门隐藏对象的真正类型。因为不需要进行向上转型,接口在JavaScript中的最大作用就退化到了检查代码的规范性。”

使用鸭子类型进行接口检查

鸭子类型的定义:“如果它走起路来像鸭子,叫起来也是鸭子,那么它就是鸭子。”鸭子类型是动态类型语言面向对象设计中的一个重要概念。

在 使用 Object.prototype.toString.call() 判断类型之前,使用鸭子类型来判断一个对象是否为数组

  // 判断数组
  const isArray = function(obj) {
    return obj &&
        typeof obj === 'obj' &&
        typeof obj.length === 'number' &&
        typeof obj.splice === 'function'
  }

使用 TypeScript

TypeScript 是JavaScript的一个超集,与 CoffeeScript 类似。TypeScript 代码最终会被编译成原生的JavaScript代码执行。通过 TypeScript,可以使用静态语言的方式来编写 JavaScript 程序。

五、代码重构最佳实践

  • 提炼函数

    • 避免出现超大函数
    • 独立出来的函数有助于代码复用。
    • 独立出来的函数更容易被覆写
  • 合并重复的条件判断

    const paging = function(page) {
        if (page <= 0) {
            page = 0
            jump(page)
        }else if (page >= totalPage) {
            page = totalPage
            jump(page)
        }else {
            jump(page)
        }
    }
    
    // vs
    const paging = function(page) {
        if (page <=0) page = 0
        else if (page >= totalPage) page = totalPage
        jump(page)
    }
    
  • 将复杂的分支语句提炼出函数

    if (/*xxx && xxx && xxx*/) { /*...*/ }
    
    // vs
    if (is()) {/*...*/)}
    
  • 合理使用循环

  • 提前让函数退出代替嵌套条件分支

    if () {}
    else {}
    
    // 
    
    if () {
        // ... 
        return 
    }
    // ... else
    
  • 传递对象参数代替过长的参数列表

    const user = function(name, id, avatar, gender) {}
    const user = function(userInfo) {}
    
  • 尽量减少参数数量

  • 少用三三目运算符

  • 合理使用链式调用

  • 分解大型类

  • 使用return退出多次循环

    不利于阅读的退出方式有:声明控制标记变量、设置循环标记 break;

参考文献
曾探.JavaScript设计模式与开发实践.北京:人民邮电出版社,2015

你可能感兴趣的:(JavaScript设计模式)