模块化模式
模块
模块是任何健壮的应用程序体系结构不可或缺的一部分,特点是有助于保持应用项目的代码单元既能清晰地分离又有组织。
在JavaScript中,实现模块有几个选项,他们包括:
- 模块化模式
- 对象表示法
- AMD模块
- CommonJS 模块
- ECMAScript Harmony 模块
我们在书中后面的现代模块化JavaScript设计模式章节中将探讨这些选项中的最后三个。
模块化模式是基于对象的文字部分,所以首先对于更新我们对它们的知识是很有意义的。
对象字面值
在对象字面值的标记里,一个对象被描述为一组以逗号分隔的名称/值对括在大括号({})的集合。对象内部的名称可以是字符串或是标记符后跟着一个冒号":"。在对象里最后一个名称/值对不应该以","为结束符,因为这样会导致错误。
var myObjectLiteral = { variableKey: variableValue, functionKey: function () { //... }; };
对象字面值不要求使用新的操作实例,但是不能够在结构体开始使用,因为打开"{"可能被解释为一个块的开始。在对象外新的成员会被加载,使用分配如下:
smyModule.property = "someValue";
下面我们可以看到一个更完整的使用对象字面值定义一个模块的例子:
var myModule = { myProperty: "someValue", // 对象字面值包含了属性和方法(properties and methods). // 例如,我们可以定义一个模块配置进对象: myConfig: { useCaching: true, language: "en" }, // 非常基本的方法 myMethod: function () { console.log( "Where in the world is Paul Irish today?" ); }, // 输出基于当前配置(configuration)的一个值 myMethod2: function () { console.log( "Caching is:" + ( this.myConfig.useCaching ) ? "enabled" : "disabled" ); }, // 重写当前的配置(configuration) myMethod3: function( newConfig ) { if ( typeof newConfig === "object" ) { this.myConfig = newConfig; console.log( this.myConfig.language ); } } }; // 输出: Where in the world is Paul Irish today? myModule.myMethod(); // 输出: enabled myModule.myMethod2(); // 输出: fr myModule.myMethod3({ language: "fr", useCaching: false })
使用对象字面值可以协助封装和组织你的代码。如果你想近一步了解对象字面值可以阅读 Rebecca Murphey 写过的关于此类话题的更深入的文章(depth)。
也就是说,如果我们选择了这种技术,我们可能对模块模式有同样的兴趣。即使使用对象字面值,但也只有一个函数的返回值。
模块化模式
模块化模式最初被定义为一种对传统软件工程中的类提供私有和公共封装的方法。
在JavaScript中,模块化模式用来进一步模拟类的概念,通过这样一种方式:我们可以在一个单一的对象中包含公共/私有的方法和变量,从而从全局范围中屏蔽特定的部分。
这个结果是可以减少我们的函数名称与在页面中其他脚本区域定义的函数名称冲突的可能性。
私有信息
模块模式使用闭包的方式来将"私有信息",状态和组织结构封装起来。提供了一种将公有和私有方法,变量封装混合在一起的方式,这种方式防止内部信息泄露到全局中,从而避免了和其它开发者接口发生冲图的可能性。在这种模式下只有公有的API 会返回,其它将全部保留在闭包的私有空间中。
这种方法提供了一个比较清晰的解决方案,在只暴露一个接口供其它部分使用的情况下,将执行繁重任务的逻辑保护起来。这个模式非常类似于立即调用函数式表达式(IIFE-查看命名空间相关章节获取更多信息),但是这种模式返回的是对象,而立即调用函数表达式返回的是一个函数。
需要注意的是,在javascript事实上没有一个显式的真正意义上的"私有性"概念,因为与传统语言不同,javascript没有访问修饰符。从技术上讲,变量不能被声明为公有的或者私有的,因此我们使用函数域的方式去模拟这个概念。在模块模式中,因为闭包的缘故,声明的变量或者方法只在模块内部有效。在返回对象中定义的变量或者方法可以供任何人使用。
历史
从历史角度来看,模块模式最初是在2003年由一群人共同发展出来的,这其中包括Richard Cornford。后来通过Douglas Crockford的演讲,逐渐变得流行起来。另外一件事情是,如果你曾经用过雅虎的YUI库,你会看到其中的一些特性和模块模式非常类似,而这种情况的原因是在创建YUI框架的时候,模块模式极大的影响了YUI的设计。
例子
下面这个例子通过创建一个自包含的模块实现了模块模式。
var testModule = (function () { var counter = 0; return { incrementCounter: function () { return counter++; }, resetCounter: function () { console.log( "counter value prior to reset: " + counter ); counter = 0; } }; })(); // Usage: // Increment our counter testModule.incrementCounter(); // Check the counter value and reset // Outputs: 1 testModule.resetCounter();
在这里我们看到,其它部分的代码不能直接访问我们的incrementCounter() 或者 resetCounter()的值。counter变量被完全从全局域中隔离起来了,因此其表现的就像一个私有变量一样,它的存在只局限于模块的闭包内部,因此只有两个函数可以访问counter。我们的方法是有名字空间限制的,因此在我们代码的测试部分,我们需要给所有函数调用前面加上模块的名字(例如"testModule")
当使用模块模式时,我们会发现通过使用简单的模板,对于开始使用模块模式非常有用。下面是一个模板包含了命名空间,公共变量和私有变量。
var myNamespace = (function () { var myPrivateVar, myPrivateMethod; // A private counter variable myPrivateVar = 0; // A private function which logs any arguments myPrivateMethod = function( foo ) { console.log( foo ); }; return { // A public variable myPublicVar: "foo", // A public function utilizing privates myPublicFunction: function( bar ) { // Increment our private counter myPrivateVar++; // Call our private method using bar myPrivateMethod( bar ); } }; })();
看一下另外一个例子,下面我们看到一个使用这种模式实现的购物车。这个模块完全自包含在一个叫做basketModule 全局变量中。模块中的购物车数组是私有的,应用的其它部分不能直接读取。只存在与模块的闭包中,因此只有可以访问其域的方法可以访问这个变量。
var basketModule = (function () { // privates var basket = []; function doSomethingPrivate() {} function doSomethingElsePrivate() {} // Return an object exposed to the public return{ // Add items to our basket addItem: function( values ) { basket.push(values); }, // Get the count of items in the basket getItemCount: function () { return basket.length; }, // Public alias to a private function doSomething: doSomethingPrivate, // Get the total value of items in the basket getTotal: function () { var q = this.getItemCount(), p = 0; while (q--) { p += basket[q].price; } return p; } }; }());
在模块内部,你可能注意到我们返回了应外一个对象。这个自动赋值给了basketModule 因此我们可以这样和这个对象交互。
// basketModule returns an object with a public API we can use basketModule.addItem({ item: "bread", price: 0.5 }); basketModule.addItem({ item: "butter", price: 0.3 }); // Outputs: 2 console.log( basketModule.getItemCount() ); // Outputs: 0.8 console.log( basketModule.getTotal() ); // However, the following will not work: // Outputs: undefined // This is because the basket itself is not exposed as a part of our // the public API console.log( basketModule.basket ); // This also won't work as it only exists within the scope of our // basketModule closure, but not the returned public object console.log( basket );
上面的方法都处于basketModule 的名字空间中。
请注意在上面的basket模块中 域函数是如何在我们所有的函数中被封装起来的,以及我们如何立即调用这个域函数,并且将返回值保存下来。这种方式有以下的优势:
- 可以创建只能被我们模块访问的私有函数。这些函数没有暴露出来(只有一些API是暴露出来的),它们被认为是完全私有的。
- 当我们在一个调试器中,需要发现哪个函数抛出异常的时候,可以很容易的看到调用栈,因为这些函数是正常声明的并且是命名的函数。
- 正如过去 T.J Crowder 指出的,这种模式同样可以让我们在不同的情况下返回不同的函数。我见过有开发者使用这种技巧用于执行UA(尿检,抽样检查)测试,目的是为了在他们的模块里面针对IE专门提供一条代码路径,但是现在我们也可以简单的使用特征检测达到相同的目的。
模块模式的变体
Import mixins(导入混合)
这个变体展示了如何将全局(例如 jQuery, Underscore)作为一个参数传入模块的匿名函数。这种方式允许我们导入全局,并且按照我们的想法在本地为这些全局起一个别名。
// Global module var myModule = (function ( jQ, _ ) { function privateMethod1(){ jQ(".container").html("test"); } function privateMethod2(){ console.log( _.min([, 5, 0, 2, ]) ); } return{ publicMethod: function(){ privateMethod1(); } }; // Pull in jQuery and Underscore }( jQuery, _ )); myModule.publicMethod();
Exports(导出)
这个变体允许我们声明全局对象而不用使用它们,同样也支持在下一个例子中我们将会看到的全局导入的概念。
// Global module var myModule = (function () { // Module object var module = {}, privateVariable = "Hello World"; function privateMethod() { // ... } module.publicProperty = "Foobar"; module.publicMethod = function () { console.log( privateVariable ); }; return module; }());
工具箱和框架特定的模块模式实现。
Dojo
Dojo提供了一个方便的方法 dojo.setObject() 来设置对象。这需要将以"."符号为第一个参数的分隔符,如:myObj.parent.child 是指定义在"myOjb"内部的一个对象“parent”,它的一个属性为"child"。使用setObject()方法允许我们设置children 的值,可以创建路径传递过程中的任何对象即使这些它们根本不存在。
例如,如果我们声明商店命名空间的对象basket.coreas,可以实现使用传统的方式如下:
var store = window.store || {}; if ( !store["basket"] ) { store.basket = {}; } if ( !store.basket["core"] ) { store.basket.core = {}; } store.basket.core = { // ...rest of our logic };
或使用Dojo1.7(AMD兼容的版本)及以上如下:
require(["dojo/_base/customStore"], function( store ){ // using dojo.setObject() store.setObject( "basket.core", (function() { var basket = []; function privateMethod() { console.log(basket); } return { publicMethod: function(){ privateMethod(); } }; }())); });
欲了解更多关于dojo.setObject()方法的信息,请参阅官方文档。
ExtJS
对于这些使用Sencha的ExtJS的人们,你们很幸运,因为官方文档包含一些例子,用于展示如何正确地在框架里面使用模块模式。
下面我们可以看到一个例子关于如何定义一个名字空间,然后填入一个包含有私有和公有API的模块。除了一些语义上的不同之外,这个例子和使用vanilla javascript 实现的模块模式非常相似。
// create namespace Ext.namespace("myNameSpace"); // create application myNameSpace.app = function () { // do NOT access DOM from here; elements don't exist yet // private variables var btn1, privVar1 = ; // private functions var btn1Handler = function ( button, event ) { console.log( "privVar1=" + privVar1 ); console.log( "this.btn1Text=" + this.btn1Text ); }; // public space return { // public properties, e.g. strings to translate btn1Text: "Button 1", // public methods init: function () { if ( Ext.Ext2 ) { btn1 = new Ext.Button({ renderTo: "btn1-ct", text: this.btn1Text, handler: btn1Handler }); } else { btn1 = new Ext.Button( "btn1-ct", { text: this.btn1Text, handler: btn1Handler }); } } }; }();
YUI
类似地,我们也可以使用YUI3来实现模块模式。下面的例子很大程度上是基于原始由Eric Miraglia实现的YUI本身的模块模式,但是和vanillla Javascript 实现的版本比较起来差异不是很大。
Y.namespace( "store.basket" ) = (function () { var myPrivateVar, myPrivateMethod; // private variables: myPrivateVar = "I can be accessed only within Y.store.basket."; // private method: myPrivateMethod = function () { Y.log( "I can be accessed only from within YAHOO.store.basket" ); } return { myPublicProperty: "I'm a public property.", myPublicMethod: function () { Y.log( "I'm a public method." ); // Within basket, I can access "private" vars and methods: Y.log( myPrivateVar ); Y.log( myPrivateMethod() ); // The native scope of myPublicMethod is store so we can // access public members using "this": Y.log( this.myPublicProperty ); } }; })();
jQuery
因为jQuery编码规范没有规定插件如何实现模块模式,因此有很多种方式可以实现模块模式。Ben Cherry 之间提供一种方案,因为模块之间可能存在大量的共性,因此通过使用函数包装器封装模块的定义。
在下面的例子中,定义了一个library 函数,这个函数声明了一个新的库,并且在新的库(例如 模块)创建的时候,自动将初始化函数绑定到document的ready上。
function library( module ) { $( function() { if ( module.init ) { module.init(); } }); return module; } var myLibrary = library(function () { return { init: function () { // module implementation } }; }());
优势
既然我们已经看到单例模式很有用,为什么还是使用模块模式呢?首先,对于有面向对象背景的开发者来讲,至少从javascript语言上来讲,模块模式相对于真正的封装概念更清晰。
其次,模块模式支持私有数据-因此,在模块模式中,公共部分代码可以访问私有数据,但是在模块外部,不能访问类的私有部分(没开玩笑!感谢David Engfer 的玩笑)。
缺点
模块模式的缺点是因为我们采用不同的方式访问公有和私有成员,因此当我们想要改变这些成员的可见性的时候,我们不得不在所有使用这些成员的地方修改代码。
我们也不能在对象之后添加的方法里面访问这些私有变量。也就是说,很多情况下,模块模式很有用,并且当使用正确的时候,潜在地可以改善我们代码的结构。
其它缺点包括不能为私有成员创建自动化的单元测试,以及在紧急修复bug时所带来的额外的复杂性。根本没有可能可以对私有成员打补丁。相反地,我们必须覆盖所有的使用存在bug私有成员的公共方法。开发者不能简单的扩展私有成员,因此我们需要记得,私有成员并非它们表面上看上去那么具有扩展性。
想要了解更深入的信息,可以阅读Ben Cherry 这篇精彩的文章。