从实践出发,前端怎么利用设计模式写出更“优雅”的js代码

前言

介绍一些我在js编程中经常使用的一些设计模式,本文没有理论的设计模式的知识,每一个模式都会从实际的例子出发,说明为何要使用相应的设计模式?怎么去使用?vue

你们也不要以为设计模式很难,很高级,之因此以为“难”,只是由于纯理论知识的枯燥难懂,我会从实际例子出发,用很是接地气的方式,给你们列举一些咱们平时经常使用,好用的一些设计模式的具体实践。java

设计模式简介

简单介绍一下设计模式,指导理论一共有5个基本原则react

  • 单一功能原则
  • 开放封闭原则
  • 里式替换原则
  • 接口隔离原则
  • 依赖反转原则

23个经典的模式 这些内容看过一遍就行,不须要深刻去了解。对于基本原则,在js的编程设计中,了解“单一功能“和“开放封闭”基本就够用。对于模式上,不少的模式其实我也根本没有使用过,由于设计模式的产生初衷,是为了补充 Java 这样的静态语言的不足。许多"经典"设计模式,在编程语言的演化中,早已成为语言机制的一部分。好比,export 内建了对单例模式的支持、将内容用 function 包装一层就是工厂模式、yield 也实现了迭代器模式等等。jquery

为何要使用设计模式

设计模式的核心思想只有一个,那就是封装变化。借用修言大佬的话ios

实际开发中,不发生变化的代码能够说是不存在的。咱们能作的只有将这个变化形成的影响最小化 —— 将变与不变分离,确保变化的部分灵活、不变的部分稳定。git

这就是平时咱们常说的“健壮”的代码,而设计模式就是帮助咱们实现这个目的的工具。es6

简单工厂模式

不说理论,直接上例子。github

在王者荣耀里,根据每一个人的星级数量,都会有一个排位的等级,如今好比有三个等级,黄金,钻石,王者。它们三个有一点区别,黄金段位是全英雄匹配,钻石和王者是BP模式的匹配。王者段位能够进行巅峰赛,可是其余两个不行。如今有个需求,让你经过段位来返回一个相应的实例,并且须要符合这些区别?面试

这对于咱们来讲,也过轻松了吧。噼里啪啦几几分钟,代码就写好了。算法

class 黄金 {
  constructor() {
    this.level = '黄金'
    this.ifBP = false
    this.canJoinPeaked = false
  }
}
class 钻石 {
  constructor() {
    this.level = '钻石'
    this.ifBP = true
    this.canJoinPeaked = false
  }
}
class 王者 {
  constructor() {
    this.level = '王者'
    this.ifBP = true
    this.canJoinPeaked = true
  }
}
function Factory(level) {
  switch(level) {
    case '黄金':
      return new 黄金()
      break
    case '钻石':
      return new 钻石()
      break
    case '王者':
      return new 王者()
      break
  }
}	
复制代码

后面王者更新了,若是新增了10个新的段位,你要怎么改这个代码?仍是一个个手动添加吗?

如今让咱们来改造一下

class 段位通用类 {
  constructor(level, ifBP, canJoinPeaked) {
    this.level = level
    this.ifBP = ifBP
    this.canJoinPeaked = canJoinPeaked
  }
}
function Factory(level) {
	let ifBP, canJoinPeaked
  switch(level) {
    case '黄金':
      ifBP = false
      canJoinPeaked = false
      break
    case '钻石':
      ifBP = true
      canJoinPeaked = false
      break
    case '王者':
      ifBP = true
      canJoinPeaked = true
      break
  }
  return new 段位通用类(level, ifBP, canJoinPeaked)
}	
复制代码

这个就是简单工厂模式的具体应用,将建立对象的过程封装,咱们不须要去关心具体的内容,只要传入参数,拿到工厂给咱们的对象便可。

策略模式

王者荣耀里,咱们若是进行排位赛,会根据你的段位去匹配一块儿游戏的玩家,如今有个需求,要求写一个排位匹配函数,根据玩家当前的段位等级,来执行不一样段位的排位匹配功能?

这对于习惯了if-else的咱们来讲,也是如此简单。

