常用的js设计模式

387 阅读8分钟

前言

最近看了一下《JavaScript设计模式与开发实践》,开阔了不少眼界,所以写下此文总结一下各个常用模式。

单例模式

单例模式是一种非常常见的模式,单例模式的定义是:保证一个类仅有一个实例,并提供一个可以访问它的全局访问点。

应用场景

单例模式多用于唯一弹窗,全局加载条,绑定回调等等。以下是常见使用,利用闭包保存实例,可以减少对全局环境的污染。

function singleObj() {
	this.name = 'hello'
}
const getSingleObj = (function single() {
	let instance = null
	return function() {
		if (!instance) {
			instance = new singleObj()
		}
		return instance
	}
})()

惰性单例

惰性单例的例子其实就是上面的例子,意思是在实际第一次引用时再创建,而不是在开头就已经创建好。这样做的好处就是减少页面加载的时间。

通用惰性单例

围绕着把不变和变的逻辑分离出来的原则,可以把创建行为抽离出来。

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

策略模式

策略模式也是非常常用的模式了。策略模式定义是:将每个算法都独立封装出来,将不变的部分隔离出来变成设计模式的主题,即将使用方法和实现方法分离开来。

JS中的策略模式

因为js是无类语言,所以直接写成将算法的集合写成对象。

应用场景

比如对于计算每个职位的的工资时,每个职位对应的算法都是独立而且不一样的,这时候我们就可以使用策略模式来优化我们的代码。(而不是写一堆if else来把代码都写到一块)

const calculateBonusStrategies = {
	'S': function( salary ){ 
		 return salary * 4 
	 },
	'A': function( salary ){ 
		 return salary * 3 
	 }
}

function calculateBonus(level, salary) {
	return calculateBonusStrategies[level](salary)
}

一旦使用这种写法,那么当后续我们增加新种类员工的时候我们就可以在避免修改源代码的情况下,增加我们的代码,只需要在calculateBonusStrategies中增加对应新种类员工的算法代码即可。

表单中的策略模式

利用策略模式的思想,我们可以简单的实现表单判断

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

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

Validator.prototype.add = function(dom, rules) {
  const self = this
  const triggerObj = {}
  for (let i = 0, len = rules.length; i < len; i++) {
    (function(rule) {
      const { strategy, errorMsg, trigger } = rule
      if (!triggerObj[trigger]) {
        triggerObj[trigger] = []
      }
      const strategyArray = strategy.split(':')
      const type = strategyArray.shift()
      strategyArray.push(errorMsg)
			strategyArray.unshift(dom.value)
      function validator() {
        strategyArray[0] = dom.value
        return validatorStrategies[type].apply(dom, strategyArray)
      }
      triggerObj[trigger].push(validator)
      self.cache.push(validator)
    })(rules[i])
  }
  for (const key in triggerObj) {
    const funcArray = triggerObj[key]
    dom[`on${key.toLocaleLowerCase()}`] = function() {
      for (let i = 0, len = funcArray.length; i < len; i++) {
        const errorMsg = funcArray[i]()
        if (errorMsg) {
          alert(errorMsg)
          return
        }
      }
    }
  }
}

Validator.prototype.start = function() {
  for (let index = 0, len = this.cache.length; index < len; index++) {
    const errorMsg = this.cache[index]()
    if (errorMsg) {
      return errorMsg
    }
  }
}

/*
*使用
*/

const form = document.getElementById('form')

const validateFunc = function() {
  const validator = new Validator()
  validator.add(form.userName, [
    { strategy: 'isNonEmpty', errorMsg: '不能为空', trigger: 'blur' },
    { strategy: 'isNonEmpty', errorMsg: '不能为空', trigger: 'change' }
  ])
  validator.add(form.password, [
		{ strategy: 'minLength:6', errorMsg: '密码不能少于6', trigger: 'change' }
	])
  const errorMsg = validator.start()
  return errorMsg
}

js中另一种方式的策略模式

上面的我们用了对象的方式来实现策略模式。但在js中,因为函数也是可以赋值传递的,所以我们可能把算法抽象成一个个函数,然后通过高阶函数的方式,把函数作为变量传进去来执行

