Javascript设计模式(下)——— 常见设计模式

在前阵子的 一篇文章 中,回顾了一下 Javascript 中常用的三个设计原则,今天,我们更上一层,总结一下 Javascript 中常用的设计模式。

设计模式是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。 使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性、程序的重用性。

对于很多没有系统学习过设计模式的人,可能会觉得设计模式佷高大上,其实不然,很多时候,我们在不经意间已经用上了设计模式,只是你不知道而已。 Javascript 中常见的设计模式有:

  • 单例模式
  • 策略模式
  • 代理模式
  • 发布 / 订阅模式
  • 装饰者模式

我们来具体看一看

单例模式

单例模式的核心是确保同类型实例只有一个,并提供全局访问。

一个最简单的单例模式:

window.config = {} // global.config = {}

当然,实际项目中我们应该尽量减少全局变量的使用,因为这不是个好的设计。

再举一个比较通用的单例模式实现方式:

class Singleton {
  constructor (name) {
    this.name = name
    this.instance = null
  }
  static getInstance (name) {
    if (!this.instance) {
      this.instance = new Singleton(name)
    }
    return this.instance
  }
}

调用时,通过 Singleton.getInstance 获取实例,只要 name 相同,获取到的实例总是同一个。

const a = Singleton.getInstance('test')
const b = Singleton.getInstance('test')

console.log(a === b) // true

不想用 getInstance ?

假如不想用 Singleton.getInstance 的方式获取实例,而是直接使用 new Singleton(),我们可以通过闭包来实现

const Singleton = (function () {
  let instance
  return class Singleton {
    constructor (name) {
      if (!instance) {
        this.name = name
        instance = this
      }
      return instance
    }
  }
})()

const a = new Singleton('test')
const b = new Singleton('test')

console.log(a === b) // true

策略模式

定义一组算法,把它们封装起来,并使它们可以互相替换。策略可以让算法独立于使用者的变化。

在 javascript 中,策略模式非常的实用,它可以让我们较好的符合开放-封闭原则,我们来看具体的例子:

年终奖是根据员工的岗位系数和绩效计算来的,不同岗位系数和绩效的人年终奖发放月数不同。

一个简单的实现:

function calcBonus (jobLevel, performance) {
  if (performance === 'S') {
    return jobLevel * 5
  }
  if (performance === 'A') {
    return jobLevel * 3
  }
  if (performance === 'B') {
    return jobLevel
  }
}

这种不考虑设计模式实现的函数非常简单,但是有显而易见的缺点:

  • if-else 太多,函数体很不清晰
  • 不符合开放-封闭原则,修改绩效 S 的奖金时需要深入函数去修改
  • 复用性差

我们来看看,使用策略模式的效果

const Strategies = {
  'S': function (jobLevel) {
    return jobLevel * 5
  },
  'A': function (jobLevel) {
    return jobLevel * 3
  },
  'B': function (jobLevel) {
    return jobLevel
  }
}
function calcBonus (jobLevel, performance) {
  return Strategies[performance](jobLevel)
}

之后,当奖金变更时,我们只需要修改策略,而不需要修改 calcBonus 函数。

代理模式

代理模式为对象提供一种代理来控制对该对象的访问。

思考这样一个场景,你是一个 super star,平时会接一些商演或者代言。这些事情,如果所有人都是直接找你对接,那么会很多烦恼:

  • 非正常的联系你需要自己过滤,因为你公开了自己的访问途经:比如私生饭的骚扰
  • 确实是商演或者代言来联系,但是非常不适合,你也需要自己过滤:比如你是男明星,有人找你代言姨妈巾 :)

所以在现实中,稍有点名气的明星都有自己的经纪人,经纪人会作为中间层帮明星处理和过滤这些请求,只有经纪人知道明星的联系方式,商演和代言都需要经过经纪人去处理,这个场景中的经纪人就是代理模式中的代理。

我们来看看没有代理模式的实现:

// 假设 Endorsement 类已定义
class SuperStar {
  constructor (name) {
    this.name = name
    this.endorsements = []
  }
  // 接电话 or 邮件之类的
  answer (content) {
    if (content instanceof Endorsement) {
      let ok = false
      // 分析代言是否合理
      if (ok) {
        this.endorsements.push(content)
      }
      return ok ? '合作愉快' : '这个代言不太适合我,不好意思'
    } else {
      // 这不是个代言,可能是骚扰
      return '停止你的骚扰行为'
    }
  }
  addEndorsement (endorsement) {
    this.endorsements.push(endorsement)
  }
}

const jay = new SuperStar('Jay')
jay.answer('Jay 你演唱会门票很难抢诶,能送我两张吗') // 谁都可以调用 jay.answer()

可以预想,SuperStar类会非常的臃肿,并且,SuperStar类需要处理所有事物,但实际上 SuperStar 的重心应该放在创作或者表演之类的事情上,而不是不停的接电话或者回复邮件。这时候,代理模式就很有必要了。来看看代理模式的使用:

class StarAgent {
  constructor (name, star) {
    this.name = name
    this.star = star
  }
  answer (content) {
    if (content instanceof Endorsement) {
      let ok = false
      if (ok) {
        this.star.addEndorsement(content)
      }
      return ok ? '合作愉快' : `这个代言不太适合${this.star.name},不好意思`
    } else {
      return '停止你的骚扰行为'
    }
  }
}
class SuperStar {
  constructor (name) {
    this.name = name
    this.agent = null
    this.endorsements = []
  }
  setAgent (agent) {
    this.agent = agent
  }
  addEndorsement (endorsement) {
    this.endorsements.push(endorsement)
  }
}