class 王者帐号 {
  constructor() {}
  排位匹配(level) {
    if (level === '黄金') {
      console.log('执行黄金段位的匹配')
      // 这里只是举个例子,平时开发,这里可能会有很长一段的复杂代码逻辑
    }
    if (level === '钻石') {
      console.log('执行钻石段位的匹配')
    }
    if (level === '王者') {
      console.log('执行王者段位的匹配')
    }
  }
}
王者帐号.排位匹配('黄金')
复制代码

代码写完了,功能实现了,运行起来的确没问题。可是其实这里存在多个隐患。

  • 没有遵循单一功能原则,这里在一个函数里处理了多种状况的逻辑,万一其中有一个出了bug,后续的逻辑就都没法运行了。并且功能都放在一块儿,功能的抽离复用变得很困难。
  • 没有遵循开放封闭原则(只新建,不修改),若是后续又多了一个段位,只能继续经过if去判断,致使每次新增都要对这个排位匹配函数进行测试回归,增长工做量。

如今咱们来对其进行改造,首先遵循单一功能原则,把每一项的功能逻辑抽离出来。

function 黄金匹配() {
  console.log('执行黄金段位的匹配')
}
function 钻石匹配() {
  console.log('执行钻石段位的匹配')
}
function 王者匹配() {
  console.log('执行王者段位的匹配')
}
class 王者帐号 {
  constructor() {}
  排位匹配(level) {
    if (level === '黄金') {
      黄金匹配()
    }
    if (level === '钻石') {
      钻石匹配()
    }
    if (level === '王者') {
      王者匹配()
    }
  }
}
王者帐号.排位匹配('黄金')
复制代码

接下来,咱们来遵循开放封闭原则(只新建,不修改),封装变化

const 匹配逻辑 = {
  黄金() {
    console.log('执行黄金段位的匹配')
  },
  钻石() {
    console.log('执行钻石段位的匹配')
  },
  王者() {
    console.log('执行王者段位的匹配')
  },
}
class 王者帐号 {
  constructor() {}
  排位匹配(level) {
    匹配逻辑[level]()
  }
}
王者帐号.排位匹配('黄金')
复制代码

改动以后,后续无论是新增仍是删除,咱们都不须要去修改排位匹配这个函数,只用对匹配逻辑进行修改就好。

策略模式的核心就是把变化算法提取封装好,并是让其可替换。适合表单验证、或者存在大量 if-else 的场景使用。

状态模式

状态模式跟策略模式其实没啥本质上差异,可是多了一个状态的概念,咱们仍是刚上一个排位匹配的代码来示例。

王者里有个机制,信誉分,信誉分太低系统会禁止玩家排位功能。我这边稍做修改来当作例子,王者帐号这个类里有一个信誉分的参数,信誉分达到80分,黄金段位能够排位,信誉分达到90分钻石段位才能够排位,信誉分达到100分,王者段位才能够排位。如今要求实现这个逻辑?

const 匹配逻辑 = {
  黄金() {
    console.log('执行黄金段位的匹配')
  },
  钻石() {
    console.log('执行钻石段位的匹配')
  },
  王者() {
    console.log('执行王者段位的匹配')
  },
}
class 王者帐号 {
  constructor() {
    this.creditPoints = 80
    //80黄金,90钻石,100王者
  }
  排位匹配(level) {
    匹配逻辑[level]()
  }
}
复制代码

经过上面的练习,如今你们应该想到,要把这单个的功能在到匹配逻辑里的各项里去,这样后续若是有段位的新增和删除,或者信誉分逻辑的修改,咱们都不须要去修改排位匹配这个函数,能够减小测试的工做量。

可是这个信誉分的状态怎么拿到呢?如今咱们来使用状态模式的思想来改造一下。

class 王者帐号 {
  constructor() {
    this.creditPoints = 80
    //80黄金,90钻石,100王者
  }
  匹配逻辑 = {
    that: this,
    黄金() {
      if (this.that.creditPoints >= 80) {
        console.log('执行黄金段位的匹配')
      }
    },
    钻石() {
      if (this.that.creditPoints >= 90) {
        console.log('执行钻石段位的匹配')
      }
    },
    王者() {
      if (this.that.creditPoints >= 100) {
        console.log('执行王者段位的匹配')
      }
    }
  }
  排位匹配(level) {
    匹配逻辑[level]()
  }
}
复制代码