var S = function(salary){ 
	return salary * 4
}
var A = function(salary){ 
	return salary * 3; 
}
var B = function(salary){ 
	return salary * 2
}
var calculateBonus = function(func, salary){ 
	return func( salary )
}
calculateBonus(S, 10000)

代理模式

代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问。代理模式的好处就是使用的是对象的代理而不是对象本身,客户对于调用对象时不用担心对象内部改变。

应用场景

下面是虚拟加载图片的例子。

const imageLoad = (function() {
  const img = document.createElement('img')
  document.body.appendChild(img)
  return {
    setImage: function(src) {
      img.src = src
    }
  }
})()

const proxyImageLoad = (function() {
  const image = new Image()
  image.onload = function() {
    imageLoad.setImage(this.src)
  }
  return {
    setImage: function(src) {
      imageLoad.setImage('loading.gif')
      image.src = src
    }
  }
})()

proxyImageLoad.setImage('hello.jpg')

对于客户来说,proxyImageLoad控制了客户对imageLoad的访问,并加入了一些额外的操作(菊花图)。而代理模式的意义就是为了让我们能够更好地遵守单一职责原则。即将两个功能,加载菊花图和加载图片拆分开来。

迭代器模式

迭代器模式是我们比较常用的东西了,其实就是Array实现的各种迭代函数。

迭代器又分外部迭代器和内部迭代器。

内部迭代器

内部迭代器指的是,迭代器不暴露迭代的过程,迭代器内部已经实现好迭代规则了。常见的有Array的each等等。

外部迭代器

外部迭代器指的是把迭代方式暴露给用户,让用户显示地请求迭代下一个元素。以下是简单的外部迭代器。

const Iterator = function(obj) {
  let current = 0
  const next = function() {
    current++
  }
  const isDone = function() {
    return current >= obj.length
  }
  const getCurrItem = function() {
    return obj[current]
  }
  return {
    next,
    isDone,
    getCurrItem
  }
}

观察者模式

观察者模式是一种一对多的依赖关系。当一个对象发生状态改变时,所有依赖于这个对象的对象都会得到改变通知。这种模式最常见的就是DOM的事件绑定。

实现步骤

要实现观察者模式,可以遵循以下步骤来实现

  • 首先定义好发布者
  • 在发布者对象里面添加一个缓存列表,存放订阅者对象
  • 给被观察者添加一个发布消息函数,当运行这个函数时,被观察者遍历整个缓存列表,并一一回调存放的观察者对象。
const event = {
  notSend: {},
  clientList: {},
  listen: function(key, fn) {
    if (!this.clientList[key]) {
      this.clientList[key] = []
    }
    const notSend = this.notSend[key]
    if (notSend) {
      fn.apply(this, notSend)
    }
    this.clientList[key].push(fn)
  },
  trigger: function() {
    const key = Array.prototype.shift.call(arguments)
    const fns = this.clientList[key] || []
    const len = fns.length
    if (len === 0) {
      if (!this.notSend[key]) {
        this.notSend[key] = []
      }
      this.notSend[key] = arguments
      return false
    }
    for (let i = 0, len = fns.length; i < len; i++) {
      const fn = fns[i]
      fn.apply(this, arguments)
    }
  },
  remove: function(key, fn) {
    const fns = this.clientList[key]
    if (!fns) {
      return false
    }
    if (!fn) {
      fns && (fns.length = 0)
    } else {
      const len = fns.length
      for (let i = 0; i < len; i++) {
        const _fn = fns[i]
        if (_fn === fn) {
          fns.splice(i, 1)
        }
      }
    }
  }
}

为了解决订阅前消息已经发送的情况,这里给观察者模式增加了一个notSend对象,用来存储没发送成功的消息数据。等订阅的时候再发送。

发布——订阅模式

发布订阅模式和观察者模式很相像,但是两者有很显著的区别。订阅模式中,发布者和订阅者是彼此互不相识的。他们之间通信是通过中间一个中转站——经纪人,来交流的。也就是说,订阅模式中,并不是松耦合,而是完全解耦。而观察者模式中,被观察者内部要维护一套观察者(Observer),即观察者和被观察者其实是一种松耦合的情况。由此看出,其实两者是完全不一样的。

命令模式

