前端常用设计模式分享

5,155 阅读20分钟

前言

本篇文章由本人阅读《JavaScript设计模式与开发实践-曾探》这本书的感受所写,感谢作者增探写出这么好的一本书,受益匪浅,如有理解错误,欢迎网友鞭策,会及时改进!

适合阅读人群

虽然社区上关于设计模式的文章数不胜数,但是我还是想以自己的理解,对从未使用过设计模式的小白讲解一些内容,在这篇文章里我将通过几个前端常用的设计模式讲解,能让你对设计模式能有一些浅显的概念,以及能够让你了解这些设计模式具体的应用,让你在阅读和理解更高阶的设计模式文章上有一些帮助!但是JavaScript基础薄弱的同学可能读起来会很吃力,以及对ES6 class语法不熟悉的同学,所以确保你有一些基础在阅读可能理解会好些~

何为设计模式?&&为什么要使用?

历史就不谈了,大家可以百度~~简单点说,就是在不同业务情况下,要如何去解决问题的一种方案,让业务代码变得灵活,增强复用性,可维护性,增强业务代码面对不同场景的适应能力!可以说设计模式是我们初级前端走向中高级前端不能少走的一步!

设计模式原则表(简)

开闭原则对拓展开放,对修改关闭
里氏替换原则不要破坏继承体系
合成复用原则少用继承,多用合成关系实现
依赖倒转原则面向接口编程
迪米特法则降低代码耦合度
单一职责原则类的职责要单一
接口隔离原则设计接口的时候要精简单一

设计模式类型

设计模式也是细分了几种类型的,这几种类型之间区别也是有所不同,简单给大家介绍一下核心区别(标注了星号的为本文介绍的模式)

创造型

该模式处理的是用于创建对象的各种机制,这种模式着眼于优化的或更可控的对象创建机制;

包含以下但不限于这几种模式:

  • 工厂模式
  • 抽象工厂
  • 建造者
  • 原型
  • 单例模式(*)

结构型

这个类型的设计模所考虑的是对象的组成和对象之间的关系,假如对象发生了重大改变,对当前对象操作影响降至最低

包含以下但不限于这几种模式:

  • 适配器模式
  • 桥接模式
  • 装饰器模式
  • 外观模式
  • 享元模式
  • 代理模式

行为型

该模式关注的是对象之间的依赖关系以及通信

包含以下但不限于这几种模式:

  • 解释器
  • 模板方法
  • 责任链
  • 命令模式(*)
  • 迭代器
  • 中介者
  • 备忘录
  • 观察者模式(*)
  • 状态
  • 策略模式(*)
  • 访问者

四种前端常用设计模式

一,单例模式

核心概念

单例模式的定义是,保证一个类仅有一个实例,并且要提供访问他的全局api

单例模式在前端是一种很常见的模式,一些对象我们往往就只需要一个,如果你使用过VueX,React-redux等框架全局状态管理工具进行项目开发,你不难发现,这类工具库也是运用了单例模式的特性,用途相当广泛,要使用JavaScript实现一个标准的单例很简单,就是使用一个变量作为标识来判断当前是否已经创建过对象,如果没有就创建,如果已经创建则返回之前创建过的对象

基于核心概念确定单例模式功能

  • 确保一个类只有一个实例
  • 提供全局访问的api
简单实现代码(class语法风格)
class Singleton {
  constructor (name) {
    this.name = name
  }
  // 静态方法
  static getInstance (name) {
    if (!this.instance) {
      this.instance = new Singleton(name)
    }
    return this.instance
  }
}
let a = Singleton.getInstance('a1')
let b = Singleton.getInstance('b2')
console.log(a == b)
简单代码实现(闭包函数风格)

为了避免有些同学看不太懂class语法,用JavaScript原生函数实现一次

const Singleton = function (name) {
  this.name = name
}
// 利用自执行函数产生闭包
Singleton.getInstance = (function () {
  var instance
  return function (name) {
    if (!instance) {
      return (instance = new Singleton(name))
    }
    return instance
  }
})()
let a = Singleton.getInstance('a1')
let b = Singleton.getInstance('b2')
console.log(a===b) // true