是否是很简单?

状态模式主要解决的是当控制一个对象状态的条件表达式过于复杂时的状况。把状态的判断逻辑转移到表示不一样状态的一系列类中,能够把复杂的判断逻辑简化。

单例模式

要求实现一个全局惟一的Modal弹框

这是一道很是经典的单例模式的例子,也是比较常见的面试题。直接上答案了。

const SingleModal = (function() {
  let modal
  // 利用闭包实现单例
  return function() {
    if(!modal) {
        modal = document.createElement('div')
        modal.innerHTML = '全局惟一的Modal'
        modal.style.display = 'none'
        document.body.appendChild(modal)
    }
    return modal
  }
})()
// 建立和显示
const modal = SingleModal()
modal.style.display = 'block'

// 隐藏
const modal = SingleModal()
modal.style.display = 'none'
复制代码

后续每次调SingleModal()返回的都是第一次运行时建立的那个Modal弹框。也可使用类的方式实现单例。

class SingleModal{
  // 这里是定义了一个静态方法,也能够写在类的构造函数里。你们能够本身试着写一下
  static createModal() {
      if (!SingleModal.instance) {
        let modal
        modal = document.createElement('div')
        modal.innerHTML = '全局惟一的Modal'
        modal.style.display = 'none'
        document.body.appendChild(modal)
        SingleModal.instance = modal
      }
      return SingleModal.instance
  }
}
const modal1 = SingleModal.createModal()
const modal2 = SingleModal.createModal()

modal1 === modal2 // true
复制代码

单例模式的目的就是保障无论多少次的调用,返回的都是同一个实例。

vuex就是典型的单例实现,全部子组件访问到的store其实都是根组件的那个store实例,修改的都是同一个由vuex建立出来的vue实例。

装饰器模式

王者荣耀里,基本每一个英雄都有好几套皮肤,酷炫的皮肤带来了更佳的游戏体验。拿我最喜欢的英雄李白为了例子,我如今假设出了一个神级皮肤,换上这套皮肤以后,李白会再多出一个技能,这个技能的效果就是“嘲讽”,并且没有cd,无限的嘲讽攻击,让对手失去理智。要求实现这个皮肤的效果?

先来一个李白实例,本来有三个技能。

class 李白 {
  技能1() {
    console.log('将进酒')
  }
  技能2() {
    console.log('神来之笔')
  }
  技能3() {
    console.log('青莲剑歌')
  }
}
复制代码

如今要求根据是否使用了这个皮肤来判断,是否要添加“嘲讽”这个技能。怎么写?

很轻松嘛,根据皮肤状态来判断一下就ok嘛。

class 李白 {
  constructor(skin) {
    if (skin === '神级皮肤') {
      this.嘲讽 = () => {
        console.log('释放嘲讽')
      }
    }
  }
  技能1() {
    console.log('将进酒')
  }
  技能2() {
    console.log('神来之笔')
  }
  技能3() {
    console.log('青莲剑歌')
  }
}
复制代码

首先,这个实现,违背了开放封闭原则,咱们但愿可以遵照“只新增,不修改”的原则。其次,“嘲讽”这种做为普适性很强的行为极可能会被加到其余的英雄上面去,好比后续需求变动了,全部的英雄都会出一个神级皮肤,都须要有一个嘲讽技能怎么办?全部的英雄一个个去加吗?

这时候,咱们可使用装饰器的思想去改造。

class 李白 {
  技能1() {
    console.log('将进酒')
  }
  技能2() {
    console.log('神来之笔')
  }
  技能3() {
    console.log('青莲剑歌')
  }
}
class 嘲讽技能装饰器 {
  constructor(hero) {
    this.hero = hero
  }
  技能1() {
    this.hero.技能1()
  }
  技能2() {
    this.hero.技能2()
  }
  技能3() {
    this.hero.技能3()
  }
  嘲讽() {
    console.log('释放嘲讽')
  }
}
let hero = new 李白()
if (skin === '神级皮肤') {
  hero = new 嘲讽技能装饰器(hero)
  hero.嘲讽()
}
复制代码

