第四章 单例模式

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

单例模式是一种常用的模式,有一些对象我们往往只需要一个,比如线程池、全局缓存、浏览器中的 window 对象等。

4.1 实现单例模式

要实现一个单例模式,只需要用一个变量来标识当前是否已为某个类创建过对象,如果是,则在下一次获取该类的实例时,直接返回之前创建的对象。

js
1var Singleton = function (name) {
2  this.name = name
3  this.instance = null
4}
5
6Singleton.prototype.getName = function () {
7  alert(this.name)
8}
9
10Singleton.getInstance = function (name) {
11  if (!this.instance) {
12    this.instance = new Singleton(name)
13  }
14  return this.instance
15}
16
17var a = Singleton.getInstance('sven1')
18var b = Singleton.getInstance('sven2')
19
20alert(a === b) // true

也可以使用闭包来完成这个功能

js
1var Singleton = function (name) {
2  this.name = name
3}
4
5Singleton.prototype.getName = function () {
6  alert(this.name)
7}
8
9Singleton.getInstance = (function () {
10  var instance = null
11  return function (name) {
12    if (!instance) {
13      instance = new Singleton(name)
14    }
15    return instance
16  }
17})()

这个方法非常简单,但是却增加了单例类的“不透明性”。跟以往通过 new xxx 来获取对象的方式不同,开发者必须知道这是一个单例类,并且通过Singleton.getInstance来获取 Singleton 类的对象。

4.2 透明的单例模式

下面代码是对上面的单例模式的一种改进,以创建唯一的 div 节点为例。

js
1var CreateDiv = (function () {
2  var instance
3
4  CreateDiv = function (html) {
5    if (instance) {
6      return instance
7    }
8    this.html = html
9    this.init()
10    return (instance = this)
11  }
12
13  CreateDiv.prototype.init = function () {
14    var div = document.createElement('div')
15    div.innerHTML = this.html
16    document.body.appendChild(div)
17  }
18
19  return CreateDiv
20})()
21
22var a = new CreateDiv('sven1')
23var b = new CreateDiv('sven2')
24
25alert(a === b) // true

这个单例模式比较透明,为了将 instance 封装起来,使用了闭包和立即执行函数,并且返回了真正的构造函数。

这种方式增加了一些程序的复杂度,阅读起来也不是很舒服。

观察这段代码

js
1CreateDiv = function (html) {
2  if (instance) {
3    return instance
4  }
5  this.html = html
6  this.init()
7  return (instance = this)
8}

在这个构造函数中,一共负责了两件事情。第一是保证只有一个实例,第二是调用该函数的 init 函数,这违反了单一职责原则,让这个函数看起来很奇怪。

4.3 用代理实现单例模式

我们来对上面的 CreateDiv 构造函数进行改造,将返回单一实例的代码提炼出来,使它变成一个纯正的构造函数

js
1var CreateDiv = function (html) {
2  this.html = html
3  this.init()
4}
5
6CreateDiv.prototype.init = function () {
7  var div = document.createElement('div')
8  div.innerHTML = this.html
9  document.body.appendChild(div)
10}

然后引入一个代理类

js
1const proxySingletonCreateDiv = (function () {
2  let instance
3  return function (html) {
4    if (!instance) {
5      return (instance = new CreateDiv(html))
6    }
7    return instance
8  }
9})()

现在我们把负责管理单例的逻辑移到了代理类 proxySingletonCreateDiv 中,它使职责划分更清晰,跟 createDiv 组合也可以产生单例模式的效果。

接下来测试一下

js
1var a = new proxySingletonCreateDiv('sven1')
2var b = new proxySingletonCreateDiv('sven2')
3
4alert(a === b) // true

4.4 JavaScript 中的单例模式

前面的几种单例模式的实现,在传统的面向对象语言当中, 是非常自然的。以 Java 为例,如果需要某个对象,就必须先定义一个类,对象总是从类中创建而来。单例对象自然也是从类创建而来。

但在 JavaScript 中,创建对象非常简单,我们并不需要创建一个类。

这就意味着传统的单例模式并不适用于 JavaScript。

单例模式的核心是确保只有一个唯一的实例并提供给全局访问。

全局变量不是单例模式,但在 JavaScript 中,我们可以将其当作单例来使用。

例如:

js
1var a = {}

全局对象提供给全局访问时理所当然的,这样就满足了单例模式的两个条件。

但全局变量也有问题。它会造成命名空间受污染。

以下有几种方式可以降低全局变量带来的命名污染。

  1. 使用 namespace

    最简单的方式是采取对象字面量的方式:

    js
    1var namespace = { a: function () {}, b: function () {} }

    把需要的变量都定义为 namespace 的属性,这样可以减少变量和全局作用域打交道的机会。

    还可以动态创建全局命名空间

    js
    1const myApp = {
    2  namespace(name) {
    3    var current = this
    4    var parts = name.split('.')
    5    for (let p of parts) {
    6      if (!current[p]) {
    7        current[p] = {}
    8      }
    9      current = current[p]
    10    }
    11  },
    12}
    13myApp.namespace('dom.style.classname')
    14
    15//上面的代码相当于
    16var myApp = {
    17  dom: {
    18    style: {
    19      classname: {},
    20    },
    21  },
    22}
  2. 使用闭包封装私有变量

    这种方法是将变量封装在闭包的内部,只暴露一些接口跟外部通信

    js
    1var user = (function () {
    2  var __name = 'sven',
    3    __age = 29
    4  return {
    5    getUserInfo: function () {
    6      return __name + '-' + __age
    7    },
    8  }
    9})()

    我们用下划线来约定私有变量__name和__age,它们被封装在闭包产生的作用域中,外部是访问不到这两个变量的,这就避免了对全局的命令污染