我们获取单例对象是通过了Singleton.getInstance来获取的,这种方式比较简单,也满足功能,但是有一点是,这个例子我们没有用到new这个操作符,也就意义不大,如果我不告诉你这个类是一个单例类你可能也不会太会去使用这个类,所以我们要想办法让这个类变得"透明"

透明单例模式
  • 能直接new一个对象
  • 并且这个对象不能new多次,保持唯一性
var instance
function Singleton (name) {
  if (!instance) {
    return (instance = this)
  }
  return instance
}
let a = new Singleton('a1')
let b = new Singleton('b1')
console.log(a===b); // true

书本中的透明单例是由闭包所完成,但其自执行函数嵌套对JavaScript基础不好的同学极其难以阅读,所以稍作改进让大伙更直观的看到透明单例的实现,但这其实这是一种很糟糕的方式,会在后两节讲到!

上述代码有一个问题,Singleton从定义开始就是一个单例类,假如我们有很多实例需要单例特性,那么像Singleton类会写无数多个同样相同的逻辑,而且基于上面的逻辑,我们会产出很多类似命名的变量,造成变量污染,我们迫切需要一个代理来为我们创建单例特性

使用代理实现单例模式
  • 代理只专注创建单例
  • 让Singleton变成普通类
  • 通过代理让Singleton普通类拥有单例特性
// 定义代理
var CreateSingleton = (function () {
    let instance;
    // 接收一个普通类用于创建单例特性
    return function(Singleton,name) {
        if(!instance) {
            return instance = new Singleton(name)
        }
        return instance
    }
})()

var Singleton = function(name) {
    this.name = name
}

let a = new CreateSingleton(Singleton,'a')
let b = new CreateSingleton(Singleton, 'b')
console.log(a===b); // true

现在类和类之间的职责已经被剥离,符合设计模式原则单一原则~

JavaScript中的单例模式

前面的几种实现方式,他们更多接近的传统面向对象语言的实现,对于JavaScript这种无类语言来说有点穿棉衣洗澡,因为传统面向对象语言单例对象从"类"中创建而来,而我们天生拥有及简的对象创建方式,大可不必模仿强类型语言去实现单例,对没错!我们只需要直接创建对象就是单例模式,只要做好以下两点

  • 保证创建的对象是唯一
  • 并且提供方法给全局使用

小伙伴可能猜到了,能提供给全局访问的是不是只有全局变量了,是的没错

var a = {}

这段代码声明一个全局a对象,这时候的a确实是独一无二的,但是在大型项目中,多人参与项目开发需要单例特性,如果统统采用这种方式声明,那么必然会造成命名空间污染,JavaScript中的变量很容易被覆盖,JavaScript的作者都说全局变量是一个糟糕的特性,作为普通开发者的我们,我们其实有必要去减少全局变量污染问题!

如何避免变量污染呢?

1.使用命名空间

let MyApp = {
    a:function(){
        console.log('a')
    },
    b:function(){
        console.log('b')
    }
}

能看到a变量已经减少和全局作用域打交道的机会

2.使用闭包特性+命名空间实现变量私有化

let MyApp2 = (function () {
  let _name = 'sven',
    _age = 18
  return {
    getUserInfo () {
      return _name + '-' + _age
    }
  }
})()

MyApp2._name = 'sb' // 尝试修改
console.log(MyApp2.getUserInfo()) // sven-18  没修改成功

现在外界是真的访问不到这两个变量了,成功避免全局污染~~

惰性单例

惰性单例才是单例模式的重点!它所指的是,在需要的时候才创建实例对象;这模式在真实开发极其有用!

我们来模拟一个场景,我们正在开发一个网站,网站类型是一个视频网站,网站有个登录按钮,点击登录会弹出一个登录框进行登录,你现在可能已经联想到,这个登录框一定是页面唯一的一个dom节点,一个页面存在两个登录框是不存在的!

如果要实现这种效果第一种解决方案就是在页面加载的时候就已经创建好dom节点,并且设置样式为display为none,当点击登录时修改为block显示;

