JavaScript设计模式之单例模式

·  阅读 4418

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

概念

在《JavaScript设计模式与开发实践》 中对单例模式的定义为:保证一个类仅有一个实例,并提供一个访问它的全局访问点单例模式属于是比较常用的设计模式,其一个主要用途是避免重复的创建实例,节约不必要的开销。通常会在第三方库的开发中使用到,如果是项目的开发,单例模式也会用在一些数据缓存、全局通用弹窗(如登录弹窗)等一些场景中。

单例模式

标准的单例模式

标准的单例模式并不复杂,这里直接借用书中的一个例子:

var Singleton = function( name ){
    this.name = name;
    this.instance = null;
};

Singleton.prototype.getName = function(){
    alert ( this.name );
};

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

var a = Singleton.getInstance( 'sven1' );
var b = Singleton.getInstance( 'sven2' );

alert ( a === b ); // true
复制代码

从这个例子中,很容易卡不到当执行Singleton.getInstance时:只有没有instance时才会执行初始化方法,后面再执行的时候会直接复用之前的instance,这就保证了即使函数执行了很多次,也能保证唯一实例。

JavaScript中的单例模式

JavaScript中创建对象的方式非常简单,而通常,我们只需要创建一个全局变量,即可满足单例模式的核心:确保只有一个实例,并提供全局访问。但在书中有一句话:全局变量不是单例模式,但在JavaScript开发中,我们经常会把全局变量当成单例来使用。我对这句话的理解是,因为全局变量并不能保证实例唯一,也就是说我们可以在任何一个地方去修改全局变量,也就可能导致全局变量的代码被覆盖,引起代码报错,而单例模式则只会创建一个实例,就像上面的getInstance一样,如果已经存在instance就不会创建新实例。书中也建议尽量减少全局变量的使用,即使需要,也要把它们的污染降到最低。

降低全局变量带来的污染

    1. 使用命名空间
var namespace1 = {
    a: functino() {},
    b: function() {}
}
复制代码
    1. 使用闭包封装私有变量
var user = (function() {
    var _name = 'seven',
        _age = 29
        
    return {
        getUserInfo: function() {
            return _name + '-' + _age 
        }
    }
})()
复制代码

惰性单例

惰性单例指的是在需要的时候才创建对象实例。通常会用在全局唯一且非必需的一些场景,例如:全局弹窗、购物车列表、全局共同信息等场景。拿书中登录弹窗的例子可以理解的更加清晰:

var createLoginLayer = (function() {
    var div;
    return function() {
        if(!div) {
            div = document.createElement('div');
            div.innerHtml = '登录弹窗';
            div.style.display = 'none';
            document.body.appendChild(div);
        }
        return div;
    }
})()

document.getElementById('loginBtn').onclick = function() {
    var loginLayer = createLoginLayer();
    loginLayer.style.display = 'block';
}
复制代码

在这个例子中,只有在登录按钮点击时,才会去创建登录弹窗dom节点,而不是在页面加载时就默认创建,并且,只有在第一次执行时创建登录弹窗dom节点,再次执行也不会创建多余的节点,节省了一部分性能。

通用的惰性单例

在上面登录弹窗这个例子中,虽然实现了惰性单例,但同时还存在一些代码设计上的问题:
上面代码在createLoginLayer方法中,既实现了单例的逻辑,也实现了创建登录弹窗的逻辑。这违反了上节介绍的单一职责原则,如果下次仍然需要创建另一个弹窗或其他的功能,我们仍然需要将创建单例这部分逻辑再次抄一遍。这么一说相信大家也能理解到这段代码需要如何进行拆分了:将不变的部分抽离出来,也就是将要介绍的通用的惰性单例


// 通用的惰性单例
var getSingle = function(fn) {
    var result;
    return function() {
        return result || (resule = fn.apply(this, arguments));
    }
}

// 创建登录弹窗的方法就可以改写成
var createLoginLayer = function() {
    var div = document.createElement('div');
    div.innerHtml = '登录弹窗';
    div.style.display = 'none';
    document.body.appendChild(div);
    return div;
}

var createSingleLoginLayer = getSingle(createLoginLayer);

document.getElementById('loginBtn').onclick = function() {
    var loginLayer = createSingleLoginLayer();
    loginLayer.style.display = 'block';
}
复制代码

如上面代码,我们将创建登录弹窗和单例的逻辑分离成createLoginLayergetSingle,这样之后再有了类似的需求,就可以复用到getSingle方法实现单例。

源码中的单例模式

前面介绍了单例模式的基础概念和使用方式,虽然也有一些代码实例来说明问题,但毕竟不是实实在在的真实场景,所以还是差一点意思。后面我也看了一些主流的开源代码,并找到几个源码中用到的部分,来看一下这些优秀的开源代码是如何使用的吧。

Vuex

在介绍中也说到了单例模式会用到数据缓存的场景中,那么状态管理库一定是最能满足这个场景的,这里我只选择了Vuex来举例,实际上其他的状态管理库也是有类似的实现方案:

// src/store.js

let Vue;

export class Store {
    constructor(options = {}) {
        if (!Vue && typeof window !== 'undefined' && window.Vue) {
          install(window.Vue)
        }
    }
}

export function install (_Vue) {
  if (Vue && _Vue === Vue) {
    if (__DEV__) {
      console.error(
        '[vuex] already installed. Vue.use(Vuex) should be called only once.'
      )
    }
    return
  }
 
  Vue = _Vue
  applyMixin(Vue)
}

