单例模式的定义是:
保证一个类仅有一个实例,并提供一个访问它的全局访问点。
单例模式是一种常用的模式,有一些对象我们往往只需要一个,比如线程池、全局缓存、浏览器中的 window 对象等。在 JavaScript 开发中,单例模式的用途同样非常广泛。试想一下,当我们单击登录按钮的时候,页面中会出现一个登录浮窗,而这个登录浮窗是唯一的,无论单击多少次登录按钮,这个浮窗都只会被创建一次,那么这个登录浮窗就适合用单例模式来创建
1.js中的单例设计模式
全局变量不是单例模式,但在 JavaScript 开发中,我们经常会把全局变量当成单例来使用。
例如为了实现鼠标滑动特效,我们定义一些方法
function get(id) {
return document.getElementById(id);
}
function css(id, key, value) {
get(id).style[key] = value;
}
function attr(id, key, value) {
get(id)[key] = value;
}
function html(id, value) {
get(id).innerHTML = value;
}
function on(id, type, fn) {
get(id)[`on${type}`] = fn;
}
当用这种方式创建函数的时候,函数确实是独一无二的。函数被声明在全局作用域下, 则我们可以在代码中的任何位置使用这个变量,全局变量提供给全局访问是理所当然的。这样就满足了单例模式的两个条件。
但是全局变量存在很多问题,它很容易造成命名空间污染。在大中型项目中,如果不加以限制和管理,程序中可能存在很多这样的变量。JavaScript 中的变量也很容易被不小心覆盖,相信每个 JavaScript 程序员都曾经历过变量冲突的痛苦,就像上面的各种函数,随时有可能被别人覆盖。
1.1 命名空间
适当地使用命名空间,并不会杜绝全局变量,但可以减少全局变量的数量。 最简单的方法依然是用对象字面量的方式:
我们可以直接将上面的代码改为下面这样:
var Hao = {
get(id) {
return document.getElementById(id);
},
css(id, key, value) {
this.get(id).style[key] = value;
},
// ....
}
1.2 模块分明
在js中单例设计模式除了定义命名空间,还可以管理代码库中的各个设计模块,比如早期百度tangram,雅虎的YUI都是通过单例设计模式来控制自己每个功能模块的。其实就是将功能相关的放在同一个对象的同一个模块中。
例如:
baidu.dom.addClass //添加元素累
baidu.dom.append //插入元素
baidu.event.stopPropagation // 阻止冒泡
baidu.event.preventDeafult //阻止默认行为
1.3使用闭包封装私有变量
将一些变量封装在函数的内部,,只暴露一些接口给外部使用
var user = (function(){
var _name = ''hcd,
_age = 24;
return {
getUserInfo: function(){
return _name + '-' + _age;
}
}
})()
我们用下划线来约定私有变量_name 和_age,它们被封装在闭包产生的作用域中,外部是访问不到这两个变量的,这就避免了对全局的命令污染。
2. 实现单例模式
2.1 简单的单例模式
要实现一个标准的单例模式并不复杂,无非是用一个变量来标志当前是否已经为某个类创建过对象,如果是,则在下一次获取该类的实例时,直接返回之前创建的对象。代码如下:
const Obj = function(name){
this.name = name;
this.instance = null;
}
Obj.prototype.say = function() {
console.log(this.name);
}
Obj.get = function(name) {
if(!this.instance) {
return this.instance = new Obj(name);
}
return this.instance;
}
let hcd = Obj.get('hcd');
let asd = Obj.get('asd');
console.log(hcd == asd); //true
我们通过 Obj.get
来获取 Obj
类的唯一对象,这种方式相对简单,但有 一个问题,就是增加了这个类的“不透明性”, Obj
类的使用者必须知道这是一个单例类, 跟以往通过 new XXX 的方式来获取对象不同,这里偏要使用 Obj
来获取对象
2.2 透明的单例模式
我们现在的目标是实现一个“透明”的单例类,用户从这个类中创建对象的时候,可以像使用其他任何普通类一样。通过new
来创建对象。
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 构造方法,这增加了一些程序的复杂度,阅读起来也不是很舒服。
观察现在的 单例 构造函数:
var CreateDiv = function( html ){
if ( instance ){
return instance;
}
this.html = html;
this.init();
return instance = this;
};
在这段代码中,CreateDiv 的构造函数实际上负责了两件事情。第一是创建对象和执行初始化 init 方法,第二是保证只有一个对象。
根据“单一职责原则”的概念, 这是一种不好的做法。
假设我们某天需要利用这个类,在页面中创建千千万万的 div,即要让这个类从单例类变成 一个普通的可产生多个实例的类,那我们必须得改写 CreateDiv 构造函数,把控制创建唯一对象的那一段去掉,这种修改会给我们带来不必要的烦恼。
2.3 代理实现单例模式
现在我们通过引入代理类的方式,来解决上面提到的问题。
我们依然使用上面的代码,首先在 CreateDiv 构造函数中,把负责管理单例的代码移除出去,使它成为一个普通的创建 div 的类:
var CreateDiv = function (html) {
this.html = html;
this.init();
};
CreateDiv.prototype.init = function () {
var div = document.createElement('div');
div.innerHTML = this.html;
document.body.appendChild(div);
};
var ProxySingletonCreateDiv = (function () {
var instance;
return function (html) {
if (!instance) {
instance = new CreateDiv(html);
}
return instance;
}
})();
var a = new ProxySingletonCreateDiv('sven1');
var b = new ProxySingle
通过引入代理类的方式,我们同样完成了一个单例模式的编写,跟之前不同的是,现在我们 把负责管理单例的逻辑移到了代理类 proxySingletonCreateDiv 中。这样一来,CreateDiv 就变成了 一个普通的类,它跟 proxySingletonCreateDiv 组合起来可以达到单例模式的效果。
3. 惰性单例
惰性单例指的是在需要的时候才创建对象实例。
惰性单例是单例模式的重点,这种技术在实际开发中非常有用,有用的程度可能超出了我们的想象,在2.1的简单单例中就使用过这种技术, instance 实例对象总是在我们调用 Obj.get
的时候才被创建,而不是在页面加载好的时候就创建,代码如下:
Obj.get = function(name) {
if(!this.instance) {
return this.instance = new Obj(name);
}
return this.instance;
}
3.1惰性单例实例
下面我们来举一个WebQQ 的登录浮窗的例子。
假设我们是 WebQQ 的开发人员(网址是web.qq.com),当点击左边导航里 QQ 头像时,会弹 出一个登录浮窗(如图 4-1 所示),很明显这个浮窗在页面里总是唯一的,不可能出现同时存在 两个登录窗口的情况。
第一种解决方案:
在页面加载完成的时候便创建好这个 div 浮窗,这个浮窗一开始肯定是隐藏状态的,当用户点击登录按钮的时候,它才开始显示:
<html>
<body>
<button id="loginBtn">登录</button> </body>
<script>
var loginLayer = (function(){
var div = document.createElement( 'div' );
div.innerHTML = '我是登录浮窗';
div.style.display = 'none';
document.body.appendChild( div );
return div;
})();
document.getElementById( 'loginBtn' ).onclick = function(){
loginLayer.style.display = 'block';
};
</script>
</html>
这种方式有一个问题,也许我们进入 WebQQ 只是玩玩游戏或者看看天气,根本不需要进行 2 登录操作,因为登录浮窗总是一开始就被创建好,那么很有可能将白白浪费一些 DOM 节点。
第二种解决方案:
用户点击登录按钮的时候才开始创建该浮窗:
<html>
<body>
<button id="loginBtn">登录</button> </body>
<script>
var createLoginLayer = function(){
var 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';
};
</script>
</html>
虽然现在达到了惰性的目的,但失去了单例的效果。当我们每次点击登录按钮的时候,都会 创建一个新的登录浮窗 div。虽然我们可以在点击浮窗上的关闭按钮时(此处未实现)把这个浮 窗从页面中删除掉,但这样频繁地创建和删除节点明显是不合理的,也是不必要的。
第三种解决方案:
我们可以用一个变量来判断是否已经创建过登录浮窗
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';
};
3.2通用的惰性单例
前面我们完成了一个可用的惰性单例,但是我们发现它还有如下一些问题。
这段代码仍然是违反单一职责原则的,创建对象和管理单例的逻辑都放在 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;
}
})();
我们需要把不变的部分隔离出来,先不考虑创建一个 div 和创建一个 iframe 有多少差异,管 理单例的逻辑其实是完全可以抽象出来的,这个逻辑始终是一样的:用一个变量来标志是否创建 过对象,如果是,则在下次直接返回这个已经创建好的对象:
var obj;
if ( !obj ){
obj = xxx;
}
现在我们就把如何管理单例的逻辑从原来的代码中抽离出来,这些逻辑被封装在 getSingle 函数内部,创建对象的方法 fn 被当成参数动态传入 get 函数
var getSingle = function( fn ){
var result;
return function(){
return result || ( result = fn .apply(this, arguments ) );
}
};
接下来将用于创建登录浮窗的方法用参数 fn 的形式传入 getSingle,我们不仅可以传入 createLoginLayer,还能传入 createScript、createIframe、createXhr 等。之后再让 getSingle 返回 一个新的函数,并且用一个变量 result 来保存 fn 的计算结果。result 变量因为身在闭包中,它永远不会被销毁。在将来的请求中,如果 result 已经被赋值,那么它将返回这个值。代码如下:
var createLoginLayer = function(){
var div = document.createElement( 'div' );
div.innerHTML = '我是登录浮窗';
div.style.display = 'none';
document.body.appendChild( div );
return div;
};
var createSingleLoginLayer = getSingle( createLoginLayer );
document.getElementById( 'loginBtn' ).onclick = function(){
var loginLayer = createSingleLoginLayer();
loginLayer.style.display = 'block';
};
在这个例子中,我们把创建实例对象的职责和管理单例的职责分别放置在两个方法里,这两 个方法可以独立变化而互不影响,当它们连接在一起的时候,就完成了创建唯一实例对象的功能, 看起来是一件挺奇妙的事情。
3.3 惰性单例的更过应用
我们通常渲染完页面中的一个列表之后,接下来 9 要给这个列表绑定 click 事件,如果是通过 ajax 动态往列表里追加数据,在使用事件代理的前提下,click 事件实际上只需要在第一次渲染列表的时候被绑定一次,但是我们不想去判断当前是 否是第一次渲染列表,如果借助于 jQuery,我们通常选择给节点绑定 one 事件:
var bindEvent = function(){
$( 'div' ).one( 'click', function(){
alert ( 'click' );
});
};
var render = function(){
console.log( '开始渲染列表' );
bindEvent();
};
render();
render();
render();
如果利用通用惰性单例getSingle 函数,也能达到一样的效果。代码如下:
var bindEvent = getSingle(function(){
document.getElementById( 'div1' ).onclick = function(){
alert ( 'click' );
}
return true;
});
var render = function(){
console.log( '开始渲染列表' );
bindEvent();
};
render();
render();
render();
可以看到,render 函数和 bindEvent 函数都分别执行了 3 次,但 div 实际上只被绑定了一个 事件。
4.总结
单例模式是一个只允许实例化一次的对象类,有事这么做也是为了节省资源。当然js中的单例模式经常被用作命名空间对象来实现,通过单例模式我们可以将各个模块管理得井井有条。
所以如果你只想在系统中存在一个同一类对象,可以考虑单例模式。
参考资料
JavaScript设计模式与开发实践----曾探
JavaScript设计模式----张容铭