这样,咱们没有对李白这个实例进行任何的修改,只是新增了一个装饰器,并且这个装饰品还能够复用于全部其余英雄的实例上。

装饰器的核心思想就是不对原先的功能有任何的影响,只使其具有新的能力。

es7中,js能够经过@语法糖对类或者类中的函数方法添加装饰器。这块内容你们有兴趣的话本身去了解一下,篇幅限制,这里就不细讲了。给你们推荐一个优秀的第三方装饰器库 core-decorators

装饰器的应用很普遍,再讲一些其余例子。

// 对于Math.abs来讲,add也算一个装饰器
const add = (a, b, abs) => {
    return abs(x) + abs(y);
}
const num = add(1, -1, Math.abs);
复制代码
// react里很常见的高阶组件,也是装饰器的一个应用
const withDoSomthing = (component) => {
  const NewComponent = (props) =>{
    return <component {...props} />
  }
  return NewComponent
}
复制代码

适配器模式

适配器主要是为了解决兼容性的问题,帮助咱们抹平差别。

举个例子,我用的是苹果手机,充电口是Lightning接口。今天我一不当心,把个人苹果充电线弄断了。手机快没电了,可我这局王者才开始,这一局是进阶赛,赢了就上王者了。可我家里只有一根安卓的type-c充电线,还有一根usb的充电线。我看着1%的电量感慨到,若是能有一个转换头,能把type-c以及usb的接口转换成苹果的Lightning接口那该有多好。

这个转换头就是适配器。这边再举两个实际的例子给你们参考。

jquery的each遍历

你们对forEach应该特别熟悉,咱们在遍历数组的时候常常会用到,好比

let arr = ['a', 'b', 'c']
arr.forEach(item => {
  console.log(item)
})
复制代码

可是若是咱们换一个对象

const divList = document.getElementsByTagName('div')
for (let i = 0;i < divList.length;i ++) {
  console.log(divList[i])
}
// 正常
document.getElementsByTagName('div').forEach(item => {
  console.log(item)
})
// Uncaught TypeError: document.getElementsByTagName(...).forEach is not a function
复制代码

咱们会发现,for方法能够正常打印全部的div标签。可是forEach方法会报错,为何呢?

由于这里的divList是一个类数组对象,它本质上是一个对象,只是它的key是0,1,2这种格式,并且存在length属性。既然它不是数组,咱们固然不能用forEach来对她进行遍历。

但若是咱们使用jqueryeach方法。

const arr = ['a', 'b', 'c']
const divList = document.getElementsByTagName('div')
$.each(arr, function (index, item) {
  console.log(item)
})
// 正常遍历
$.each(divList, function (index, item) {
  console.log(item)
})
// 正常遍历
复制代码

咱们发现对于这两种类型,均可以进行遍历,这是由于jqueryeach内部已经帮咱们抹平了差别,我可使用一样的方法来读取不一样类型的列表数据。这就是适配器的典型表现。

axios

axios的不一样配置方式也是适配器的一种表现形式。

axios({
   url: '/post',
   method: 'post',
   data: {
     msg: 'hello'
   }
 })
 axios('/post', {
   method: 'post',
   data: {
     msg: 'hello'
   }
 })
 axios.request({
   url: '/post',
   method: 'post',
   data: {
     msg: 'hello'
   }
 })
 axios.post('/post', { msg: 'hello' })
复制代码

上面4种配置方式,均可以实现相同的接口调用,不愧是axios啊。

代理模式

代理模式在平时的开发中也是应用很是普遍,并且代理模式的理念可以带来很是直接的性能提高,很是实用。

事件代理

利用点击事件的冒泡机制实现的事件代理,这个太基础了,不细讲了,略过。

缓存代理

把一些计算频繁的模块内容存下来,等到下次用到了,直接读取,再也不二次计算,看几个具体例子吧。

