原文:https://addyosmani.com/resources/essentialjsdesignpatterns/book/#modulepatternjavascript

模块

模块是任何健壮的应用程序架构所必备的部分,通常有助于保持项目代码单元清晰的分离和组织。

在 JavaScript 中,有几种方式来实现模块。它们是:

  • 模块模式
  • 对象字面量表示法
  • AMD 模块
  • CommonJS 模块
  • ECMAScript 标准模块


我们随后将在本书的 现代模块化 JavaScript 设计模式 章节中来探讨后三种方案。

模块模式 是部分基于对象字面量,所以我们有必要先重新回顾一下相关知识。

对象字面量

在对象字面量表示法中,对象被描述为一系列被花括号({})包裹,逗号分隔的键值对组成。对象中的键可以是任何字符或者符号,并且会跟随着一个冒号。最后一组键值对后不应该再有逗号,因为它可能会引起程序报错。

  1. var myObjectLiteral = {
  2. variableKey: variableValue,
  3. functionKey: function() {
  4. // ...
  5. }
  6. };

对象字面量不需要用 new 操作符来实例化,但是不应该在语句的开头使用,因为 { 可能会被解释成块的开始。在对象外,可以使用以下方式来为其添加成员:myMofule.property = "someValue"。

下面我们将看到用对象字面量来定义模块的更复杂的示例:

  1. var myModule = {
  2. myProperty: "someValue",
  3. // 对象字面量可以包含属性和方法
  4. // 例如我们可以定义一个嵌套对象来存储更多的模块配置
  5. myConfig: {
  6. useCacing: true,
  7. language: "em"
  8. },
  9. // 一个简单的方法
  10. saySomething: function() {
  11. console.log("Where in the world is Pual Irish today?");
  12. },
  13. // 根据当前模块配置信息输出值
  14. reportMyConfig: function() {
  15. console.log("Caching is: " + ( this.myConfig.useCaching ? "enable" : "disabled") );
  16. },
  17. // 覆盖当前的配置
  18. updateMyConfig: function(newConfig) {
  19. if (typeof newConfig === "object") {
  20. this.myConfig = newConfig;
  21. console.log(this.myConfig.language);
  22. }
  23. },
  24. };
  25. // 输出:Where in the world is Paul Irish today?
  26. myModule.saySomething();
  27. // 输出: Caching is: enabled
  28. myModule.reportMyConfig();
  29. // 输出: fr
  30. myModule.updateMyConfig({
  31. language: "fr",
  32. useCaching: false
  33. });
  34. // 输出: Caching is disabled
  35. myModule.reportMyConfig();

使用对象字面量有助于封装和组织你的代码,如果你希望进一步了解对象字面量,你可以阅读 Rebecca Muerphey 先前关于这个话题的深度讨论

也就是说,如果我们选择这种技术,我们可能同样会对 模块模式 感兴趣。它也是使用对象字面量,但是只是作为一个闭包函数的返回值来使用。

模块模式

模块模式最初被定义为在传统软件工程中为类提供私有和公共封装的一种方式。

在 JavaScript 中,模块模式被用来进一步模拟类的概念,在这种方式中,我们能够在一个对象中包含公公共/私有的方法和变量,从而将特定部分从全局作用域中隔离出来。它能带来的好处就是减少我们的函数名与页面内其他脚本中的函数名的冲突的可能性。

私有的

模块模式使用闭包封装了“隐私的”、状态和组织。它提供了一种方式来封装公共和私有的方法和变量,保护片段不泄漏到全局作用域内,不与另外的开发者的接口发生意外的冲突。使用这种模式,只有公共的 API 会被返回,将闭包中其他内容保持为私有。

这为我们提供了一种干净的解决方案来保护执行繁重任务的逻辑,同时只暴露我们希望程序其他部分可以使用的接口。这个模式使用的是一个会返回一个对象的立即执行的函数(IIFE,参考 命名空间 模式来了解更多)。

应该注意的是,在 JavaScript 中并没有真正意义上的“私有”,因为与一些传统语言不同,它没有访问修饰符。理论上来说,变量不能被声明为公共或者私有,所以我们使用函数作用域来模拟这个概念。在模块模式中,由于闭包的作用,定义的变量和方法都只能在模块内部访问。定义在返回对象中的变量和方法却是任何人都可以访问。

历史

从历史的角度来看,模块模式最早由 Richard Cornford 等人在2003年最早开发出来。随后通过 Douglas Crockford 的讲座得到推广。另外一个细节就是,如果你曾经使用过 Yahoo 的 YUI 库,会发现它的一些特征可能看起来很相似,这是因为模块模式在创建组件时对 YUI 有很大影响。

示例
我们先通过创建一个独立的模块来学习模块模式的实现。

  1. var testModule = (
  2. var counter = 0;
  3. return {
  4. incrementCounter: function(){
  5. return counter++;
  6. },
  7. resetCounter: function() {
  8. console.log("counter value prior to reset: " + counter);
  9. counter = 0;
  10. }
  11. };
  12. )();
  13. // 用法:
  14. // 增加我们的 counter
  15. testModule.incrementCounter();
  16. // 检查我们的 counter 值,然后重置
  17. // 输出:counter value prior to reset: 1
  18. testModule.resetCounter();

本例中,其他部分的代码是无法直接读取 incrementCounter() 或者 resetCounter() 的值。counter 变量实际上完全从全局作用域隔离开的,它就相当于是一个私有变量 ,它只存在模块的闭包内,所以只有我们的两个函数能访问到它。我们的方法是实际被限定到命名空间了,所以在测试代码部分,我们需要在方法调用前再加上模块名作为前缀(例如:”testModule”)。

使用模块模式时,我们会发现定义一个简单的模板来开始使用它是很有用的。下面是一个包含了命名空间、公/私有变量的示例:

  1. var myNamespace = (function() {
  2. var myPrivateVar, myPrivateMethod;
  3. // 一个私有的counter变量
  4. myPrivateVar = 0;
  5. // 一个私有的方法来记录入参
  6. myPrivateMethod = function(foo) {
  7. console.log(foo);
  8. };
  9. return {
  10. // 一个公有的变量
  11. myPublicVar: "foo",
  12. // 一个公有的方法来访问私有部分
  13. myPublicMethod: function(bar) {
  14. // 增加我们私有的 counter
  15. myPrivateVar++;
  16. // 调用我们私有的方法
  17. myPrivateMethod(bar);
  18. }
  19. };
  20. })();

来看另一个例子,下面将会用这个模式来实现一个购物车。模块本身被完全独立在一个名为 basketModule 的全局变量中。模块中的数组 basket 是私有的,所以我们应用其他部分是无法直接读取到它。它只存在在模块的闭包内,只能通过能访问这个闭包的方法访问它(即 addItem()getItemCount() 等)。

  1. var basketModule = (function() {
  2. // 私有的
  3. var basket = [];
  4. function doSomethingPrivate() {
  5. // ...
  6. }
  7. function doSomethingElsePrivate() {
  8. // ...
  9. }
  10. // 返回一个对象暴露出去
  11. return {
  12. // 添加到购物车
  13. addItem: function(values) {
  14. basket.push(values);
  15. },
  16. // 返回购车物品数量
  17. getItemCount: function() {
  18. return basket.length;
  19. },
  20. // 公有方法映射到私有方法
  21. doSomething: doSomethingPrivate,
  22. // 返回购物车中的总额
  23. getTotal: function() {
  24. var q = this.getItemCount(),
  25. p = 0;
  26. while(q--) {
  27. p += basket[q].price;
  28. }
  29. return p;
  30. }
  31. };
  32. })();

你可能注意到了我们在模块内部返回了一个对象。它自动的分配到 basketModule 变量,这样这样来使用它:

  1. // basketModule 返回一个保护公共 API 的对象供我们使用
  2. basketModule.addItem({ item: "bread", price: 0.5 });
  3. basketModule.addItem({ item: "butter", price: 0.3 });
  4. // 输出:2
  5. console.log(basketModule.getItemCount());
  6. // 输出:0.8
  7. console.log(basketModule.getTotal());
  8. // 然而,下面的代码不会生效
  9. // 输出:undefined
  10. // 这是因为 basket 没有通过我们的公共 API 暴露出来
  11. console.log(basketModule.basket);
  12. // 这也行不通,因为它只存在 basketModule 闭包范围内,不在返回的公共对象上
  13. console.log(basket);

上面这些方法有效的被限定在 basketModule 命名空间内。

注意 basket 模块的作用域函数是如何包裹我们所有的函数,我们随后调用它并立即存储它的返回值。这种方式有一些好处:

  • 自由的拥有私有方法和私有成员,它们只在我们模块内部使用。因为它们没有暴露给页面其他部分(只有我们导出的公共API才暴露了出去),它们可以认为是真正的私有的。
  • 假设函数正常的声明并命名,它能让我们在通过调用堆栈查找出哪个函数抛出异常更加方便。
  • 正如 T.J Crowder 先前指出的,它还能让我们根据环境返回不同的函数。在过去,我看到过开发者使用这种方式来检测 UA,以便为 IE 浏览器提供一个特殊的代码路径,但是我们现在可以更容易的使用特性检测来实现类似的目标。

模块模式的演变

Import mixins

这个模式的演变示范了全局变量(如 jQuery、UnderScore)如何通过参数传递给我们模块的匿名函数。这能有效的使我们能够导入它们,并在内部按照我们的意愿来为它重命名。

  1. // 全局模块
  2. var myModule = (function ( jQ, _ ) {
  3. function privateMethod1(){
  4. jQ(".container").html("test");
  5. }
  6. function privateMethod2(){
  7. console.log( _.min([10, 5, 100, 2, 1000]) );
  8. }
  9. return{
  10. publicMethod: function(){
  11. privateMethod1();
  12. }
  13. };
  14. // 导入jQuery和underscore
  15. })( jQuery, _ );
  16. myModule.publicMethod();

Exports

这个演变让我们能够在不使用它们的情况下声明全局变量,并且可以类似的支持上一个例子那样的全局变量导入的概念。

  1. // 全局模块
  2. var myModule = (function () {
  3. // 模块对象
  4. var module = {},
  5. privateVariable = "Hello World";
  6. function privateMethod() {
  7. // ...
  8. }
  9. module.publicProperty = "Foobar";
  10. module.publicMethod = function () {
  11. console.log( privateVariable );
  12. };
  13. return module;
  14. })();

工具和特定框架的模块声明

Dojo

Dojo 提供了一个名为 dojo.setObject() 的便捷方法来配合对象使用。它的第一个参数接收以 . 为分割符的字符,如 myObj.parent.child ,它会定义在 myObj 中定义一个含有 child 属性的属性 parent 。使用 setObject()允许我们设置子属性的值,并且会在中间节点不存在的情况下自动声明其值为对象。

例如,如果我们想声明 basket.core 作为 store 命名空间下的一个对象,可以通过下面这种传统的方式实现:

  1. var store = window.store || {};
  2. if ( !store["basket"] ) {
  3. store.basket = {};
  4. }
  5. if ( !store.basket["core"] ) {
  6. store.basket.core = {};
  7. }
  8. store.basket.core = {
  9. // ...剩余逻辑
  10. };

或者像下面一样使用 Dojo 1.7 及以上(适配 AMD 版本):

  1. require(["dojo/_base/customStore"], function( store ){
  2. // 使用 dojo.setObject()
  3. store.setObject( "basket.core", (function() {
  4. var basket = [];
  5. function privateMethod() {
  6. console.log(basket);
  7. }
  8. return {
  9. publicMethod: function(){
  10. privateMethod();
  11. }
  12. };
  13. })());
  14. });

想了解更多关于 dojo.setObject() 的信息,可以查看官方文档

ExtJS

对于那些使用 Sencha 的 ExtJS 的开发者,下面是一个演示如何在框架中正确的使用模块模式的示例。

这里,我们看到一个关于如何定义一个命名空间的例子,它可以声明一个包含公有/私有 API 的模块。除了一些语法上的差异,它和使用普通的 JavaScript 声明模块模式类似。

  1. // 创建命名空间
  2. Ext.namespace("myNameSpace");
  3. // 创建应用程序
  4. myNameSpace.app = function () {
  5. // 不要在这里访问DOM节点,元素还不存在
  6. // 私有变量
  7. var btn1,
  8. privVar1 = 11;
  9. // 私有方法
  10. var btn1Handler = function ( button, event ) {
  11. console.log( "privVar1=" + privVar1 );
  12. console.log( "this.btn1Text=" + this.btn1Text );
  13. };
  14. // 公有空间
  15. return {
  16. //公共属性, 例如: 要翻译的字符
  17. btn1Text: "Button 1",
  18. // 公有的方法
  19. init: function () {
  20. if ( Ext.Ext2 ) {
  21. btn1 = new Ext.Button({
  22. renderTo: "btn1-ct",
  23. text: this.btn1Text,
  24. handler: btn1Handler
  25. });
  26. } else {
  27. btn1 = new Ext.Button( "btn1-ct", {
  28. text: this.btn1Text,
  29. handler: btn1Handler
  30. });
  31. }
  32. }
  33. };
  34. }();

YUI

同样的,在使用 YUI3 来创建应用时,我们也可以使用模块模式。下面的例子很大程度是基于 Eric Miraglia 实现的最初版的 YUI 模块模式,但同样,它也普通的 JavaScript 版本没有太大的区别。

  1. Y.namespace( "store.basket" ) ;
  2. Y.store.basket = (function () {
  3. var myPrivateVar, myPrivateMethod;
  4. // 私有变量:
  5. myPrivateVar = "I can be accessed only within Y.store.basket.";
  6. // 私有方法:
  7. myPrivateMethod = function () {
  8. Y.log( "I can be accessed only from within YAHOO.store.basket" );
  9. }
  10. return {
  11. myPublicProperty: "I'm a public property.",
  12. myPublicMethod: function () {
  13. Y.log( "I'm a public method." );
  14. // 在 basket 中,我们可以访问私有变量和方法:
  15. Y.log( myPrivateVar );
  16. Y.log( myPrivateMethod() );
  17. // myPublicMethod 的 native scope 是 store
  18. // 所以我们可以通过 this 来获取公共成员:
  19. Y.log( this.myPublicProperty );
  20. }
  21. };
  22. })();

jQuery

有很多种方式可以将不依赖于插件的 jQuery 代码包装在模块模式内。Ben Cherry 之前提出一种实现方式, 如果模块之间存在很多共性,则用 包装函数 来定义模块。

在下面的例子中,定义了一个library 函数,它声明了一个新的 library ,并在创建新的 libraries (即: modules)时自动的将 init 函数到 document.ready 上。library 函数就是一个包装函数。

  1. function library( module ) {
  2. // $(function() {...}) 等效于 $(document).ready(function() { ... });
  3. $( function() {
  4. if ( module.init ) {
  5. module.init();
  6. }
  7. });
  8. return module;
  9. }
  10. var myLibrary = library(function () {
  11. return {
  12. init: function () {
  13. // 模块声明
  14. }
  15. };
  16. }());

优点


我们已经明白了为什么构造器模式是有用的,但是为什么模块模式是一个好的选择?对初学者来说,对于有面向对象经验的开发者来说,它比真正的封装思想要更清晰,至少从 JavaScript 角度来看。

其次,它支持私有数据 — 因此,在模块模式内部,我们公共部分的代码能访问私有部分,但是外部的代码是无法访问类的私有部分(不准笑!感谢 David Engfer 提供的这个笑话)。

缺点

模块模式的缺点是因为我们访问公有和私有成员方式不同,当我们要改变一个成员的可见性时,我们需要修改所有使用到它的地方。

我们也不能在模块创建后再添加到对象上的方法中访问私有成员。也就是说,如果使用得当,很多情况下模块模式都是有用的,它肯定是能够帮助优化我们代码的结构。

其他的缺点有:不能为私有成员创建自动化单元测试,而且在需要热修复代码时,难度会增加。根本不可能以补丁的方式修复私有部分的问题。相反,必须覆盖与有 bug 的私有部分有交互的所有公共方法。 开发者也不能轻易的拓展私有部分,所以有必要记住私有并不像他们最初看起来那样灵活。

想了解更多关于模块模式的内容,可以阅读 Ben Cherry 的这边很有深度的 文章