这种解决方式有一个问题,我作为普通用户没vip我可能都不会去点这个登录,假如用户一点进来你就开始创建这个dom节点,那么可能就会浪费一些性能;

于是我们着手修改了一下代码逻辑,只有当用户点击时才会创建

var createDiv = function () {
  var div = document.createElement('div')
  div.innerHTML = '我是登录窗口'
  div.style.display = 'none'
  document.body.appendChild(div)
  return div
}
button.addEventListener('click', function () {
  let div = createDiv()
  div.style.display = 'block'
})

这样我们达成了惰性的特征,及需要的时候才进行创建,但是失去了单例效果,频繁的创建删除dom节点也是不合理的地方!我们再结合之前学过的单例特性运用到createDiv函数上进行修改

var createDiv = (function () {
  var instance
  return function () {
    if (!instance) {
      var div = document.createElement('div')
      div.innerHTML = '我是登录窗口'
      div.style.display = 'none'
      document.body.appendChild(div)
      return instance = div
    }
    return instance
  }
})()

现在我们得到了完整的惰性加单例,但是!!!!这种函数行为做法其实又是违背单一职责的,假如我们要在页面上创建一个单例iframe标签,是不是又得复制粘贴createDiv函数的内容?利用闭包在进行变量判断返回我们的单例这个过程其实不变的,这个过程完全可以剥离开来;最终我们理想代码如下

// 剥离的惰性单例函数
var getSingle = function (fn) {
  var result
  return function () {
    return result || (result = fn.apply(this, arguments))
  }
}

var createIframe = function () {
  let iframe = document.createElement('iframe')
  iframe.style.display = 'none'
  document.body.appendChild(iframe)
  return iframe
}

var createDiv = function () {
  let div = document.createElement('div')
  div.innerHTML = '我是登录窗口'
  div.style.display = 'none'
  document.body.appendChild(div)
  return div
}

let createDivSingle = getSingle(createDiv)
createDivSingle().style.display = 'block'
createDivSingle().style.display = 'block'
createDivSingle().style.display = 'block'
createDivSingle().style.display = 'block'
// 我们调用了好几次页面上依旧只有一个登陆窗口

小结

单例模式可以说在前端范围你不可能遇不上,特别是单例惰性模式,在适合的时候才创建对象,并且只创建唯一的一个,如果创建对象和管理创建单例职责分布在两个不同的方法当中,解耦性的加持会让这个模式威力大大增加,这是能提高性能的一个突破口,Vue,React的路由懒加载的实现都是有着单例惰性思想在里边,把单例模式的一些想法学好你就能改动你现有大部分代码。

二,观察者模式

核心概念

这个模式也叫发布订阅模式,他的核心概念就是当一个对象状态发生变更时,所有依赖于它的对象都会得到通知。

试想一下,一个杂志出版社,会有很多人来通过各种渠道订阅相关内容,当出版社出版新内容,出版社就会通知所有订阅者;在这个场景里,出版社就是作为了发布者,而人们则作为了订阅者。观察模式通常把订阅者称之为观察者observer,观察者的目标对象发布方subject,subject可以拥有多个observer,一旦subject的状态发生改变了,所有监听了subject的observer都要得到通知,执行相应的业务,让subject和observer状态保持同步。

基于核心概念我们可以确定一些Subject和Observer的功能

Subject:

  • 能够被多个observer监听
  • 给observer提供注册和退订
  • 当自身状态发生变化时,对所有监听的观察者们发布"信息"

Observer:

  • 给Subject提供自己对应的更新方法,通常这个方法应该由Subject对象来进行调用,因为状态主体是在Subject那边,可以拿到保持同步的参数;

那么作为前端的我们可以具体应用在哪些案例上呢(开始编一些不切实际的例子了)

假设我们在开发一个网站的首页,这个首页有header头部,nav导航,消息列表等模块,这些模块都是需要登录之后获取用户信息进行渲染,例如header要显示用户头像和用户名字等等

通常情况下我们的代码一般是这个样子