// 最多见,最简单的缓存代理
for (let i = 0; i < document.getElementsByTagName('div').length;i ++) {
  console.log(document.getElementsByTagName('div')[i])
}
// 使用缓存代理
const divList = document.getElementsByTagName('div')
for (let i = 0; i < divList.length;i ++) {
  console.log(divList[i])
}

// 对一些须要遍历对象深层次数据也是同理
const obj = { child: { child: { child: [1,2,3,4,5] } } }
const childList = obj.child.child.child
for (let i = 0; i < childList.length;i ++) {
  console.log(childList[i])
}
复制代码

进阶版的缓存代理,取自修言大佬的JavaScript 设计模式核⼼原理与应⽤实践小册

// 计算全部参数之和
const addAll = function() {
    let result = 0
    const len = arguments.length
    for(let i = 0; i < len; i++) {
        result += arguments[i]
    }
    return result
}
// 为求和方法建立代理
const proxyAddAll = (function(){
    // 求和结果的缓存池
    const resultCache = {}
    return function() {
        // 将入参转化为一个惟一的入参字符串
        const args = Array.prototype.join.call(arguments, ',')
        
        // 检查本次入参是否有对应的计算结果
        if(args in resultCache) {
            // 若是有,则返回缓存池里现成的结果
            return resultCache[args]
        }
        return resultCache[args] = addAll(...arguments)
    }
})()
复制代码

缓存代理能够减小二次计算,提升性能,真是太实用了。

vue在生成子组件的时候,就是使用了缓存代理,在第一次生成子组件以后,后面若是须要再次生成该子组件,vue会从缓存当中返回子组件实例,避免了组件生成逻辑的从新计算。

拦截代理

拦截代理其实在es6以前其实没啥很特别的表现形式,具体的形式,其实就只是一些判断而已。

function 吃饭() {
  console.log('吃饭')
}
// 这个方法其实就是拦截代理
function 我要不要吃饭(status) {
  if (status === '我饿了') {
    吃饭()
  }
}
function 午餐时间到了(status) {
  我要不要吃饭()
}
复制代码

es6以后,咱们有一个新的拦截器的方法,Proxy

这边举一个最多见的setget的例子。

let myMessage = { name: "zouwowo", age: 27, sex: '男' }
// 添加Proxy拦截
let message = new Proxy(myMessage, {
  get(target, key) {
    if (key === 'age') {
      // 只要获取个人age,永远都是18岁
      return 18
    } else {
      return target[key]
    }
  },
  set(target, key, value) {
    if (key === 'sex') {
      // 我是男的,怎么改,都改不了性别
      target[key] = '男'
    } else {
      target[key] = value
    }
  }
});
console.log(message.age) // 无论myMessage变量里的age是多少,永远返回的是18岁
message.sex = '女' // myMessage里的sex不会被修改
复制代码

Proxy有10多种监听拦截的方法,有兴趣的同窗能够去了解学习一下。vue3的数据监听也从Object.defineProperty方法改到了Proxy,解决了以前新增的深度数据,部分数组修改方法没法监听的问题,足以见其的强大。

观察者模式

小明昨天玩王者荣誉,被对面有神级皮肤的李白疯狂嘲讽,一整场下来,被李白杀了10屡次,“一群菜鸡队友,否则确定吊打这个XX李白。”小明气不过,加了李白好友,约定组个队再打一局。小明找了本身好友里段位最高的四我的,小王、小者、小荣、小耀。五我的一块儿拉了一个微信群,小明说:“你们稍等一下子,等要开打了,叫大家”。四我的各自忙本身的事情去了,而后等到晚上9点,小明在群里一吼:“兄弟们上号!”。四人收到了消息,各自上号。最终小明依然经历了一次边被嘲讽,边被虐杀的游戏体验。

上面这个例子,就是一个典型的观察者模式。

在上述的过程当中,发布者只有一个——小明,可是观察者有多个,小王、小者、小荣和小耀。发布者发布事件,全部观察者都能经过微信群观察到发布者的指令,而后执行各自的任务(各自上号)。

咱们来整理一下发布者观察者各自都须要实现什么功能。

发布者须要两个功能

  1. 建立微信群(添加观察者)
  2. 通知上号(发布事件)

