【设计模式】单例模式在前端开发中的实践

279 阅读5分钟

努力让学习成为一种习惯,自信来源于充分的准备

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

前言

单例模式是设计模式中较为简单的一种。虽然简单,但在前端领域中,使用的场景有很多。我们一起来看看单例模式在前端开发中有哪些实践

单例模式

在实战篇之前,我们先说下什么是单例模式,单例模式有两个特点:

  1. 一个类有且只有一个实例

  2. 提供一个对外暴露的API用于访问该实例(严格定义来说为:提供一个访问它的全局节点,这意味着严格意义来讲类的构造函数是私有化的,不能通过它来实例化)

参考这里给出的uml图

image.png

我们可以基于以上特点写出下面的伪代码

class Singleton {
  // 在类中添加一个私有静态成员变量用于保存单例实例
  private static instance: Singleton

  // 将类的构造函数设为私有。 类的静态方法仍能调用构造函数, 但是其他对象不能调用
  private constructor() {}

  // 声明一个公有静态构建方法用于获取单例实例
  public static getInstance(): Singleton {
    return this.instance || (this.instance = new Singleton())
  }
}

但由于js本身是单线程,自身特性很灵活。因此js实现单例模式可以很简单,没必要拘泥上述规范。明白其设计原理,解决了什么问题才是关键

我们来看下具体实现代码

es5

// es5
function Singleton() {}

Singleton.getInstance = function () {
  if (!Singleton.instance) {
    Singleton.instance = new Singleton()
  }
  return Singleton.instance
}

const s1 = Singleton.getInstance()
const s2 = Singleton.getInstance()
console.log(s1 === s2);

es6

class Singleton {
  static getInstance() {
    if(!Singleton.instance) {
      Singleton.instance = new Singleton()
    }
    return Singleton.instance
  }
}

const s1 = Singleton.getInstance()
const s2 = Singleton.getInstance()
console.log(s1 === s2); // true

// 也可以基于 constructor(虽然不标准化,但本质都是一样的)
class Singleton {
  constructor() {
    if (!Singleton.instance) {
      Singleton.instance = this
    }
    return Singleton.instance
  }
}
const s1 = new Singleton()
const s2 = new Singleton()
console.log(s1 === s2); // true

在上面实现的代码中,我们使用了静态属性、方法的方式存储、获取唯一实例,当然我们也可以使用闭包

Singleton.getInstance = (function () {
  let instance
  return function () {
    if (!instance) {
      instance = new Singleton()
    }
    return instance
  }
})()

实践场景

localStorage

对于localStorage,我们并不陌生,业务开发中会需要它本地存储数据。localStorage其实就是一个全局单例对象。我们可以模仿实现一下

es5

function StorageBase() {}
StorageBase.prototype.getItem = function(key) {
  return this[key]
}
StorageBase.prototype.setItem = function (key, value) {
  this[key] = value
}

var Storage = (function () {
  var instance = null
  return function () {
    return instance || (instance = new StorageBase())
  }
})()

// 这里用不用new无所谓
var s1 = new Storage()
var s2 = new Storage()

console.log(s1 === s2); // true
s1.setItem('a', 1)
console.log(s1.getItem('a')); // 1
console.log(s2.getItem('a')); // 1

es6

class Storage {
  static instance = null
  static getInstance = function () {
    return this.instance || (this.instance = new Storage())
  }
  getItem(key) {
    return this[key]
  }
  setItem(key, value) {
    this[key] = value
  }
}

const s1 = Storage.getInstance()
const s2 = Storage.getInstance()

console.log(s1 === s2); // true
s1.setItem('a', 1)
console.log(s1.getItem('a')); // 1
console.log(s2.getItem('a')); // 1

message弹框

我们先来看一道经典考察单例模式的面试题:实现一个全局唯一的Modal弹框

直接上代码:

// html
<div>
  <button class="add">add</button>
  <button class="close">close</button>
</div>

// js
class Model {
  static instance
  static getInstance () {
    if (!this.instance) {
      const model = document.createElement('div')
      model.id = 'model'
      model.style.display = 'none'
      model.innerHTML = 'here! i am here!'
      document.body.appendChild(model)
      this.instance = new Model(model)
    }
    return this.instance
  }
  constructor(model) {
    this.model = model
  }
  open() {
    this.model.style.display = 'block'
  }
  close() {
    this.model.style.display = 'none'
  }
}