//登录函数
$.ajax({
    url:'/api/login',
    data: {
		username,
        password
    },
    succees:function(data) {
        if(data.code === 200) {
        // 执行各个模块的函数
        header.setAvatar(data.res.userImage) // 设置顶部用户个人头像
        header.setUserName(data.res.userName) // 设置顶部用户姓名
        nav.setAvatar(data.res.navImage) // 设置导航模块图像...
   	 }
    }
})

// header模块
var header = (function(){
    var setAvatar = function(data) {
        console.log('设置用户头像'+data)
    }
    var setUserName = function (data) {
        console.log('设置用户名'+data)
    }
    return {
        setAvatar,
        setUserName
    }
})()

// nav模块
var header = (function(){
    var setAvatar = function(data) {
        console.log('设置导航图像'+data)
    }
    return {
        setAvatar
    }
})()


其实我们发现上述代码其实是违反了设计模式开闭原则的,登录函数和各个模块耦合性太强,假如这个时候需求下来一个增加收获地址列表,并且要求在登录成功后刷新收获列表地址,我们代码可能就会在上面基础上增加如下代码

//登录函数
$.ajax({
    url:'/api/login',
    data: {
		username,
        password
    },
    succees:function(data) {
        if(data.code === 200) {
        // 执行各个模块的函数
        header.setAvatar(data.res.userImage) // 设置顶部用户个人头像
        header.setUserName(data.res.userName) // 设置顶部用户姓名
        nav.setAvatar(data.res.navImage) // 设置导航模块图像...
        address.refresh() // 增加了这一行
   	   }
    }
})

// 同事B开发的收货地址列表模块
var address = (function(){
    var refresh = function (data) {
        console.log('刷新收获地址列表'+data)
    }
    return {
        refresh
    }
})()

现在我们是作为了登录函数模块的开发者,而且在实际开发当中,各个模块很有可能是由不同人员来进行开发,谁增加了模块谁修改了模块都要在登录函数进行修改,打个比方,收获地址模块是由同事B开发的,他把模块开发好了,让你在登录函数里去调用它模块的方法,这个时候你还得加班去看他的方法名然后再去修改自己的登录函数,显然这种需求一次两次还好,次数多起来就会觉得很疲惫了。

这个例子就能很好运用观察模式进行代码解耦,把登录成功函数作为发布者,各个模块作为观察者,当登录成功之后发布信息让各个模块进行相应的业务操作,改动的代码如下

// 登录函数
var login = (function () {
  var cache = {}
  // 监听函数
  var listen = function (type, callback) {
    if (!cache[type]) {
      cache[type] = [] // 如果之前没订阅过就要给一个缓存列表
    }
    cache[type].push(callback)
  }
  // 发布信息
  var trigger = function (type, data) {
    cache[type].map(fn => {
      fn.call(this, data) // 给各个模块传递参数
    })
  }
  return {
    listen,
    trigger
  }
})()

// header模块
var header = (function (){
    // 订阅登录成功的消息
    login.listen('loginType',function(data){
        // 会在登录成功后执行
        header.setAvatar(data)
        header.setUserName(data)
    })
    var setAvatar = function (data) {
        console.log('设置用户头像'+data)
    }
    var setUserName = function (data) {
        console.log('设置用户名'+data)
    }
    return {
        setAvatar,
        setUserName
    }
})()
// ......


// 发送ajax请求登录
$.ajax({
    url:'api/login',
    success:function(data) {
        if(data.code === 200) {
            login.trigger('loginType',data) // 成功登录发布信息
        }
    }
})


可以看到,这些作为登录函数开发者的我们已经不在关心之后要做什么,也不用去了解各个模块有什么方法以及内部细节,模块开发者现在只需要专注于业务的开发以及订阅相应的事件就能完成需求!

小结

其实观察者模式对于前段来说应用很广,一个普通dom绑定一个事件其实也算观察者模式,区别就在于你能否在这模式核心基础上考虑到更多的东西,而不单单仅仅的发布监听,Vue,React等等框架都大范围使用了这种设计模式,优点很明显,一是时间解耦,二是对象之间的解耦,例子就能很好体现,能帮助我们完成很轻松的解耦代码编写,但是无论是啥模式都会有缺点,观察模式如果过度使用,会造成对象代码关联弱化,导致程序代码难以理解于跟踪维护。