观察者须要两个功能

  1. 等待群主发布通知上号(接受通知)
  2. 各自上号(执行各自的任务)

如今咱们来实现这个最简单的观察者模式

class 发布者 {
  constructor() {
    this.observers = []
  }
  // 添加观察者
  addObserver(observer) {
    this.observers.push(observer)
  }
  // 发布事件,通知全部的观察者
  notify() {
    this.observerList.forEach(observer => observer.update())
  }
}
class 观察者 {
  constructor(work) {
    this.work = work
  }
  update() {
      console.log(this.work)
  }
}
const 小明 = new 发布者()
const 小王 = new 观察者('辅助')
const 小者 = new 观察者('打野')
const 小荣 = new 观察者('中单')
const 小耀 = new 观察者('上单')
// 小明建立微信群,拉人
小明.addObserver(小王)
小明.addObserver(小者)
小明.addObserver(小荣)
小明.addObserver(小耀)
// 小明通知群里的全部人上号,群里的人,各自完成本身的任务
小明.notify()
复制代码

观察者模式的核心思想就是这种一对多的关系,当发布者发布事件,全部的观察者都会自动完成更新。

vue的响应式依赖实现的核心,就是Dep类,Watch类和Object.defineProperty这三者实现的观察者模式。

发布订阅模式

发布订阅模式实现的也是这种事件的发布和订阅功能。这个比较好理解,直接看代码吧。

class EventBus {
  constructor() {
    // 存放全部的事件
    this.events = {}
  }
  // 发布事件
  subscribe(event, fn) {
    if ( !this.events[event] ) {
        this.events[event] = []
    }
    // 将事件函数放入该事件名的数组里
    this.events[event].push(fn)
  }
  // 订阅事件
  publish(event, ...args) {
    if (this.events[event] ) {
      // 调用该事件名下的全部事件
      this.events[event].forEach( fn => fn(...args) )
    }
  }
  // 删除事件名下某个事件
  unsubscribe(event, fn) {
    if (this.events[event]) {
      const targetIndex = this.events[event].findIndex(item => item === fn) 
      if (targetIndex !== -1) {
        this.events[event].splice(targetIndex, 1)
      }
      // 该事件名下无事件时直接删除该订阅事件
      if (this.events[event].length === 0) {
        delete this.events[event]
      }
    }
  }
  // 删除某个事件名下的全部事件
  unsubscribeAll(event) {
    if (this.events[event]) {
      delete this.events[event]
    }
  }
}
复制代码

具体使用

const event = new EventBus()
event.subscribe('aaa', ()=> console.log('我订阅了aaa事件'))
event.subscribe('aaa', ()=> console.log('我又订阅了aaa事件'))
event.publish('aaa')
// 打印: 我订阅了aaa事件
// 打印: 我又订阅了aaa事件
复制代码

相比较可观察者模式发布订阅模式除了发布者和订阅者以外,多了一个事件中心。发布者和订阅者之间没有任何的关联,二者只能经过事件中心去进行通讯。

vue内部也实现了一个发布订阅模式$on,$emit就是对应的发布者和订阅者。

写在最后

各个设计模式并非完成分离的,它们都是相辅相成,能够互相套用的。好比策略模式,只要适合,能够用在其它的设计模式里。

这篇文章不是让你们强行套用设计模式,咱们须要记住的不是这一个个设计模式的名字,也不是为了在看一些别人写的优秀代码的时候必定要分辨出这是使用了哪一个设计模式。设计模式只是手段,帮助咱们写出"优雅"代码的手段,咱们只须要记住这些设计模式的核心思想。君子善假于物,可是不能被“物”所束缚,并且千万要避免过分设计。

设计是一个按部就班的过程,是从不断的试错当中来的,前期再完美的设计并不能知足中后期大量的需求变动,产品的一个需求,可能就能把你以前完美的设计打破,因此不要期望一下把全部细节都设计出来,边写边重构才是咱们项目开发中的一个好习惯。

参考文章

修言大佬 javaScript设计模式核心原理与应用实践

感谢

感谢你们的阅读,若是以为不错的话,帮忙点个赞,给咱一点支持,谢谢!