JavaScript的设计模式(1):单例模式

最近在持续学习JavaScript的一些设计模式,那么为什么要学习设计模式呢?所谓的设计模式就是在不同的开发场景下,总结出的一种通用解决方案。知道和学习这些设计模式对我们以后的开发将大有裨益,也能让你的能力提升一个层级,接下来就开始我们的实践吧。
这篇文章我们将讲解单例模式,那么什么是单例模式?它的定义如下:保证一个类仅有一个实例,并提供一个访问它的全局的访问点。这是因为在实际的生产和开发环境中,一些对象我们往往只需要一个;举一个典型的例子,比如我们的页面上有一个按钮,每当用户点击这个按钮的时候,就会出现一个弹窗;这种情况下我们只需在用户首次点击的时候创建好这个弹窗,然后用样式控制弹窗的显示和隐藏,以后如果用户再次点击这个按钮的话,就复用之前创建的这个弹窗。这种情况下我们就只需要这一个弹窗对象,而不是每次点击就重新创建一个新的弹窗。
因为JavaScript不是纯粹的面向对象的语言,所以在JavaScript里面使用单例模式和别的面向对象的编程语言还是有一些不同的。下面我们来详细讲解一下,在JavaScript里面如何使用单例模式。

使用命名空间(1513666901432)

因为在JavaScript中可以很方便的创建全局变量,一旦一个全局变量创建完成,我们可以很方便的在接下来的代码中使用这个全局变量;但是这也是JavaScript经常被很多开发者诟病的一个地方,因为这样很容易造成命名空间的污染,所以我们要非常谨慎地使用全局变量。
使用对象字面量是创建一个单例最简单的方法:

window.app = {
  version: '1.0.0',
  init() {
    // 初始化应用程序
    console.log('应用程序初始化完成。');
  },
  destroy() {
    // 应用程序被销毁
    console.log('应用程序已被销毁。')
  }
};

// 测试app
app.init();
app.destroy();

当然,我们也可以动态的创建命名空间,给我们的app对象添加一个创建命名空间的方法createNamespace,如下所示:

//...
app.createNamespace = function(name) {
  let arr = name.split('.');
  let len = arr.length;
  let current = this;
  for(let i = 0; i < len; i++) {
    if(!current[arr[i]]) {
      current[arr[i]] = {};
    }
    current = current[arr[i]];
  }
};

// 测试我们创建命名空间的方法
app.createNamespace('store.message.add');
console.log(app);

我们会看到控制台的输出如下所示:

使用闭包封装私有变量,然后返回一个可以操作封装的私有变量的对象

因为这种方法需要大家对闭包有所了解,如果还不是很了解闭包的同学可以先看看JavaScript闭包的一些知识。下面就是使用闭包创建的一个单例,代码如下:

// 使用闭包创建单例
let counter = (function() {
  let _count = 0;

  return {
    setCount(count) {
      _count = count;
    },
    getCount() {
      return _count;
    }
  }
})();

console.log(counter.getCount());
counter.setCount(10);
console.log(counter.getCount());

控制台的输出如下:

使用辅助函数创建单例

在平时的开发中,我们可能需要创建很多个单例,所以我们可以把创建单例,和管理单例的逻辑分开来。就拿文章开头说的那个例子来进行说明,我们需要一个函数来创建弹窗实例,还需要另一个函数来确保我们的实例是唯一的,代码如下所示:

// 使用辅助函数来创建单例对象
const singletonHelper = function(fn) {
  let instance;
  return function() {
    if(!instance) {
      instance = fn.apply(this, arguments);
    }
    return instance
  }
};

const createDialog = function(html) {
  let div = document.createElement('div');
  div.innerHTML = html;
  return div;
};

const createSingleDialog = singletonHelper(createDialog);

let d1 = createSingleDialog('d1');
let d2 = createSingleDialog('d2');
console.log(d1, d2, d1 === d2);

我们可以看到控制台的输出如下所示:

这说明我们确实只创建了一个实例对象,这里我再来解释一下上面的相关函数的作用;首先我们的singletonHelper函数用来确保传进来的那个函数只创建一个实例对象;然后createDialog函数只负责创建弹窗对象,最后我们得到了一个新的函数createSingleDialog,这个函数就具有只创建一个弹窗单例的功能。
我们这样写的好处有两个:(1)这种模式是一种通用的模式,将创建单例和管理单例分开来。让管理单例的函数得到了复用,减少了代码量。以后如果我们还需要创建任何一个单例对象,我们就只需要专注于创建单例的代码部分就好了。(2)这是一种惰性创建单例的方法,我们不会在代码加载的时候就创建我们的单例,而是在我们需要的时候创建这个单例对象,减少了不必要的资源消耗。
到这里很多同学就会说,你上面的这些方法都没有通过使用new操作符来创建单例对象,如果我想使用new操作符来创建单例对象该如何编写类的代码呢?因为我们之前说过,JavaScript并不算是真正意义上的面向对象的语言,所以在上面我们没有使用new操作符来创建单例对象,当然这不代表我们不可以使用new操作符来创建单例对象。接下来我们来讲解一下如何在JavaScript里面,通过使用new操作符来创建单例对象。