命令模式指的是通过一个command来执行某些特定事情的指令。和策略模式不同的是,在策略模式的目标往往是一致,它们只是达成同一个目的的不同手段。而命令模式中不关心策略的选取,面向的问题域也更加广泛,同时命令模式还有撤销、排队功能。在实际写法中,命令模式和策略模式不同在于多了一个command对象作为客户与算法之间的中间层来引用算法。

应用场景

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

组合模式

组合模式是将一个主体拆分成多个自体来组合构成的模式。组合模式通过对主体的一次操作便可将主体的内的子体的操作都执行一遍。需要注意的是,组合模式不是父子关系,是**HAS-A(聚合)**的关系。

实际使用

个人理解中,其实在js中当我们迭代dom树或者一些树形结构数据的时候,如果需要做同一个操作的时候(对子体操作一致性的接口),我们就可以用组合模式来进行开发。

性能优化

当我们要修改或者删除父节点上的一个子节点的时候,往往需要要再次遍历一次树才能找回父节点。如果在子节点中记录下当前父节点的对象,那将不需要再次遍历整棵树了,dom树中子元素中的parent属性便是记录其父节点对象。

模板模式

模板方法模式是一种只需使用继承就可以实现的非常简单的模式。它分为两个部分:第一个部分是抽象父类;第二个部分是具体实现的子类。利用模板模式的好处就是能够使相同逻辑抽离出来,减少重复代码。但js并没有真正的继承,所以如果用js来实现的话,可以用高阶函数的方式来实现模板模式。

亨元模式

亨元模式是一种性能优化的模式。如果对象能够复用,就尽量不创建新对象。亨元模式要求对对象的属性划分为内部状态和外部状态。可以用以下4条指引来划分外部状态和内部状态。

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

应用场景

  • 一个程序中使用了大量的相似对象
  • 由于使用了大量对象,导致内存开销很大
  • 对象的大多数状态都可以变为外部状态
  • 可以通过剥离外部后的共享对象来取代大量对象。

文件上传例子

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

Upload.prototype.delFile = function(id) {
  UploadManager.setExternalState(id, this)
  if (this.fileSize < 3000) {
    delete UploadManager.uploadDatabase[id]
    return this.dom.parentNode.removeChild(this.dom)
  }
}

const UploadFactory = (function() {
  const createUploadObj = {}
  return {
    create: function(uploadType) {
      if (createUploadObj[uploadType]) {
        return createUploadObj[uploadType]
      }
      return createUploadObj[uploadType] = new Upload(uploadType)
    }
  }
})()

const UploadManager = (function() {
  const uploadDatabase = {}
  return {
    add: function(id, uploadType, fileName, fileSize) {
      const upload = UploadFactory.create(uploadType)
      const dom = document.createElement('div')
      dom. innerHTML = `
        <span>文件名称:${fileName},文件大小${fileSize}</span>
        <button class="del-file">删除</button>
      `
      dom.querySelector('.del-file').onclick = function() {
        upload.delFile(id)
      }
      uploadDatabase[id] = { 
        fileName: fileName, 
        fileSize: fileSize, 
        dom: dom
      }
      return uploadDatabase[id]
    },
    setExternalState: function(id, obj) {
      const uploadData = uploadDatabase[id]
      for(const key in uploadData) {
        obj[key] = uploadData[key]
      }
    }
  }
})()

没有内部状态的亨元模式

没有内部状态的亨元模式其实就是单例模式。

没有外部状态的亨元模式

没有外部状态的亨元模式其实是把存放外部对象的地方变为对象池。

对象池

对象池维护一个装载空闲对象的池子,如果需要对象的时候,不是直接 new,而是转从对象池里获取。如果对象池里没有空闲对象,则创建一个新的对象,当获取出的对象完成它的职责之后, 再进入池子等待被下次获取。

通用对象池

const objectPoolFactory = function(fn) {
  const objPool = []
  return {
    create: function() {
      const obj = objPool.length === 0 ?
            fn.apply(this, arguments) : objPool.shift()
      return obj
    },
    recover: function(obj) {
      objPool.push(obj)
    }
  }
}

责任链模式