三,命令模式

核心概念

这个模式的核心概念相当简单,用于消除调用者和接收者之间的耦合关系,并且,执行命令过程当中可以进行留痕操作!

还是举个场景例子,我去饭店吃饭,真正给我做菜的人是厨师,可是我总不能直接跑去后厨直接和厨师面对面下单吧,跑去后厨我也不知道哪个人可以给我炒菜QAQ,这个时候服务员出来了,我们可以通过服务员告诉他我想吃什么菜,让他去帮我找厨师给我炒菜,且在下单过程当中,我突然不想吃这个菜了,厨师这会还没开始炒,那么就可以通过服务员进行订单的撤销,这一套流程下来调用者和接收者的解耦工作就是由服务员来完成的,留痕操作则是由订单完成。

我们来确定一下一个命令模式的基础功能

  • 消除调用者和接受者的耦合
  • 命令可以被记录进行回撤

下面开始演示命令模式,这里我们用一个动画来进行展示

直接上代码会更清楚结构

<div id="app">
<!-- 菜单列表 -->
	<div class="commands-container">
        <h3>菜单列表</h3>
		<ul class="commands">
			<li cmd="xhscjd">西红柿炒鸡蛋</li>
			<li cmd="kgcjd">苦瓜炒鸡蛋</li>
			<li cmd="ljclj">辣椒炒辣椒</li>
			<li cmd="jdcjd">鸡蛋炒鸡蛋</li>
		</ul>
	</div>
	<!-- 回撤 -->
	<button id="undo">undo</button>
</div>

// 把这个类抽象成服务员
class Command {
    constructor (commands) {
        this.commands = commands // 接收菜单集合
        this.oldCommands = [] // 记录命令用于回撤
    }
    execute (type) {
        // 调用真实方法并存储id
        let id =  this.commands[type].execute().id
        this.oldCommands.push(id)
    }
    // 回撤
    undo () {
        let id = this.oldCmmands.pop()
		console.log('要撤回的订单id是'+id)
    }
}
// 菜单集合
var Menu = {
    xhscjd:{
        execute:function () {
            console.log('西红柿炒鸡蛋')
            let id = 'data-'+Date.now() // 用于标识唯一订单
            return {
                id
            }
        }
    }
    // ... ...
}

// 实例化一个服务员帮忙做事
let waiter = new Command(Menu)
let lis = document.querySelectorAll('.commands>li')
[...lis].map(item => {
   item.addEventListener('click', function () {
     // 让服务员下单
     waiter.execute(item.getAttribute('cmd'))
  })
})

其实你会发现我们好像把简单的事复杂化了,的确如此,完成上述效果并不需要太复杂刻意的去生成一个"服务员"解耦,只需要将分离的命令模块进行直接调用即可,像下面这样

[...lis].map(item => {
   item.addEventListener('click', function () {
     // 直接调用
     const type = item.getAttribute('cmd')
     Menu[type].execute()
  })
})

小结

命令模式不单单是简单将函数体封装调用,而是通过这种模式给命令去增加撤销操作,像上面demo一样支持撤销订单,也就是说,要基于需求合理使用这个模式,实现这个模式并不困难,如果不清楚一个需求是否需要命令模式,就不要着急实现,只有你真正用到了撤销,恢复等等操作时,这个模式发挥才有意义~~

四,策略模式

核心概念

定义一系列算法,把它们一个个封装起来,并且使他们可以相互替换

注意**"并且使他们可以相互替换"**,这句话其实是不适用在JavaScript这种动态语言身上的,站在JavaScript角度理解这句话,要这么理解,定义一系列的算法,把他们封装不同的类,这些策略类都拥有相同的方法,算法被封在方法内部里,在开发者调用Context接口时,Context总是会把请求委托给这些封装好的策略类来完成需求

为了更好的讲解这个模式的应用我先引出一个需求并确定满足策略模式的功能

  • 分离算法
  • 由Context调用委托给策略类

