Skip to main content

第四章 单例模式

单例模式的定义是:

  1. 保证一个类仅有一个实例,并
  2. 提供一个访问它的全局访问点

有一些对象我们往往只需要一个,比如

  1. 线程池、
  2. 全局缓存、
  3. 浏览器中的 window 对象
  4. 等。

在 JavaScript 开发中,单例模式的用途同样非常广泛。

试想一下,当我们单击登录按钮的时候,页面中会出现一个登录浮窗,而这个登录浮窗是唯一的,无论单击多少次登录按钮,这个浮窗都只会被创建一次,那么这个登录浮窗就适合用单例模式来创建。

4.1 实现单例模式

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

我们可以用下面两种方式来简单实现一个单例模式:

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

// TEST
var a = Singleton.getInstance("sven1");
var b = Singleton.getInstance("sven2");

alert(a === b); // true
var Singleton = function (name) {
this.name = name;
};

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

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

// TEST
var a = Singleton.getInstance("sven1");
var b = Singleton.getInstance("sven2");

alert(a === b); // true

这种方式相对简单,但有一个问题,就是增加了这个类的 “不透明性”

Singleton 类的使用者必须知道这是一个单例类,跟以往通过 new XXX 的方式来获取对象不同,这里偏要使用 Singleton.getInstance 来获取对象。

4.2 透明的单例模式

var CreateDiv = (function () {
var instance;

var CreateDiv = function (html) {
if (instance) {
return instance;
}
this.html = html;
this.init();
return (instance = this);
};

CreateDiv.prototype.init = function () {
var div = document.createElement("div");
div.innerHTML = this.html;
document.body.appendChild(div);
};

return CreateDiv;
})();

var a = new CreateDiv("sven1");
var b = new CreateDiv("sven2");

alert(a === b); // true

为了把 instance 封装起来,我们使用了自执行的匿名函数和闭包,并且让这个匿名函数返回真正的 Singleton 构造方法,这增加了一些程序的复杂度,阅读起来也不是很舒服。

在这段代码中,CreateDiv 的构造函数实际上负责了两件事情。

var CreateDiv = function (html) {
if (instance) {
// 保证只有一个对象
return instance;
}
this.html = html;
this.init(); // 执行 init 方法
return (instance = this); // 创建 instance
};
  1. 创建对象和执行初始化 init 方法,

  2. 保证只有一个对象。

这是一种不好的做法(违背了 单一职责原则),至少这个构造函数看起来很奇怪。

4.3 用代理实现单例模式

现在我们通过引入代理类,来解决上面的问题(违背了单一职责原则)。

首先,将负责管理单例的代码移除,使剩余代码成为一个普通的创建 div 的类:

var CreateDiv = function (html) {
this.html = html;
this.init(); // 执行 init 函数
};

// 创建 init 方法
CreateDiv.prototype.init = function () {
var div = document.createElement("div");
div.innerHTML = this.html;
document.body.appendChild(div);
};

接下来引入代理类 ProxySingletonCreateDiv

var ProxySingletonCreateDiv = (function () {
var instance;
return function (html) {
if (!instance) {
// 保证只有一个对象
instance = new CreateDiv(html);
}
return instance;
};
})();

var CreateDiv = function (html) {
this.html = html;
this.init(); // 执行 init 函数
};

// 创建 init 方法
CreateDiv.prototype.init = function () {
var div = document.createElement("div");
div.innerHTML = this.html;
document.body.appendChild(div);
};

var a = new ProxySingletonCreateDiv("sven1");
var b = new ProxySingletonCreateDiv("sven2");

alert(a === b);

通过引入代理类的方式,同样完成了一个单例模式的编写。

跟之前不同的是,现在我们把负责管理单例的逻辑移到了代理 proxySingletonCreateDiv 中。

CreateDiv 就变成了一个普通的类,它跟 proxySingletonCreateDiv 组合起来可以达到单例模式的效果。

本例是缓存代理的应用之一。

4.4 JavaScript 中的单例模式

单例对象从“类”中创建而来。

在以类为中心的语言中,这是很自然的做法。

在 Java 中,如果需要某个对象,就必须先定义一个类,对象总是从类中创建而来的。

JavaScript 其实是一门无类(class-free)语言,也正因为如此,生搬单例模式的概念并无意义。

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

全局变量不是单例模式。

但在 JavaScript 开发中,我们经常会把全局变量当成单例来使用。

全局变量存在很多问题,它很容易造成命名空间污染。

Douglas Crockford 多次把全局变量称为 JavaScript 中最糟糕的特性。

在对 JavaScript 的创造者 Brendan Eich 的访谈中,Brendan Eich 本人也承认全局变量是设计上的失误,是在没有足够的时间思考一些东西的情况下导致的结果。

作为普通的开发者,我们有必要尽量减少全局变量的使用,即使需要,也要把它的污染降到最低。

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