复制代码

在上面代码中,很明显可以找到一个单例模式的应用:在Store的初始化时,只会执行一次install方法。在install方法中,会将Vue赋值,并将vuex的相关逻辑绑定到Vue实例上。

antd/message

在大家最常用的Antd中,message组件相信大家也不陌生了,其实他的弹窗组件也用到了单例模式:

// components/message

let messageInstance;

function getMessageInstance(callback) {
  if (messageInstance) {
    callback(messageInstance);
    return;
  }
  Notification.newInstance(
    {
      prefixCls,
      transitionName,
      style: { top: defaultTop }, // 覆盖原来的样式
      getContainer,
      maxCount,
    },
    instance => {
      if (messageInstance) {
        callback(messageInstance);
        return;
      }
      messageInstance = instance;
      callback(instance);
    },
  );
}
复制代码

在上面代码中,我们可以看到当已经存在messageInstance时,会直接复用对应的实例:callback(messageInstance),否则的话,将会赋值messageInstance = instance。既然知道了message组件用到了单例模式,那么我们也要了解一下使用的背景。
先看下面这段代码:

<template>
  <div>
    <a-button @click="handleMessage">message</a-button>
  </div>
</template>

export default {
  methods: {
    handleMessage() {
      this.$message.info('handleMessage');
    },
  },
  created() {
    this.$message.config({
      top: '100px',
      duration: 1,
    });
  },
};
复制代码

这是一个vue组件,在created时,我们配置了message的参数,当点击按钮时,会执行方法调用message.info方法弹出组件。在message源码中,会通过执行notice方法弹出弹窗,而如果我们是第一次弹出弹窗时:

image.png

会创建一个ant-message节点其中top: 100px就是刚才设置的配置项,当我们再次触发时:

image.png

可以看到会复用刚刚创建的dom节点,并在内部创建一个弹窗。那么这样做有什么好处呢?

  • 最显然的一个优势就是复用了dom节点
  • 还有一个优势,当我们连续多次点击时,可以看到弹窗的效果是按顺序依次显示的,只有一个dom节点可以保证多次弹出的弹窗只有一个父节点,那么弹窗位置只要由父节点控制即可,不需要每次都重新计算,效果如图所示:

image.png

通过这个message源码中的应用,应该会对单例模式的应用有了一个更直观和深刻的认识,在今后封装一些自定义弹窗时,也可以借鉴这样一个思路。

单例模式应用

对于单例模式的实际应用场景,其实也确实不少。最简单的,当我们使用vuex时,其实已经算是应用到了单例模式,除此之外,如果我们需要封装一些公共库时,更应该想到单例模式的应用。

axios取消重复请求

背景 举一个自己在开发中的使用场景,例如列表筛选,在项目经常会遇到一个场景,通过列表的SearchBar对列表进行筛选,如果结果返回较慢并且此时用户频繁切换tab,就会导致多个接口一直在响应中,但前面的筛选数据已经没有意义了,如果接口不是按顺序响应,那就会导致返回数据与筛选不符合,在项目中应该会有很多类似的场景这里提供一个我的思路,应用到了单例模式的思想

import axios from "axios";
const CancelToken = axios.CancelToken;
let cancelId = 0;
let cancelArray = [];

// 添加请求拦截器
axios.interceptors.request.use(function (config) {
  // 在请求时,可以添加自己的特点标识去筛选出需要重复取消的接口
  const source = CancelToken.source();
  cancelId++;
  const id = cancelId;
  config.cancelId = id;
  config.cancelToken = source.token;
  const cancelIndex = cancelArray.findIndex(e => e.url === config.url);
  cancelArray.push({
    id,
    url: config.url,
    source
  })
  if (cancelIndex > -1) {
    cancelArray[cancelIndex].source.cancel('取消重复请求');
    cancelArray.splice(cancelIndex, 1)
  }
  return config;
}, function (error) {
  return Promise.reject(error);
});

// 添加响应拦截器
axios.interceptors.response.use(function (response) {
  const cancelIndex = cancelArray.findIndex(e => e.id === response.cancelId);
  if (cancelIndex >= -1) {
    cancelArray.splice(cancelIndex, 1)
  }

  // 对响应数据做点什么
  return response;
}, function (error) {
  if (axios.isCancel(error)) {
    // 如果是取消的接口,可以自行返回一个特定标识
    console.log('isCancel')
  } else {
    // 对响应错误做点什么
    return Promise.reject(error);
  }
});

export default axios
复制代码

测试一下效果: image.png

如上代码用到了cancelArray保存在请求中的接口,当接口响应后,会删除掉该数据,如果在下一个请求触发时仍然存在未完成接口,则直接取消。我认为这个是用到了单例模式的思想,在项目中可提高一定的开发效率,避免复制大量重复代码。

总结

单例模式的概念和使用场景都很容易理解,结合一些优秀的开源代码相信大家对单例模式的场景和作用也有了更深刻的认识。在前端开发中,单例模式算是比较常用的设计模式之一,尤其是惰性单例,在开发中非常实用。同时,在举例单例模式时,也巩固了上节所说的单一职责原则的概念,并根据这一设计原则对代码进行了优化,抽象出了更加通用的惰性单例,希望大家能通过这篇文章学到一些的编码技巧。
感谢阅读 🙏

收藏成功!
已添加到「」, 点击更改