通过修改构造函数来创建单例对象

代码如下所示:

// 修改构造函数来创建单例对象
function Car(name) { // 1
  let instance;
  this.name = name;
  instance = this; // 2
  Car = function() { // 3
    return instance;
  };
}

let c1 = new Car('c1');
let c2 = new Car('c2');
console.log(c1, c2, c1 === c2);

控制台的输出如下所示:

控制台的输出说明我们这种方式是可以创造一个单例对象的,我这里再简单地说一下上面的代码,首先在注释1这里,我们创建了Car这个构造函数,然后在注释2这里我们在构造函数里创建了一个变量instance,这个变量用来保存我们第一次创建的对象;最后我们在注释3这里修改了最初的Car构造函数,这使得当第一次调用Car构造函数之后,后面再调用Car构造函数就是调用我们修改过之后的构造函数,修改过之后的构造函数直接返回最初创建的那个对象。

给构造函数添加辅助变量来创建单例对象

代码如下所示:

// 给构造函数添加辅助变量来创建单例对象
function Animal(name) {
  if(typeof Animal.instance === 'object') {
    return Animal.instance;
  }
  this.name = name;
  Animal.instance = this;
}

let a1 = new Animal('a1');
let a2 = new Animal('a2');
console.log(a1, a2, a1 === a2);

控制台的输出如下所示:

从控制台的输出我们可以看到,使用这种方法也是可以创建单例对象的。
当然,除了上面所说的两种方法可以使用new操作符来进行单例对象的创建之外,还有别的方法,这里就不在列举了;大家可以自己去探索一下。
随着ES6语法规范的普及,越来越多的前端开发工程师已经开始使用新的语法来进行业务的开发,所以在这里我们也要介绍一下如何使用新的语法规范来实现单例模式。
代码如下所示:
index.html

<!doctype html>
  <html>
    <head>
      <meta charset="utf-8"/>
    </head>
    <body>
      <script src="main.js"></script>
    </body>
  </html>

main.js

import {notification} from './Notification';
import {notificationTester} from './notification_test.js';

notification.addMessage('from main.js');
notification.showMessages();

notificationTester.addMessage('from notificationTester');
notification.showMessages();

Notification.js

class Notification{
  constructor(){
    this.messages = [];
  }
  addMessage(message){
    this.messages.push(message);
  }
  showMessages(){
    console.log(this.messages);
  }
}

export let notification = new Notification();

notification_test.js

import {notification} from './Notification';
export let notificationTester = notification;

上面的代码是在 WebpackBin 上面运行的,在线的地址是 ES6语法实现单例模式 ,应该需要翻墙才可以看在线的示例。下面是代码的运行结果:

控制台的结果说明了我们上面的代码也是可以实现单例模式的。我这里也做一下简单的说明,首先在Notification.js文件中,我们创建了一个Notification类,然后导出了这个类的一个实例,然后我们分别在main.jsnotification_test.js导入这个实例,然后在main.js分别运行了这两个对象上面的addMessage方法,从结果我们可以知道main.js中的notificationTesternotification对象其实指向的是同一个对象,所以我们通过这种方法也实现了单例模式。
还有另一种通过使用ES6语法来实现单例模式的方法,不在只是导出一个类的实例;而是在类上面做一些修改,使得每次使用类创建新的对象都只会创建唯一的一个。这个方法如下所示:
Notification.js

let instance;
export class Notification{
  constructor(){
    if(!instance) {
      this.messages = [];
      instance = this;
    }
    return instance;
  }
  addMessage(message){
    this.messages.push(message);
  }
  showMessages(){
    console.log(this.messages);
  }
}

main.js

import {Notification} from './Notification';
let n1 = new Notification();
let n2 = new Notification();
console.log(n1, n2, n1 === n2);

我们移除了notification_test.js,控制台的结果如下:

从控制台的结果可知,我们这种方法也是可以实现单例模式的。
到这里为止,关于JavaScript的单例模式我们已经讲的差不多啦,没有使用ES6语法的那部分的代码可以在 Github 上面看到;当然上面的内容难免会有一些错误,也欢迎大家指正;希望这篇文章能够给大家带来一些新的知识。

一些参考的资料:
深入理解JavaScript系列(25):设计模式之单例模式
JavaScript设计模式与开发实践 第四章

Comments
Write a Comment