单体模式是javascript中最基本但又最有用的模式之一,它可能比其他任何模式都更常用。这种模式提供了一种将代码组织为一个逻辑单元的手段,这个逻辑单元中的代码可以通过单一的变更进行访问。通过确保单体对象只存在一份实例,你就可以确信自己的所有代码使用的都是全局资源。
这种模式在javascript中非常重要,也许比在其他任何语言中都更重要。在网页上使用全局变量有很大的风险,而用单体对象创建的命名空间则是清除这些全局变量的最佳手段之一。仅此一个原因你就该掌握这种模式,更别说它还有许多别的用途。
单体的基本结构
最简单的单体实际上就是一个对象字面量,它把一批有一定关联的方法和属性组织在一起:
/* Basic Singleton. */ var Singleton = { attribute1: true, attribute2: 10, method1: function() { }, method2: function(arg) { } }; Singleton.attribute1 = false; var total = Singleton.attribute2 + 5; var result = Singleton.method1();
在这个示例中,所有那些成员现在都可以通过变量Singleton来访问。为此可以使用圆点运算符:
Singleton.attribute1= false;
var total =Singleton.attribute2+5;
var result =Singleton.method1();
这个单体对象可以被修改。你可以为其添加新成员,这一点与别的对象字面量没什么不同。你也可以用delete运算符删除其现有成员。这实际上违背了面向对象的设计的一条原则:类可以被扩展,但不应该被修改
你可能还没发觉这种单体对象与普通对象字面量有什么不同。按传统定义,单体是一个只能被实例化一次并且可以通过一个众所周知的访问点访问的类。要是严格地按这个定义来说,前面的例子所示的并不是一个单体,因为它不是一个可实例化的类。我们打算把单体模式定义得更广义一些:单体是一个用来划分命名空间并将一批相关方法和属性组织在一起的对象,如果它可以被实例化,那么它只能被实例化一次。
对象字面量只是用以创建单体的方法之一。
划分命名空间
单体对象由两个部分组成:包含着方法和属性成员的对象自身,以及用来访问它的变量。这个变量通常是全局性的,以便在网页上任何地方都能直接访问到它所指向的单体对象。这个单体对象的所有成员都被包装在这个对象中,所以它们不是全局性的。由于这些成员只能通过这个单体对象变量进行访问,因此在某种意义上,可以说它们被单体对象圈在了一个命名空间中。
命名空间是可靠的javascript编程的一个重要工具。在javascript中什么都可以被改写,程序员一不留神就会擦除一个变量、函数甚至整个类,而自己却毫无察觉。这种错误找起来非常费时:
/* Declared globally. */ function findProduct(id) { ... } ... // Later in your page, another programmer adds... var resetProduct = $('reset-product-button'); var findProduct = $('find-product-button'); // The findProduct function just got // overwritten.
为了避免无意中的改写变量,最好的解决办法之一就是用单体对象将代码组织在命名空间中。下面的例子就是用单体模式改良后的结果:
/* Using a namespace. */ var MyNamespace = { findProduct: function(id) { ... }, // Other methods can go here as well. } ... // Later in your page, another programmer adds... var resetProduct = $('reset-product-button'); var findProduct = $('find-product-button'); // Nothing was overwritten.
现在findProduct函数是MyNamespace中的一个方法,它不会被全局命名空间中声明的任何新变更改写。要注意,该方法仍然可以从各个地方访问。不同之处在于现在其调用方式不是findProduct(id),而是MyNamespace.findProduct(id)。这还有一个好处就是,这可以让其他程序员大体知道这个方法的声明地点及作用。用命名空间把类似的方法组织到一起,也有助于增强代码的文档性。
但是,要指出的是,MyNamespace是一个糟糕的单体名字,命名空间应该能够说明其中的代码的用途,在本例中用ProductTools这个名字更为恰当。
命名空间还可以进一步分割。现在网页上的javascript代码往往不止有一个来源。其中除了你写的代码外,还会有库代码、广告代码和徽章代码。这些变量都出现在全局命名空间中。为了避免冲突,可以定义一个用来包含自己的所有代码的全局对象:
* GiantCorp namespace. */
var GiantCorp = {};
然后可以分门别类地把自己的代码和数据组织到这个全局对象中的各个对象(单体)中:
GiantCorp.Common = { // A singleton with common methods used by all objects and modules. }; GiantCorp.ErrorCodes = { // An object literal used to store data. }; GiantCorp.PageHandler = { // A singleton with page specific methods and attributes. };
来源外部的代码与GiantCorp这个变量发生冲突的可能性很小。如果真有冲突,其造成的问题会非常明显,所以很容易发现。想到自己办事牢靠,没有把全局命名空间搞得一片狼藉,你大可高枕无忧。你只是在全局命名空间中加入了一个变量,这是一个javascript程序员可望获得的最小地盘。
拥有私用成员的单体
我们在前面的章节中讨论过几种创建类的私用成员的做法,使用真正私用方法的一个缺点在于它们比较耗费内存,因为每个实例都具有方法的一份新副本。不过由于单体对象只会被实例化一次,因此为其定义真正的私用方法时必顾虑内存方法的问题。
使用下划线表示方法
在单体对象内创建私用成员最简单、最直接的办法就是用下划线表示法,这可以让其他程序员知道相关方法或属性是私用的,只在对象内部使用。在单体对象中使用下划线表示法是一种告诫其他程序员不要直接访问特定成员的简明办法 如下:
/* DataParser singleton, converts character delimited strings into arrays. */ GiantCorp.DataParser = { // Private methods. _stripWhitespace: function(str) { return str.replace(/s+/, ''); }, _stringSplit: function(str, delimiter) { return str.split(delimiter); }, // Public method. stringToArray: function(str, delimiter, stripWS) { if(stripWS) { str = this._stripWhitespace(str); } var outputArray = this._stringSplit(str, delimiter); return outputArray; } };
使用闭包
在单体对象中创建私用成员的第二种办法需要借助闭包。这与js闭包应用文章里提到的真正的私用成员的做法非常相似,但也一个重要区别。先前的做法是把变量和函数定义在构造函数体内(不使用this关键字)以使其成为私用成员,此外还在构造函数体内定义了所有的特权方法并用this关键字使其可被外界访问,每生成一个该类的实例时,所有声明在构造函数内的方法和属性都会再次创建一份。这可能会非常低效。
因为单体只会被实例化一次,所以你不用担心自己在构造函数中声明了多少成员。每个方法和属性都只会被创建一次,所以你可以把它们都声明在构造函数内部。
/* Singleton as an Object Literal. */ MyNamespace.Singleton = {};
现在我们用一个在定义之后立即执行的函数创建单体:
/* Singleton with Private Members, step 1. */ MyNamespace.Singleton = (function() { return {}; })();
对上面的在进行一次改进,添加私有成员以及返回公共函数。任何声明在这个匿名函数中的变量或函数都只能被在同一个闭包中的声明的其他函数访问。这个闭包在匿名函数执行结束后依然存在,所以在其中声明的函数和变量总能从匿名函数所返回的对象内部访问。
/* Singleton with Private Members, step 3. */ MyNamespace.Singleton = (function() { // Private members. var privateAttribute1 = false; var privateAttribute2 = [1, 2, 3]; function privateMethod1() { ... } function privateMethod2(args) { ... } return { // Public members. publicAttribute1: true, publicAttribute2: 10, publicMethod1: function() { ... }, publicMethod2: function(args) { ... } }; })();
这种单体模式又称模块模式,指的是它可以把一批相关方法和属性组织为模块并直到划分命名空间的作用。
两种技术的比较
现在回到DataParser这个例子中来,看看如何在其实现中使用真正的私用成员。现在我们不再为每个私用方法名称的开关添加一个下划线,而是把这些方法定义在闭包中:
/* DataParser singleton, converts character delimited strings into arrays. */ /* Now using true private methods. */ GiantCorp.DataParser = (function() { // Private attributes. var whitespaceRegex = /s+/; // Private methods. function stripWhitespace(str) { return str.replace(whitespaceRegex, ''); } function stringSplit(str, delimiter) { return str.split(delimiter); } // Everything returned in the object literal is public, but can access the // members in the closure created above. return { // Public method. stringToArray: function(str, delimiter, stripWS) { if(stripWS) { str = stripWhitespace(str); } var outputArray = stringSplit(str, delimiter); return outputArray; } }; })(); // Invoke the function and assign the returned object literal to // GiantCorp.DataParser.
现在这些私用方法和属性可能直接用其名称访问,不必在其前面加上this或GiantCrop.DataParser.,这些前缀只用于访问于单体对象的公用成员。
这种模式与使用下划线表示法模式相比 有几点优势。把私用成员放到闭包中可以确保其不会在单体对象之外被使用。你可以自由地改变对象的实现细节,这不会殃及别人的代码。还可以用这种方法对数据进行保护和封装。
在使用这种模式时,你可以享受到真正的私用成员带来的所有好处,而不必付出什么代价,这是因为单体类只会被实例化一次。单体模式之所以是javascript最流行、应用最广泛的模式之一,原因即在于此。
惰性实例化
前面所讲的单体模式各种实现方式有一个共同点:单体对象都是在脚本加载时被创建出来。对于资源密集型或配置开销甚大的单体,也许便合理的做法是将其实例化推迟到需要使用它的时候。这种技术被称为惰性加载,它最常用于那些必须加载大量数据的单体。而那些被作用的命名空间、特定网页专用代码包装器或组织相关实用方法的工具的单体最好还是立即实例化。
这种惰性加载单体的特别之处在于,对它们的访问必须借助于一个静态方法。应该这样调用其方法:Singleton.getInstance().methodName(), 而不是这样用调用:Singleton.methodName()。getInstance方法会检查该单体是否已经被实例化。如果还没有,那么它将创建并返回其实例。如果单体已经实例化过,那么它将返回现有的实例。下面我们从前面那个拥有真正的私用成员的单体的基本框架出发示范一下如何把普通单体转化为惰性加载单体:
/* General skeleton for a lazy loading singleton, step 1. */ MyNamespace.Singleton = (function() { function constructor() { // All of the normal singleton code goes here. // Private members. var privateAttribute1 = false; var privateAttribute2 = [1, 2, 3]; function privateMethod1() { ... } function privateMethod2(args) { ... } return { // Public members. publicAttribute1: true, publicAttribute2: 10, publicMethod1: function() { ... }, publicMethod2: function(args) { ... } } } })(); /* General skeleton for a lazy loading singleton, step 2. */ MyNamespace.Singleton = (function() { function constructor() { // All of the normal singleton code goes here. ... } return { getInstance: function() { // Control code goes here. } } })(); /* General skeleton for a lazy loading singleton, step 3. */ MyNamespace.Singleton = (function() { var uniqueInstance; // Private attribute that holds the single instance. function constructor() { // All of the normal singleton code goes here. ... } return { getInstance: function() { if(!uniqueInstance) { // Instantiate only if the instance doesn't exist. uniqueInstance = constructor(); } return uniqueInstance; } } })();
以上使我们对模块模式的修改。转化工作的第一步是把单体的所有代码移到一个名为constructor方法中。
把一个单体转化为惰性加载单体后,你必须对调用它的代码进行修改。在本例中,像这样的方法调用:
MyNamespace.Singleton.publicMethod1();
应该转换为
MyNamespace.Singleton.getInstance().publicMethod1();
/* DataParser singleton, converts character delimited strings into arrays. */ GiantCorp.DataParser = { // Private methods. _stripWhitespace: function(str) { return str.replace(/s+/, ''); }, _stringSplit: function(str, delimiter) { return str.split(delimiter); }, // Public method. stringToArray: function(str, delimiter, stripWS) { if(stripWS) { str = this._stripWhitespace(str); } var outputArray = this._stringSplit(str, delimiter); return outputArray; } };
分支
分支:是一种用来把浏览器间的差异封装在运行期间进行设置的动态方法中的技术。如:我们需要创建一个返回XHR对象的方法。这种XHR对象在大多数浏览器中是XMLHttpRequest类的实例,而在IE早期版本则是某种ActiveX类的实例。这样一个方法通常会进行某种浏览器嗅探或对象探测。如果不用分支技术,那么每次调用这个方法时,所有那些流利器嗅探代码要再次运行。要是这个方法调用的很频繁,那么这样做会严重缺乏效率。
更有效的做法是只在脚本加载时一次性地确定针对特定浏览器的代码。这样一来,在初始化完成之后,每种浏览器都只会执行针对它的javascript实现而设计的代码。
我们可以创建两个不同的对象字面量,并根据某种条件将其中之一赋给那个变量
MyNamespace.Singleton= (function(){ var objA = { method1:function(){ }, method2:function(){ } }; var objB = { method1:function(){ }, method2:function(){ } } return (someCondition)?objA:objB; })();
上述代码中创建了两个对象字面量,它们拥有相同的一套方法。对于使用这个单体的程序员来说,赋给MyNamespace.Singleton的究竟是哪个对象无关紧要,因为这两个对象实现了同样的接口,可以执行同样的任务,不同之处仅仅在于对象的方法具体使用的代码。分支技术不总是高效的选择。在前面的例子中,有两个对象被创建出来并保存在内存中,但派上用场的只有一个。在考虑是否使用这种技术的时候,你必须在缩短计算时间和占用更多内存这一利一弊之间权衡一下。
用分支技术创建XHR对象
/*SimpleXhrFactorysingleton step 1. */ var SimpleXhrFactor= (function(){ var standard = { createXhrObject:function(){ return newXMLHttpRequest(); } } var activeXNew = { createXhrObject:function(){ return new ActiveXObject(“Msxml2.XMLHTTP”); } } var activeXOld = { createXhrObject:function(){ return new ActiveXObject(“Microsoft.XMLHTTP”); } } })();
创建分支型单体的第2步是根据条件将3个分支中某一分支的对象赋给那个变量。其具体做法是逐一尝试每种XHR对象,直到遇到一个当前javascript环境所支持的对象为止:step 2.
/*SimpleXhrFactorysingleton step 1. */ var SimpleXhrFactor= (function(){ var standard = { createXhrObject:function(){ return newXMLHttpRequest(); } } var activeXNew = { createXhrObject:function(){ return new ActiveXObject(“Msxml2.XMLHTTP”); } } var activeXOld = { createXhrObject:function(){ return new ActiveXObject(“Microsoft.XMLHTTP”); } } var testObject; try{ testObject = standard.createXhrObject(); return standard; }catch(e){ try{ testObject =activeXNew.createXhrObject(); return activeXNew; }catch(e){ try{ testObject =activeXOld.createXhrObject(); return activeXOld; }catch(e){ throw new Error(‘No XHRobject found in this environment. ’); } } } })();