职责链模式是使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。与策略模式不同的是,策略模式中的算法是互相独立开的,而责任链模式中的算法会层层往下传递。使用责任链模式的好处就在于能把原本写一堆if else的语句拆分成一个个独立的节点步骤,但坏处就是有可能会因为责任链过长而导致性能损耗。

实际应用

axios中的interceptors是用责任链模式实现的。

中介模式

中介模式是将多对多关系的对象的通信中增加一个中介对象,将其转变为一对多关系的模式,使每个对象之间得以解耦。中介模式是最少知识原则(迪米特法则)的一种实现。当代码耦合程度很高并随项目的变化呈指数增长曲线时,就可以考虑使用中介者模式来进行解耦。

装饰者模式

装饰者模式是一种在运行时给对象动态添加职责的模式。相比继承,这种方式更加灵活。

js中的装饰者模式

利用aop技术可以很简单地为目标函数添加装饰器,并且不会影响到原来函数。

Function.prototype.before = function(beforeFn) {
  const self = this
  return function() {
    if (beforeFn.apply(this, arguments) === false) {
      return
    }
    return self.apply(this, arguments)
  }
}

Function.prototype.after = function(afterFn) {
  const self = this
  return function() {
    const ret = self.apply(this.arguments)
    afterFn.apply(this, arguments)
    return ret
  }
}

装饰者模式和代理模式很相像,都是对某一种对象的间接引用。但两种模式的设计意图和目的是不同的,装饰者模式的作用是对对象本体动态加入新功能,而代理模式是在本对象功能的目标不变的情况下,在其基础上加入一些更加智能的功能。代理模式通常只有一层代理——本体的引用,而装饰者模式经常会形成一条长长的装饰链

实际应用

下面看一个登录的例子。

const form = document.getElementById('form')
const { username, password } = form
const btn = document.getElementById('submit') 
const validate = function() {
  if (username.value === '' || password === '') {
    return false
  }
}
const handleSubmit = function() {
  const param = {
    username: username.value,
    password: password.value
  }
  ajax('http://xxx.xxxx.xxx/login', param)
}.before(validate)
btn.onclick = function() {
  handleSubmit()
}

利用装饰者模式,可以很好地将验证功能和提交功能的逻辑给分离出来。

状态模式

状态模式:允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。从这个定义我们可以这样理解:把对象的各个状态封装成统一行为不同作用的独立类,并把这些独立类委托给状态对象来进行切换改变。可以看看以下开关灯光功能的灯泡的例子。

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

OffLightState.prototype.pressed = function() {
  console.log('关灯')
  this.light.setState(this.light.onLightState)
}

OnLightState.prototype.pressed = function() {
  console.log('开灯')
  this.light.setState(this.light.offLightState)
}

function Light() {
  this.offLightState = new OffLightState(this)
  this.onLightState = new OnLightState(this)

}

Light.prototype.init = function() {
  this.currState = this.offLightState
}

Light.prototype.setState = function(state) {
  this.currState = state
}

Light.prototype.pressed = function() {
  this.currState.pressed()
}

优缺点

优点:

  • 状态模式定义了状态与行为之间的关系,并将它们封装进一个类里。就算增加新的状态也不会影响其它状态的逻辑代码。
  • 去掉了过多的条件分支。
  • Context 中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响。

缺点:

  • 因为状态类会很多,所以会增加不少对象。
  • 因为逻辑分散了,所以没法在一个地方看出整个状态机的逻辑。

状态模式和策略模式很像,但两者区别在于,策略模式的每个算法是独立开的,而状态模式中,算法的切换是依赖状态的,并不是随意可用。

适配器模式

适配器模式是一种解决两个软件实体间的接口不兼容的问题的模式。这是一种“亡羊补牢”的模式,因为没有人会在一开始就使用它。使用适配器模式的情况一般是旧接口不适配新业务并且旧接口难维护的情况。

使用场景

比如以前数据通信都用的xml格式的数据包,随着业务的和技术的变更要使用json类型的数据,那么我们在不改变业务代码的情况下,给输出xml结果的旧接口加一层xml转json的适配器就好了。亦或者数据获取地方有很多,但是各个数据格式都不一样,那么这时候就应该使用适配器来统一数据结构然后入库。

总结

各个模式都各有优缺点,但其本质都是为了让我们更好地将代码解耦和优化,避免祖传屎山。

参考