4.5 惰性单例

惰性单例指的是在需要的时候才创建对象实例。惰性单例是单例模式的终点,这种技术在实际开发中非常有用。

下面假设我们的网站上有一个登录按钮,当用户点击这个按钮后,会出现一个登录弹窗。很显然这个弹窗在页面中总是唯一的,不可能同时存在两个登录窗口的情况。

下面我们来写第一种解决方案:页面加载完成时创建好登录框,登录框是隐藏的,当用户点击登录按钮时,它才显示出来

js
1var loginLayer = (function() {
2  var div = document.createElement('div');
3  div.innerHTML = ’我是登录浮窗’;
4  div.style.display = 'none';
5  document.body.appendChild(div);
6  return div;
7})();
8document.getElementById('loginBtn').onclick = function() {
9  loginLayer.style.display = 'block';
10};

这种方式的缺点在于该节点一开始就创建好了,如果用户没有点击登录按钮,那么创建该节点的操作就白白浪费了。

下面这种方式倒是可以在点击按钮时创建,但是每次都会创建多个 div,也就违背了单例模式。

js
1var createLoginLayer = function () {
2  var div = document.createElement('div')
3  div.innerHTML = '我是登录浮窗'
4  div.style.display = 'none'
5  document.body.appendChild(div)
6  return div
7}
8document.getElementById('loginBtn').onclick = function () {
9  const LoginLayer = createLoginLayer()
10  LoginLayer.style.display = 'block'
11}

我们只需要在上面代码的基础上用一个 div 进行判断是否创建过浮窗就可以实现单例模式了。

js
1var createLoginLayer = (function () {
2  let div
3  return function () {
4    if (!div) {
5      div = document.createElement('div')
6      div.innerHTML = '我是登录浮窗'
7      div.style.display = 'none'
8      document.body.appendChild(div)
9    }
10    return div
11  }
12})()
13document.getElementById('loginBtn').onclick = function () {
14  const LoginLayer = createLoginLayer()
15  LoginLayer.style.display = 'block'
16}

4.6 通用的单例模式

上面的单例模式虽然已经完成了功能,但是缺陷也很明显:

  • 代码违背了单一原则,所有逻辑都放在 createLoginLayer 中
  • 立即执行函数使得代码阅读起来不是很舒服
  • 无法给其他需要单例模式的场景复用

我们先把不变的逻辑抽离出来,返回单例的逻辑始终是不变的,可以封装成一个通用的单例函数:用一个变量来标识是否创建过对象,如果是,则在下次直接返回这个已经创建好的对象,然后把需要执行什么函数通过参数传递给这个单例函数:

js
1var singleton = function (handler) {
2  let result
3  return function () {
4    return result || (result = handler.apply(this, arguments))
5  }
6}

由于 result 始终在闭包里,所以它始终不会被销毁

然后修改创建登录浮窗的方法

js
1var createLoginLayer = function () {
2  div = document.createElement('div')
3  div.innerHTML = '我是登录浮窗'
4  div.style.display = 'none'
5  document.body.appendChild(div)
6  return div
7}

使用:

js
1const createSingletonLoginLayer = singleton(createLoginLayer)
2document.getElementById('loginBtn').onclick = function () {
3  const LoginLayer = createSingletonLoginLayer()
4  LoginLayer.style.display = 'block'
5}

我们将两个创建实例对象的职责和管理单例的职责分别放置在两个方法里,这两个方法可以独立变化而互不影响,当它们连接在一起时,就完成了创建唯一实例对象的功能。

单例模式的应用不止创建一个唯一的对象,也可以用在只处理一遍的业务场景上。

jquey 有一个 one 方法,它可以为元素添加处理函数。处理函数在每个元素上每种事件类型都只处理一次。

js
1$('#foo').one('click', function () {
2  alert('This will be displayed only once.')
3})

使用 getSingleton 也可以达到一样的效果

js
1var singleton = function (handler) {
2  var result
3  return function () {
4    return result || (result = handler.apply(this, arguments))
5  }
6}
7var bindEvent = singleton(function () {
8  alert(123)
9  return true
10})
11document.getElementById('loginBtn').onclick = bindEvent

4.7 小结

由于语言之间的差异性,传统的单例模式跟 JavaScript 中创建单例的方法并不相同。

在 JavaScript 的单例模式中,更多的是运用闭包和高阶函数来实现单例模式。

单例模式非常简单且实用,特别是惰性单例技术,在合适的时候才创建对象,并且只创建唯一的一个对象。

当我们在写单例模式时,最好将创建对象和管理单例的职责进行分离,等到它们组合在一起,就形成一个可复用,可读性更高的单例模式。