转自:http://dickeylth.github.io/2013/10/11/JavaScriptDesignPatterns-SingletonPattern/
最近开始系统学习设计模式,虽然以前偶尔有接触,但总感觉不够系统,正好需要做这方面的分享,遂决定来系统学习和记录一下。
设计模式是程序设计中老生常谈的话题了,简单说就是针对某些可抽象为类似问题的通用的解决方案。虽然方案的思路是死的,但在不同语言中的实现由于语言间的特性会有些差异,尤其对于像 JavaScript 这样的动态语言而言,可能某些设计模式实现起来相比静态语言更为灵活。
当然,除了提供通用的解决方案,个人感觉更重要的是设计模式的出现提供了各种模块解耦的思路,为什么需要模块解耦?因为大多数时候我们不太可能总是推倒从来,而往往是在现有的系统基础之上做进一步的优化完善,系统中模块之间的耦合度越低,可扩展性就会越强,从而可以支撑更为复杂的业务场景和需求。另外,设计模式也是为了应对驾驭复杂系统的代码组织架构,熟练掌握之后会对于系统的架构有更进一步的认识,从而提升自己在业务上独当一面的能力。
其实,设计模式并不是很遥远的东西,很可能很多时候自己已经在用了而没有感觉出来,比如 JavaScript 中的全局唯一变量就可以看作是一种单例模式。更宏观一点来看,其实设计模式在社会中早已存在,在计算机被创造出来,人类已经在应用它了,比如“烽火戏诸侯”不就是一种观察者模式(也叫 pub-sub 注册发布模式)?所以在这个系列中,我会尽可能从贴近生活的角度来阐释每种模式在身边的例子,从而更易于理解模式的思想。
因此,基于 JavaScript 的设计模式,更多地应该考虑从语言特性、场景和环境出发,不求形似但求神似,重要是模式中传达出的解决思路,领悟了这一点比死记硬背要有用得多,当然还离不开最重要的熟练应用。在这个系列中让我们一起来看一下设计模式与 JavaScript 会碰撞出什么样的火花。
参考数目:
Learning JavaScript Design Patterns
JavaScript Patterns
一、要解决的问题
单例模式主要目的是确保系统中某个类只存在唯一一个实例,也就是说对于这个类的重复实例的创建始终只返回同一个实例。它和工厂模式一样主要是为了解决对象的创建问题。从前面的描述我们可以看出单例模式的几大特点:
- 这个类只有一个实例;
- 该类需要负责实例的初始化工作;
- 对外需提供这个唯一实例的访问接口。
生活中有单例模式存在吗?有,比如大家都知道的12306 是唯一购票网站,所有人要网上订票都得访问这个单例。再比如,法律规定,每个中国男人都只能有一个合法妻子,当然现实之中还有离婚再婚,单例模式更像是理想状况下的白头偕老了。
单例模式带来的好处?除了减少不必要的重复的实例创建、减少内存占用外,更重要的是避免多个实例的存在造成逻辑上的错误。比如超级马里奥的游戏中,虽然各种小怪的实例会不断创建多个,但当前的玩家肯定只有一个,如果游戏运行过程中创建出新的马里奥的实例了,显然就出 bug 了。
二、单例模式的实现方法及分析
2.1对象字面量
对于 Java 之类的静态语言而言,实现单例模式常见的方法就是将构造函数私有化,对外提供一个比如名为getInstance方法的静态接口来访问初始化好的私有静态的类自身实例。但对于 JavaScript 这样的动态语言而言,单例模式的实现其实可以很灵活,因为 JavaScript 语言中并不存在严格意义上的类的概念,只有对象。每次创建出的新对象都和其他的对象在逻辑上不相等,即使它们是具有完全相同的成员属性的同构造器创造出的对象。所以,在 JavaScript 中,最常见的单例模式莫过于对象字面量(object literal)了:
var x = {
attr: 'value'
};
var y = {
attr: 'value'
};
x == y; // false
x === y; // false
可见,对象字面量就是一种最简单最常见的单例模式了。在全局的其他地方要获得这个单例的对象,其实就是获得这个唯一的全局变量就可以保证访问的是同一实例了。
上面的对象字面量仅仅是一个简单的键值对,但很多时候对象可能还涉及到初始化的工作,可能需要实现按需加载(懒加载),对象中还会存在内部私有成员,对外需以门面模式(Facade)
提供可访问的接口。所以我们还可以把这个简单的对象字面量再扩充一下:
var SuperMario = (function(){
var instance = null;
// 初始化函数
function init(){
var gener = 'male',
age = 12,
height = 120;
// 门面模式返回成员属性
return {
name: 'Mario',
getAge: function(){
return age;
},
getHeight: function(){
return height;
},
jump: function(){
console.log("I'm jumping!");
},
run: function(){
console.log("I'm running!");
}
};
}
return {
getInstance: function(){
if(!instance){
instance = init();
}
return instance;
}
};
})();
console.log(SuperMario.getInstance());
在 Chrome 控制台下运行可以得到如下结果:
让我们来简单分析一下这段代码。首先依然是给对象赋值,但是采用的是即时函数的方式,从而创建出一个闭包,里面存放着 SuperMario
的真身——instance
,在结尾时暴露一个getInstance
方法向外提供该实例的引用,有点像静态语言中的单例模式了吧?
在这个闭包之内,创建了一个内部私有的init
初始化函数,完成 SuperMario
对象的初始化工作。注意到这里再一次使用了闭包,将age
、height
这些私有成员的值保护起来,对外只提供getter
访问器,不允许外部代码对其修改。除此之外,还向外提供了可公开的run
、jump
方法。
为了实现懒加载,ini
t初始函数并不是自动执行的,而是调用getInstance
方法时检查到当前instance
还没有被初始化过时才会去执行init
,而在下次再次getInstance
时就直接返回之前已初始化好的实例了,这样就不至于给页面的初始化工作带来太大的负担,而是需要使用的时候按需完成初始化。
2.2 使用new
创建对象
虽然 JavaScript
中没有类,但是却也具有new
这个关键字来利用构造函数创建对象。对于这种形式创建的对象,要实现单例模式的思想,就需要保证每次new
出来的对象都是对同一对象的指针。也就是说预期应该像下面的代码这样:
var x = new SuperMario();
var y = new SuperMario();
x == y; // true
因此需要保证 x
和y
指向的是同一个SuperMario
构造函数构造出的对象,即第二次调用SuperMario
构造函数返回的是第一次调用时构造出的实例的引用,同样以后每次调用该构造函数返回的应该都是这同一实例的引用。那么实现上主要就是要解决这个实例的存放位置问题,有几种选择方案:
- 使用全局变量来存储。当然这种方案一般都不值得推荐;
- 缓存到SuperMario构造函数的静态属性中,实现起来也比较简洁,但缺点是不能避免该静态属性被外部代码
修改,毕竟 JavaScript 不像静态语言能做到对静态属性的写保护; - 借助闭包实现。这样可以确保实例的内部私有性,缺点是额外的开销,这是引入闭包必然会带来的弊端。
下面分别看看后两种方案的具体实现。
2.2.1 静态属性中的实例
采用静态属性的方式代码比较简单易懂,基本的结构类似这样:
// 定义
function SuperMario(){
// 判断当前静态属性是否已存在
if(typeof SuperMario.instance === "object"){
return SuperMario.instance;
}
// 定义属性值
this.name = "Mario";
this.age = 12;
this.gener = "male";
// 缓存到静态属性中
SuperMario.instance = this;
// 可要可不要,默认隐式返回 this
return this;
}
// 执行
var x = new SuperMario();
var y = new SuperMario();
x == y; // true
看上去很简单对吧?不过问题来了:
如果在执行部分添加一行代码:
// 执行
var x = new SuperMario();
SuperMario.instance = null;
var y = new SuperMario();
x == y; // ?
console.log(y); // ?
你肯定已经猜到了此时 x == y
结果是false
,而对于下一行呢?console.log(y)
将输出什么呢?
更进一步地,如果我们在SuperMario
的构造函数中再加一行:
// 定义
function SuperMario(){
this.attr = 'value';
// 判断当前静态属性是否已存在
if(typeof SuperMario.instance === "object"){
return SuperMario.instance;
}
...
}
此时console.log(y)
又会返回什么呢?
其实这里涉及到的是一个构造函数返回值的问题,大多数情况下我们都不会在构造函数中显式返回值,因为默认的 this 会自动隐式返回。说到这里,你可能需要先深入了解下当以new操作符调用构造函数时到底发生了什么?
当以new操作符调用构造函数时,函数内部将会发生以下情况:
- 创建一个空对象并且
this
变量引用了该对象,同时还继承了该函数的原型; - 属性和方法被加入到
this
引用的对象中; - 新创建的对象由
this
所引用,并且最后隐式地返回this
(如果没有显式地返回其他对象)
JavaScript PatternsStoyan Stefanov(中文版 P45)
那么在构造函数中定义了 return
时,以new
调用的结果是怎样的呢?
在 stackoverflow
上也有类似的问题:What values can a constructor return to avoid returning this?,第一个回答的引用,也就是ECMA-262 中定义了返回策略。
我们也可以把结论简单记为两条:
当return
一个引用对象(数组、函数、对象等)时,直接覆盖内部的隐式this
对象,返回值就是该引用对象;当return
5 种基本类型(undefined
、null
、Boolean
、Number
、String
)之一时(无return
时其实就是返回undefined
),返回内部隐式this
对象。
还需要注意一点,基本类型可以以包装器包装成对象,所以:
function SuperMario(){
...
return new String('mario');
return 'mario';
}
两者的返回值就不一样了。
现在你应该可以得出上面的问题的答案了吧?
2.2.2 闭包中的实例
采用闭包的方式一般将初始化后的实例用闭包保护起来,而后重写构造函数直接返回该实例,于是我们可以简单得到以下代码:
function Person(){
var instance = this;
this.attr = "Attribute";
Person = function(){
return instance;
};
}
var p1 = new Person();
var p2 = new Person();
但这样会有什么潜在的问题呢?我们来稍作一点变化:
function Person(){
var instance = this;
this.attr = "Attribute";
Person = function(){
console.log(this);
return instance;
};
}
Person.prototype.job = 'FE';
var p1 = new Person();
Person.prototype.city = 'Beijing';
var p2 = new Person();
console.log(p1);
console.log(p2);
console.log(p1.constructor === Person);
//console.log(Person);
出现什么问题了?之后给Person
类添加的prototype
属性被丢失了,这却是为何?因为我们重写了构造函数,结果月亮还是那个月亮,Person
却不再是那个Person
了。在第二次new Person()
时我们可以打印出此时的this
,可以看到它是继承了后面挂载的city
原型属性的,但因为原来的Person
已经被覆盖了,所以原来的job
属性就找不到了。而后我们return instance
的执行,根据上文中的结论,就会直接覆盖构造函数中的隐式this
,结果就丢掉了后面增加的原型属性city
了。
有没有改进的方法呢?经过了上面的分析,我们就可以知道,要解决这个问题,关键是除了重写构造函数之外,还需要修复继承链和构造函数,于是可以得到下面的代码:
function Person(){
var instance;
Person = function Person(){
return instance;
};
Person.prototype = this; // this
instance = new Person();
instance.constructor = Person;
instance.attr = "Attribute";
return instance;
}
Person.prototype.job = 'FE';
var p1 = new Person();
Person.prototype.city = 'Beijing';
var p2 = new Person();
console.log(p1);
console.log(p2);
console.log(p1.constructor === Person);
其实这个时候重写后的Person
类实质上变成了之前老的Person
类的子类了,它们之间就是通过这句Person.prototype = this
;联系起来的。我们也可以在控制台看看Person
展开后的样子来认识一下这个新的Person
。
最后,留一个小问题:
...
// 重写该构造函数
Person = function Person(){
return instance;
};
这里的·function Person中的Person是干嘛用的呢?
三、在开源框架和类库中的应用
单例模式在开源框架中应用其实很广泛,细数一下我们熟悉的前端开源框架和类库:jQuery
、YUI
、underscore
、KISSY
等,大多都有一个全局变量,比如 jQuery
中的jQuery
(或$
)、YUI
中的YUI
、underscore
中的_
、KISSY
中的KISSY
,这就是一种单例。让我们来看看 jQuery
:
(function( window, undefined ) {
var jQuery = (function() {
// 构建 jQuery 对象
var jQuery = function( selector, context ) {
return new jQuery.fn.init( selector, context, rootjQuery );
}
// jQuery 对象原型
jQuery.fn = jQuery.prototype = {
constructor: jQuery,
init: function( selector, context, rootjQuery ) {
// selector 有以下 7 种分支情况:
// DOM 元素
// body(优化)
// 字符串:HTML 标签、HTML 字符串、#id、选择器表达式
// 函数(作为 ready 回调函数)
// 最后返回伪数组
}
};
// 猜猜这句是干什么呢?
jQuery.fn.init.prototype = jQuery.fn;
// 合并内容到第一个参数中,后续大部分功能都通过该函数扩展
// 通过 jQuery.fn.extend 扩展的函数,大部分都会调用通过 jQuery.extend 扩展的同名函数
jQuery.extend = jQuery.fn.extend = function() {};
// 在 jQuery 上扩展静态方法
jQuery.extend({
// ready bindReady
// isPlainObject isEmptyObject
// parseJSON parseXML
// globalEval
// each makeArray inArray merge grep map
// proxy
// access
// uaMatch
// sub
// browser
});
// 到这里,jQuery 对象构造完成,后边的代码都是对 jQuery 或 jQuery 对象的扩展
return jQuery;
})();
window.jQuery = window.$ = jQuery;
})(window);
通过上面的 jQuery 代码的总体结构,可见它同样是采用的是类似上面对象字面量形式创建全局的 jQuery 对象,在其中又重定义了构造函数,完成初始化工作,最后返回新的 jQuery 对象。
四、总结
通过上面的源码简析,个人觉得,在 JavaScript 中应用单例模式采用对象字面量的方式更易读易懂,应用也更为广泛,而从理论角度采用闭包模拟类似静态语言的单例的概念的方式,虽然也可以实现,但失掉了 JavaScript 作为一门动态语言的优势,而且代码相比之下可维护性差了些。当然采用对象字面量方式需要与使用者达成约定,即直接调用该实例而非通过构造函数来获得实例,这种调用方式也很自然。