我现在是一个人,我身边有很多形形色色的朋友,在大马路上碰上了对每个人都是有不同的反应,比如碰上了好基友打招呼都是基情满满,碰上了傻*还有可能会屌他,碰上女神可能会舔一舔等等行为,假如用代码去实现我碰到不同人的反应可能是如下

class Myself {
  constructor (friendType) {
    this.friendType = friendType
  }
  sayHi () {
    if (this.friendType === '基友') {
      console.log('你昨天内裤落我家里了')
    } else if (this.friendType === '傻*') {
      console.log('啥b')
    } else if (this.friendType === '女神') {
      console.log('周末我能请你吃kfc吗')
    }
  }
}

let myself = new Myself('傻*')
myself.sayHi() // 啥b

能看到sayHi函数显得非常臃肿,如果这时候在加上一个类型的朋友,反应又可能不是一样的,那么还继续会往sayHi函数里添加无尽if-else!这很显然已经违反了开闭原则,我们应该对修改关闭,对拓展开放,下面开始用策略模式重构之前我们在回顾一下策略模式的定义,将算法封装起来,把不变的部分和变化的部分离,这其中我打招呼的方式不会变,会变的是我会遇到不一样的人从而以什么方式打招呼~下面开始代码重构!

class Myself {
  constructor () {
    this.strategy = null // 打招呼方式的策略类
  }
  sayHi () {
    return this.strategy.Hello()
  }
  setStrategy (strategy) {
    this.strategy = strategy
  }
}


// 定义打招呼的策略类
class Jiyou {
  constructor(name) {
    this.name = name
  }
  Hello () {
    console.log(this.name + '你昨天内裤落我家里了')
  }
}
class Shabi {
  constructor(name) {
    this.name = name
  }
  Hello () {
    console.log(this.name + '啥b')
  }
}
class Nvshen {
  constructor(name) {
    this.name = name
  }
  Hello () {
    console.log(this.name + '周末我能请你吃kfc吗')
  }
}

let myself = new Myself()
myself.setStrategy(new Jiyou('基友')) // 设置应用策略类
myself.sayHi() // 基友你昨天内裤落我家里了


能看到各个类之间的职责已经剥离,代码结构已经变得整洁许多,可是这段代码是模仿了传统面向对象语言实现的,为了抽象化才使用了class语法,但其实我们JavaScript可以以更简洁的方式去实现策略模式,上面的Context是Myself,Myself的strategy对象都是由各个策略类创建而来,但是前面一些章节我们有讲到!JavaScript拥有着极其方便的对象创建方式,大可不必这样周转~我们看看用JavaScript如何简化策略模式的重构

// 直接使用函数代替class生成的策略类
var strategy = {
  Jiyou: function (name) {
    console.log(name + '你昨天内裤落我家里了')
  },
  Shabi: function (name) {
    console.log(name + '啥b')
  },
  Nvshen: function (name) {
    console.log(name + '周末我能请你吃kfc吗')
  }
}
//myself充当Context
var myself = function (type, name) {
  return strategy[type](name)
}
myself('Jiyou', '基友') // 基友你昨天内裤落我家里了
myself('Nvshen','女神') // 女神周末我能请你吃kfc吗

能看到结构又大幅度精简了,因为在JavaScript里函数也属于对象,所以更直接简单方法把strategy定义为函数也能达到策略模式的标准实现

小结

策略模式在前端其实应用很频繁,我们这些经验不足的开发者在开发后台管理应用或者经常和表单打交道的,会很频繁的跟if-else打交道,使用了这种模式的有点是可以大幅度减少多重条件下的判断,将职责独立起来,提高代码可维护性阅读性,可以说这个模式对我们来说提升是相当大的,当完全理解了这个模式的意义所在,并加以重构,相信你会很开心了解了这个模式~

总结

以上四个模式就是我认为在前端会经常使用到的设计模式,如果你在阅读本文过程当中如有不理解可以评论区或者私信交流都可以,这篇文章是博主处女文,所以有一些地方描述或者举的例子不是特别恰当,可以骂我菜什么的,都能接受,目的只为的是能帮到阅读文章的你能理解一点设计模式的使用及了解~

完整项目例子git地址

git仓库地址