JavaScript单例模式

单例模式是一种很常用的模式,有些对象我们往往只需要一个。然而,JavaScript 中实现单例模式的途径与传统面向对程序设计语言有很大不同。

全局变量即单例?

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

由定义可知,单例模式的核心是保证只有一个实例,并提供全局访问,从这点来看,JavaScript 中全局作用域下使用对象字面量方式创建的对象应该算得上是符合单例模式了。

然而,JavaScript 没有真正意义上“类”的概念,所以全局作用域下使用对象字面量方式创建的对象无从谈起是属于哪个类的实例了,因为它自身就是一个实例,从这点上来看,也可以说全局变量不是单例模式。

1
2
3
4
var ming = {
name: 'ming',
age : 17
};

上面代码中,全局作用域中的对象 ming,从学院派角度而言无论其是否符合所谓“单例模式”的定义,我们都可以将其当作单例使用。所以 JavaScript 中全局变量是否严格符合单例模式的定义并不重要,重要的是,开发人员能将其当作单例使用。

值得注意的是,实际编程中,我们应该尽可能地减少全局变量的使用。

简单的单例模式实现

要实现一个单例模式并不难,无非是用一个变量来标志当前是否已经为为某个类创建过对象,如果是,则在下一次获取时,直接返回之前创建的对象。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var Leader = function() {
this.instance = null;
this.level = 'supreme';
this.toString = function() {
console.log('I am a leader.');
};
};
Leader.getSingle = function() {
if (!this.instance) {
this.instance = new Leader();
}
return this.instance;
};
var leader = Leader.getSingle();
var leader1 = Leader.getSingle();
console.log( leader === leader1 ); // true

上面代码比较简单,我们可以通过 Leader.getSingle() 来获取 Leader 类的唯一对象。这种方式虽然简单,方便理解单例模式,但具有如下缺点:

  • 增加了类的“不透明性”
  • 与传统通过 new 方式调用类不同,对用户不友好

符合单例模式的构造器

为什么不是“构造函数”呢?我个人一直认为使用“构造器”而不“构造函数”称呼 ES5(包括之前的版本) 中用于创建对象的函数更好,因为 JavaScript 中没有真正意义上“类”的概念,函数是一等公民,所谓的“构造函数”只不过是使用 new 关键字对普通函数的一种调用而已,与传统面向对象程序设计语言中的“构造函数”有着本质区别。

与在全局作用域下使用字面量方式创建对象不同,如果我们使用了构造器来创建对象,那么就必须保证无论 new 构造器多少次,创建出来的始终都是同一个对象,这样才符合单例模式的概念。

下面我们来创建一个符合单例模式的构造器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var single;
var Leader = function() {
if (!single) {
single = new init();
}
return single;
function initLeader() {
this.level = 'supreme';
this.toString = function() {
console.log('I am a leader.');
};
}
};
var leader = new Leader();
var leader1 = new Leader();
console.log( leader === leader1 ); // true

通过测试结果,显然易见,上述代码中的构造器 Leader() 是符合单例模式的。但是上述代码有如下不足:

  • 用于存储单例的变量暴露在全局作用域中
  • 构造器不符合单一职责原则

改进方案

为了解决这些问题,我们可以使用闭包、代理模式、原型链对上述方案进行优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var Leader = (function() {
var single;
return function(fn) {
return ( single || ( single = new fn() ) );
};
})();
var initLeader = function() {
this.level = 'supreme';
this.toString = function() {
console.log('I am a Leader.');
}
}
var leader = new Leader(initLeader);
var leader1 = new Leader(initLeader);
console.log( leader === leader1 ); // true

惰性单例

惰性单例指的是在需要的时候才创建对象实例。

接下来,我们编写一个通用的惰性单例,它接受一个可用作构造器的函数,并返回这个构造器的惰性单例版本。

1
2
3
4
5
6
7
8
var getSingle = function(fn) {
var single;
return function() {
var createor = fn.bind(this, arguments);
return ( single || ( single = new createor() ));
};
};

单例模式的 ES6 实现

ES6 引入了 class 关键字,这使得 JavaScript 看上去和传统面向对象程序设计语言差不多,然而这只是原型链对象关系的语法糖,本质上其还是构造器和原型链继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Leader {
constructor () {
if (!Leader.single) {
this.level = 'supreme';
Leader.single = this;
}
return Leader.single;
}
toString() {
console.log('I am a leader.');
}
}
Leader.single = null;
const leader = new Leader();
const leader1 = new Leader();
console.log(Object.is(leader, leader1)); // true

如前所述,在 JavaScript 中实现单例模式的核心是使用一个变量来标志某个类是否已经实例化过对象。在 ES6 中,有了 class 语法,我们可以方便地将这个变量绑定到类自身上,作为其静态变量。

!!!注意: ES6 规范中并未提出类静态变量的标准写法,也就说,我们只能通过类似 myClassName.staticVar = 0 这种方式来给所谓的类静态变量赋值。


参考

  • 《JavaScript设计模式与开发实践》 曾探.著 人民邮电出版社,2015