第四章 单例模式
单例模式的定义是:
- 保证一个类仅有一个实例,并
- 提供一个访问它的全局访问点。
有一些对象我们往往只需要一个,比如
- 线程池、
- 全局缓存、
- 浏览器中的 window 对象
- 等。
在 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
};
-
创建对象和执行初始化
init
方法, -
保证只有一个对象。
这是一种不好的做法(违背了 单一职责原则),至少这个构造函数看起来很奇怪。
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);
},
};
把 a
和 b
都定义为 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 头像时,会弹出一个登陆浮窗。
很明显这个浮窗在页面里总是唯一的,不会同时存在两个登陆浮窗。
有两种解决方案:
-
在页面加载完成后就创建这个浮窗
这种方式的问题是,如果用户不打算登陆,只是简单逛逛,那就浪费了性能和 DOM 节点。
-
在用户点击头像时,才创建该浮窗。
这才是更合理的处理方式。
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 框架,我们可以直接使用内置的 one
,once
等语法糖。
除此之外,我们也能利用 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
实际上只被绑定了一个事件。