const addBtn = document.querySelector('.add')
addBtn.addEventListener('click', () => {
  const model = Model.getInstance()
  model.open()
})

const closeBtn = document.querySelector('.close')
closeBtn.addEventListener('click', () => {
  const model = Model.getInstance()
  model.close()
})

但大多数情况,我们业务中使用的弹窗、消息提示都是使用第三方封装好的组件,比如elementuidialogmessage

有一个常见的场景是当我们elementuimessage消息提示,调用接口报错的时候,如果用户手速过快连续点了许多次。就会出现这种情况

image.png

但其实这种交互体验并不好,理论上在上一次message实例销毁前,不应再次产生新的实例,基于这种情况。我们就可以利用单例模式进行改造,如果之前生成的message实例还存在,则不需要生成新的实例

import Vue from 'vue'
import { Message } from 'element-ui'
import type { ElMessageOptions, ElMessageComponent } from 'element-ui/types/message'

class SingletonMessage {
  static instance: ElMessageComponent | null = null

  private constructor() {
    throw new Error('class SingletonMessage can not be called by new')
  }

  static show(options: string | ElMessageOptions) {
    if (this.instance) return

    let config: ElMessageOptions

    if (typeof options === 'string') {
      config = {
        message: options,
        onClose: () => {
          this.instance = null
        }
      }
    } else {
      const { onClose, ...otherOptions } = options
      config = {
        ...otherOptions,
        onClose: (...args) => {
          typeof onClose === 'function' && onClose.apply(this, args)
          this.instance = null
        }
      }
    }
    this.instance = Message(config)
  }

  static close() {
    this.instance && this.instance.close()
  }
}

Vue.prototype.$singletonMessage = SingletonMessage

我们在axios的拦截器使用

const SingletonMessage = Vue.prototype.$singletonMessage

axios.interceptors.response.use(
  res => res,
  err => {
     SingletonMessage.show({
       type: 'error',
       ....
     })
     return Promise.reject(err)
  }
)

这样我们多次接口报错,在当前Message销毁前,都会只有同一个实例,另外需要注意:上面具体实现的代码逻辑与单例模式uml规范会有些不同。学习设计模式重点在于思路,运用其思想解决业务中的问题才是关键。另外受限于element-ui本身,每次Message被销毁,其DOM节点也会被销毁。这其实也不是真正的单例模式。我们在这更多是借鉴其思路

这个交互在element-plus中得到了改善,新增了grouping选项,设置 grouping 为 true,内容相同的 message 将被合并

image.png

效果如下

image.png

ESM模块化

在ES6中, import引入模块就是一个很好的单例模式

// index.html
<script type="module">
import './single.js'
import './single.js'
</script>

// single.js
console.log('modeule test exec :>> ');

在上面代码中,import 引入的模块是会自动执行的,重复引入一个模块并不会执行多次。控制台只会打印一次

image.png

// index.html
<script type="module">
import obj from './single.js'
import obj2 from './single.js'

console.log(obj === obj2) // true
</script>

// single.js
const obj = {
   a: 1
}

export default obj

正是基于单例模式。而ES6模块输出的是值的引用。因此上面代码我们可以发现objobj2引用的同一个对象

// index.html
<script type="module">
import obj1 from './single.js';

console.log(obj); // {a: 1}
setTimeout(() => {
  console.log(obj); // {a: 2}
}, 3000);
</script>

// single.js
const obj = {
   a: 1
}

setTimeout(() => {
  obj.a = 2
}, 500);

export default obj

如果我们在当前模块修改了某个导出变量。别的模块再引入会是改变后(实时)的值。如果多个模块都引入该变量,则容易出现莫名问题且不好排查。因此我们尽量不要去改变导出变量的值

最后

单例模式本质核心点在于把之前生成的实例缓存起来。缓存的方式有很多种(类静态属性、闭包等),再对外提供一个访问该缓存实例的接口。由于js语言本身的灵活性且是单线程,js可以很方便的实现单例模式

到这里,就是本篇文章的全部内容了

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

如果你有疑问或者出入,评论区告诉我,我们一起讨论

参考文章

设计模式在前端开发中的实践(十二)——单例模式