const jay = new SuperStar('Jay')
const agent = new StarAgent('不知道叫什么', jay)
agent.answer('Jay 的演唱会门票很难抢诶,你能送我两张吗') // 只能通过agent沟通

这样子,繁琐的事务交由经纪人处理,SuperStar 类可以专注于自己的事情上。当然,代理模式也不仅仅适用于这类场景。

目前前端比较火的 Vue 框架就很巧妙的应用了代理模式:

new Vue({
  data: function () {
    return {
      test: 'value'
    }
  },
  created () {
    console.log(this._data) // { test: 'value' }
    console.log(this.test) // 'value'
  }
})

Vue实例在初始化时,会将 data 对象复制给 vue._data,然后遍历 data 对象实现对 data 的代理:get/set 操作实际上操作的是 _data 对象,之后通过 Object.defineProperty 绑定到 vue 实例上。Vue 的实现原理不是这篇文章的重点,这里就不多说了。(以上是 Vue2.x 之前的实现方式,Vue3.0 中代理使用的原理是 es6 中的 Proxy

发布 / 订阅模式

定义对象间的一种一对多的依赖关系,当一个对象的状态发生变更时,所有依赖它的对象都将得到更新。

在 Javascript 中,我们一般通过事件模型来实现 发布 / 订阅模式。我们先来看看现实中的一些发布订阅案例:

海底捞是目前中国最火的连锁火锅店,几乎每家门店就餐时间都需要排队,为了方便,海底捞提供了线上排号的服务。我们可以通过线上服务直接取号,当排队状况变更时,海底捞会推送通知给到排队的人,内容体类似于:“亲爱的客户,您好,您前面还有{n}桌等待中。”

在这个案例中,海底捞是发布者,排队的客户是订阅者,排队状态有更新时,订阅者会收到发布者的通知。在这个例子中,我们可以发现 发布 / 订阅模式 有一些显而易见的优点:

  • 排队者不需要几分钟就打个电话问排队情况(其实就是所谓的轮询
  • 排队者和店面不再耦合,有新的排队者时,只需要订阅店面的排队服务即可,两者之间不需要互相关心

其实大部分人都用过 发布 / 订阅模式 ,只是你可能没有意识到。比如浏览器中的 DOM事件:

document.body.addEventListener('click', (event) => {
  console.log(event)
}, false)

document.body.addEventListener('click', (event) => {
  console.log('2 ===> ', event)
}, false)

document.body.addEventListener('click', (event) => {
  console.log('3 ===> ', event)
}, false)

随意的添加订阅者不会影响已有的订阅者,也不会影响发布者的实现。

我们回过头来实现一下前面排队的例子:

class Store {
  constructor (name) {
    this.name = name
    this.queueSubs = []
  }
  queueListen (callback) {
    this.queueSubs.push(callback)
  }
  queueTrigger () {
    const args = arguments
    this.queueSubs.forEach(fn => {
      fn.apply(this, args)
    })
  }
}

const store = new Store('海底捞-1号')
// 订阅排队状态
store.queueListen(function (number) {
  console.log('当前排号 => ', number)
})
// 发布排队状态,当前排队10桌
store.queueTrigger(10)

上面只是简单的实现了排队服务的发布订阅,实际情况中排队的发布订阅可能需要更完善一点,比如海底捞分为大中小桌,当我排队的是小桌时,其他类型的排队状态变更不需要通知我,通过给 listen 和 trigger 添加对应的事件类型即可,具体实现就不再赘述了。

Vue 的核心功能中其实也有 发布 / 订阅模式 的身影,在 Vue 的响应式数据中,就是通过这一模式实现视图的自动更新。Vue 实例初始化时,在解析模板时,如果当前虚拟 DOM 节点依赖 data 中的数据,那么会生成一个 watcher 并将其添加到实例的 Deps 中,数据变更时会遍历 Deps 中对应数据的 watchers 执行 render 函数,从而更新视图(说好的 Vue 实现原理不是重点呢)。

装饰者模式

在不改变原有对象的基础之上,将功能附加到对象上。提供了比继承更有弹性的替代方案(扩展原有对象功能)

我们都知道,在 Javascript 中可以很方便的给某个对象添加属性和方法,但是很难在不改动函数源代码的情况下给该函数添加一些额外的功能。

比如说我们希望在 a 函数执行前打印一下日志,执行完成后也打印一下日志,要么改动 a 函数,在其前后加入日志,但是如果其他地方调用的 a 函数我们不希望打印,这方法就不可行了。为了避免影响其他地方对 a 函数的调用,我们可以使用这种方式:

function a () {
  // ...
}

function test () {
  console.log('before log')
  const result = a()
  console.log('after log')
  return result
}
test()

上面这个例子确实是满足条件的,但是也有缺陷:

  • n 个需求需要 copy 且维护 n 个函数
  • this 劫持问题

那么,有没有完美的方法解决上述问题?答案是有的,我们可以通过 AOP 来装饰函数,达到这个目的。

看看这个例子:

// 给 Function 添加 before 方法
Function.prototype.before = function (fn) {
  const that = this
  return function () {
    fn.apply(this, arguments)
    return that.apply(this, arguments)
  }
}
// after
Function.prototype.after = function (fn) {
  const that = this
  return function () {
    const res = that.apply(this, arguments)
    fn.apply(this, arguments)
    return res
  }
}

现在,我们可以在不改变原函数的情况下,给函数添加一些功能:

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

当然,这种实现方式会污染原型,如果无法接受,可以做一些调整,将原函数和新函数都当作参数传入 before 和 after 方法即可。

最后

目前 Javascript 常用的就以上五种,当然还有其他很多设计模式,比如状态模式、职责链模式等等,这里就不一一总结了。

参考文献:

  • 《Javascript设计模式与开发实践》—— 曾探
  • 维基百科
0%