本节书摘来自异步社区《JavaScript设计模式》一书中的第9章,第9.2节, 作者: 【美】Addy Osmani 译者: 徐涛 更多章节内容可以访问云栖社区“异步社区”公众号查看。
9.2 Module(模块)模式
模块是任何强大应用程序架构中不可或缺的一部分,它通常能够帮助我们清晰地分离和组织项目中的代码单元。
在JavaScript中,有几种用于实现模块的方法,包括:
对象字面量表示法
Module模式
AMD模块
CommonJS模块
ECMAScript Harmony模块
我们稍后将在本书第11章探索后三种方法。Module模式在某种程度上是基于对象字面量,因此首先重新认识对象字面量是有意义的。
9.2.1 对象字面量
在对象字面量表示法中,一个对象被描述为一组包含在大括号({})中、以逗号分隔的name/value对。对象内的名称可以是字符串或标识符,后面跟着一个冒号。对象中最后的一个name/value对的后面不用加逗号,如果加逗号将会导致出错。
var myObjectLiteral = {
variableKey: variableValue,
functionKey: function () {
}
};
对象字面量不需要使用new运算符进行实例化,但不能用在一个语句的开头,因为开始的可能被解读为一个块的开始。在对象的外部,新成员可以使用如下赋值语句添加到对象字面量上,如:myModule.property = "someValue";
下面我们可以看到一个更完整的示例:使用对象字面量表示法定义的模块:
var myModule = {
myProperty: "someValue",
// 对象字面量可以包含属性和方法
// 例如,可以声明模块的配置对象
myConfig: {
useCaching: true,
language: "en"
},
// 基本方法
myMethod: function () {
console.log("Where in the world is Paul Irish today?");
},
// 根据当前配置输出信息
myMethod2: function () {
console.log("Caching is:" + (this.myConfig.useCaching) ? "enabled" : "disabled");
},
// 重写当前的配置
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
});
使用对象字面量有助于封装和组织代码,如果想进一步了解有关对象字面量的信息,丽贝卡·墨菲曾对这一主题进行了深入解析,可阅读其文章进行了解。
也就是说,如果我们选择了这种技术,我们可能同样也对Module模式感兴趣。它仍然使用对象字面量,但只是作为一个作用域函数的返回值。
9.2.2 Module(模块)模式
Module模式最初被定义为一种在传统软件工程中为类提供私有和公有封装的方法。
在JavaScript中,Module模式用于进一步模拟类的概念,通过这种方式,能够使一个单独的对象拥有公有/私有方法和变量,从而屏蔽来自全局作用域的特殊部分。产生的结果是:函数名与在页面上其他脚本定义的函数冲突的可能性降低(见图9-2)。
9.2.2.1 私有
Module模式使用闭包封装“私有”状态和组织。它提供了一种包装混合公有/私有方法和变量的方式,防止其泄露至全局作用域,并与别的开发人员的接口发生冲突。通过该模式,只需返回一个公有API,而其他的一切则都维持在私有闭包里。
这为我们提供了一个屏蔽处理底层事件逻辑的整洁解决方案,同时只暴露一个接口供应用程序的其他部分使用。该模式除了返回一个对象而不是一个函数之外,非常类似于一个立即调用的函数表达式1。
应该指出的是,在JavaScript中没有真正意义上的“私有”,因为不像有些传统语言,JavaScript没有访问修饰符。从技术上来说,我们不能称变量为公有或是私有,因此我们需使用函数作用域来模拟这个概念。在Module模式内,由于闭包的存在,声明的变量和方法只在该模式内部可用。但在返回对象上定义的变量和方法,则对外部使用者都是可用的。
9.2.2.2 历史
从历史的角度来看,Module模式最初是在2003年由多人共同开发出来的,其中包括理查德•康佛德。后来由道格拉斯·克劳克福德在其讲座中推广开来。除此之外,如果你曾体验过雅虎的YUI库,它的一些特性看起来可能相当熟悉,原因是在创建它们的组件时,Module模式对YUI有很大的影响。
9.2.2.3 示例
让我们通过创建一个自包含的模块来看一下Module模式的实现。
var testModule = (function () {
var counter = 0;
return {
incrementCounter: function () {
return ++counter;
},
resetCounter: function () {
console.log("counter value prior to reset: " + counter);
counter = 0;
}
};
})();
//用法:
//增加计数器
testModule.incrementCounter();
// 检查计数器值并重置
//输出:1
testModule.resetCounter();
在这里,代码的其他部分无法直接读取incrementCounter()或resetCounter()。counter变量实际上是完全与全局作用域隔离的,因此它表现得就像是一个私有变量,它的存在被局限于模块的闭包内,因此唯一能够访问其作用域的代码就是这两个函数。上述方法进行了有效的命名空间设置,所以在测试代码中,所有的调用都需要加上前缀(如:“testModule”)。
使用Module模式时,可能会觉得它可以用来定义一个简单的模板来入门使用。下面是一个包含命名空间、公有和私有变量的Module模式:
var myNamespace = (function () {
// 私有计数器变量
var myPrivateVar = 0;
// 记录所有参数的私有函数
var myPrivateMethod = function (foo) {
console.log(foo);
};
return {
// 公有变量
myPublicVar: "foo",
// 调用私有变量和方法的公有函数
myPublicFunction: function (bar) {
// 增加私有计数器值
myPrivateVar++;
// 传入bar调用私有方法
myPrivateMethod(bar);
}
};
})();
来看另一个示例,我们可以看到一个使用这种模式实现的购物车。模块本身是完全自包含在一个被称为basketModule的全局变量中。模块中的basket数组是私有的,因此应用程序的其他部分无法直接读取它。它只与模块的闭包一起存在,所以能够访问它的方法都是那些能够访问其作用域的方法(即addItem()、getItem()等)。
var basketModule = (function () {
// 私有
var basket = [];
function doSomethingPrivate() {
//...
}
function doSomethingElsePrivate() {
//...
}
// 返回一个暴露出的公有对象
return {
// 添加item到购物车
addItem: function (values) {
basket.push(values);
},
// 获取购物车里的item数
getItemCount: function () {
return basket.length;
},
// 私有函数的公有形式别名
doSomething: doSomethingPrivate,
// 获取购物车里所有item的价格总值
getTotal: function () {
var itemCount = this.getItemCount(),
total = 0;
while (itemCount--) {
total += basket[itemCount].price;
}
return total;
}
};
})();
在该模块中,可能已经注意到返回了一个object。它会被自动赋值给basketModule,以便我们可以与它交互,如下所示:
// basketModule返回了一个拥有公用API的对象
basketModule.addItem({
item: "bread",
price: 0.5
});
basketModule.addItem({
item: "butter",
price: 0.3
});
// 输出: 2
console.log(basketModule.getItemCount());
// 输出: 0.8
console.log(basketModule.getTotal());
// 不过,下面的代码不会正常工作
// 输出:undefined
// 因为basket自身没有暴露在公有的API里
console.log(basketModule.basket);
// 下面的代码也不会正常工作,因为basket只存在于basketModule闭包的作用域里,而不是存在于返回的公有对象里
console.log(basket);
上述方法在basketModule内部都属于有效的命名空间设置。
请注意上面的basket模块中的作用域函数是如何包裹在所有函数的周围,然后调用并立即存储返回值。这有很多优点,包括:
只有我们的模块才能享有拥有私有函数的自由。因为它们不会暴露于页面的其余部分(只会暴露于我们输出的API),我们认为它们是真正的私有。
鉴于函数往往已声明并命名,在试图找到有哪些函数抛出异常时,这将使得在调试器中显示调用堆栈变得更容易。
正如 T.J.Crowder 在过去所指出的,根据环境,它还可以让我们返回不同的函数。在过去,我曾看到开发人员使用它来执行UA测试,从而针对IE在他们的模块内提供一个代码路径,但我们现在可以很容易地选择特征检测来实现类似的目的。
9.2.3 Module模式变化
9.2.3.1 引入混入
模式的这种变化演示了全局变量(如:jQuery、Underscore)如何作为参数传递给模块的匿名函数。这允许我们引入它们,并按照我们所希望的为它们取个本地别名。
// 全局模块
var myModule = (function (jQ, _) {
function privateMethod1() {
jQ(".container").html("test");
}
function privateMethod2() {
console.log(_.min([10, 5, 100, 2, 1000]));
}
return {
publicMethod: function () {
privateMethod1();
}
};
// 引入jQuery和Underscore
})(jQuery, _));
myModule.publicMethod();
9.2.3.2 引出
下一个变化允许我们声明全局变量,而不需实现它们,并可以同样地支持上一个示例中的全局引入的概念。
// 全局模块
var myModule = (function (){
// 模块对象
var module = {},
privateVariable = "Hello World";
function privateMethod() {
// ...
}
module.publicProperty = "Foobar";
module.publicMethod = function () {
console.log(privateVariable);
};
return module;
})();
9.2.3.3 工具包和特定框架的Module模式实现
Dojo。提供了一种和对象一起用的便利方法dojo.setObject()。其第一个参数是用点号分割的字符串,如myObj.parent.child,它在parent对象中引用一个称为child的属性,parent对象是在myObj内部定义。我们可以使用setObject()设置子级的值(比如属性等),如果中间对象不存在的话,也可以通过点号分割将中间的字符作为中间对象进行创建。
例如,如果要将basket.core声明为store名称空间的对象,可以采用传统的方法来实现,如下所示:
var store = window.store || {};
if (!store["basket"]) {
store.basket = {};
}
if (!store.basket["core"]) {
store.basket.core = {};
}
store.basket.core = {
// ...剩余的逻辑
};
或者,使用Dojo 1.7(AMD兼容的版本)和上述方法,如下所示:
require(["dojo/_base/customStore"], function (store) {
// 使用 dojo.setObject()
store.setObject("basket.core", (function () {
var basket = [];
function privateMethod() {
console.log(basket);
}
return {
publicMethod: function (){
privateMethod();
}
};
})());
});
ExtJS。对比那些使用Sencha ExtJS的人,你的运气会好一点,因为官方文档包含了一些示例,演示了EXTJS框架下如何正确使用Module模式。
在这里,我们可以看到这样的一个示例:如何定义一个名称空间,然后填充一个包含私有和公有 API 的模块。除了一些语义差异,它与如何在纯JavaScript中实现Module模式十分相近。
// 创建命名空间
Ext.namespace("myNameSpace");
// 创建应用程序
myNameSpace.app = function () {
// 这里不要访问DOM,因为元素还不存在
// 私有变量
var btn1,
privVar1 = 11;
// 私有函数
var btn1Handler = function (button, event) {
console.log("privVar1", privVar1);
console.log("this.btn1Text=" + this.btn1Text);
};
// 公有对象
return {
// 公有属性,例如要转化的字符
btn1Text: "Button 1",
// 公有方法
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 构建应用程序时,我们也可以实现Module模式。下面的示例在很大程度上基于由Eric Miraglia提出的原始YUI Module模式实现,但它又与纯JavaScript版本截然不同。
Y.namespace("store.basket") = (function () {
var myPrivateVar, myPrivateMethod;
// 私有变量:
myPrivateVar = "I can be accessed only within Y.store.basket.";
// 私有方法:
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.");
// 在basket里,可以访问到私有变量和方法
Y.log(myPrivateVar);
Y.log(myPrivateMethod());
// myPublicMethod的原始作用域是store,所以可以使用this来访问公有成员
Y.log(this.myPublicProperty);
}
};
})();
jQuery。有许多方式可以将非jQuery插件代码包装在Module模式中。如果模块之间有多个共性,Ben Cherry之前建议过一种实现,在模块模式内部模块定义附件使用函数包装器。
在下面的示例中,定义了library函数,它声明一个新库,并在创建新库(即模块)时将init函数自动绑定到document.ready。
function library(module) {
$(function () {
if (module.init) {
module.init();
}
});
return module;
}
var myLibrary = library(function () {
return {
init: function () {
// module implementation
// 模块实现
}
};
})();
9.2.3.4 优点
我们已经了解了单例模式如何有用,但为什么Module模式是一个好的选择呢?首先,相比真正封装的思想,它对于很多拥有面向对象背景的开发人员来说更加整洁,至少是从JavaScript的角度。
其次,它支持私有数据,因此,在Module模式中,代码的公有(public)部分能够接触私有部分,然而外界无法接触类的私有部分。
9.2.3.5 缺点
Module模式的缺点是:由于我们访问公有和私有成员的方式不同,当我们想改变可见性时,实际上我们必须要修改每一个曾经使用过该成员的地方。
我们也无法访问那些之后在方法里添加的私有成员。也就是说,在很多情况下,如果正确使用,Module模式仍然是相当有用的,肯定可以改进应用程序的结构。
其他缺点包括:无法为私有成员创建自动化单元测试,bug需要修正补丁时会增加额外的复杂性。为私有方法打补丁是不可能的。相反,我们必须覆盖所有与有bug的私有方法进行交互的公有方法。另外开发人员也无法轻易地扩展私有方法,所以要记住,私有方法并不像它们最初显现出来的那么灵活。