1.使用命名空间

最简单的方法依然是用 对象字面量 的方式:

var namespace1 = {
a: function () {
alert(1);
},
b: function () {
alert(2);
},
};

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

另外,也可以 动态地创建命名空间

var MyApp = {};

MyApp.namespace = function (name) {
var parts = name.split(".");
var current = MyApp;
for (var i in parts) {
if (!current[parts[i]]) {
current[parts[i]] = {};
}
current = current[parts[i]];
}
};

MyApp.namespace("event");
MyApp.namespace("dom.style");

console.dir(MyApp);

// 上述代码等价于:

var MyApp = {
event: {},
dom: {
style: {},
},
};

2.使用闭包封装私有变量

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

// 这里使用函数作用域 / 闭包,来创建了真正的私有变量。
var user = (function () {
var __name = "sven",
__age = 29;

return {
getUserInfo: function () {
return __name + "-" + __age;
},
};
})();

user.getUserInfo(); // 'sven-29'

__name__age 被封装在闭包产生的作用域中,外部是访问不到这两个变量的,这就避免了对全局的命令污染。

__name__age 只能通过我们暴露的接口 user.getUserInfo() 来访问。

JavaScript 中,经常使用一个或多个下划线 _ 被放在变量名的开始,来表示它是私有的。

4.5 惰性单例

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

惰性单例是单例模式的重点,这种技术在实际开发中非常有用。

实际上在本章开头就使用过这种技术,instance 实例对象总是在我们调用 Singleton.getInstance 的时候才被创建,而不是在页面加载好的时候就创建。

以 Web QQ 的登陆浮窗为例

当点击左边导航里的 QQ 头像时,会弹出一个登陆浮窗。

很明显这个浮窗在页面里总是唯一的,不会同时存在两个登陆浮窗。

有两种解决方案:

  1. 在页面加载完成后就创建这个浮窗

    这种方式的问题是,如果用户不打算登陆,只是简单逛逛,那就浪费了性能和 DOM 节点。

  2. 在用户点击头像时,才创建该浮窗。

    这才是更合理的处理方式。

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';
};

4.6 通用的惰性单例

虽然我们上面的代码实现了单例模式,也完成了需求,

但这段代码仍然是违反单一职责原则的,创建对象管理单例的逻辑都放在 createLoginLayer 对象内部。

如果我们下次需要在页面中创建一个唯一的 iframe, script ... 其他标签,就必须如法炮制,再几乎把 createLoginLayer 函数抄一遍。

var createIframe = (function () {
var iframe;
return function () {
if (!iframe) {
iframe = document.createElement("iframe");
iframe.style.display = "none";
document.body.appendChild(iframe);
}
return iframe;
};
})();

我们可以将 createDiv, createIframe, createScript ... 等函数传入 getSingle

之后 getSingle 再返回一个函数,并且用一个 result 来保存 fn 的计算结果。

result 变量因为身在闭包中,它永远不会被销毁。

这样就不需要声明全局变量。

在将来的请求中,如果 result 已经被赋值,那么它将返回这个值。

这样就完成了函数分解。

var getSingle = function (fn) {
var result;
return function () {
return result || (result = fn.apply(this, arguments));
};
};

更奇妙的是,创建对象管理单例的职责被分布在两个不同的方法中,这两个方法组合起来才具有单例模式的威力。

单例模式的其他用途

这种单例模式的用途远不止创建对象。

比如我们通常渲染完页面中的一个列表之后,接下来要给这个列表绑定 click 事件,如果是通过 AJAX 动态往列表里追加数据。

在使用事件代理的前提下,click 事件实际上只需要在第一次渲染列表的时候绑定一次,

但是我们不想去判断当前是否是第一次渲染列表,如果借助 jQuery 和 Vue 等 JavaScript 框架,我们可以直接使用内置的 oneonce 等语法糖。

除此之外,我们也能利用 getSingle 函数,达到一样的效果:

// getSingle 函数确保传入的函数只被调用一次
var getSingle = function (fn) {
var result;
return function () {
// 如果 result 未定义,则调用 fn 并缓存返回值,否则直接返回缓存的 result
return result || (result = fn.apply(this, arguments));
};
};

// bindEvent 使用 getSingle 包裹,确保内部逻辑只执行一次
var bindEvent = getSingle(function () {
document.getElementById("div1").onclick = function () {
alert("click");
};
return true; // 返回值将被缓存
});

var render = function () {
console.log("开始渲染列表");
bindEvent(); // 第一次调用时,设置点击事件,之后的调用则不执行函数内的逻辑
};

// 这里 render 被调用三次,但 bindEvent 内的逻辑只会执行一次
render();
render();
render();

render 函数和 bindEvent 函数都分别执行了 3 次,但 div 实际上只被绑定了一个事件。