JavaScript模块化模式

225 阅读12分钟

创作声明:如需转载,请注明出处

本文首发于知乎专栏

前言

最近在学习 JavaScript 的设计模式,在通过人民邮电出版社出版的《JavaScript设计模式》([美] Addy Osmani 著)学习的过程中,发现书中所译不仅没有体现原文的精华,而且还经常使我迷惑。

对此,我把视线投入到了网络,找到了 W3Cschool 的译本,然而也发现其中有不少地方翻译的让人很迷惑,这迫使我不得不去看原著,只是没想到原著也如此难找,最终在某个犄角旮瘩找到了。

Learning JavaScript Design Patterns

下文是由我逐字逐句研读原著过程中,根据自己的理解记录下的翻译。当然在研读的过程中也参考了 W3Cschool 的译本和人民邮电出版社出版的《JavaScript设计模式》。若有翻译不到之处,希望大家可以在评论中指正。

模块

模块是任何强大的应用程序架构中不可或缺的一部分,它通常能够帮助我们清晰地分离和组织项目中的代码单元。

在 JavaScript 中,实现模块有如下几种方法,他们包括:

  • 模块化模式
  • 对象字面量表示法
  • AMD(Asynchronous Module Definition)模块
  • CommonJS 模块
  • ECMAScript Harmony 模块

模块化模式是基于对象的文字部分,所以首先对于更新我们对它们的知识是很有意义的。

对象字面量

在对象字面量表示法中,一个对象被描述为一组包含在大括号({})中、以逗号分隔的 name/value 对。对象内的名称可以是字符串或者标识符,后面跟着一个冒号。对象中最后的一个 name/value 对的后面不用加逗号,如果加逗号将可能会导致出错。

var myObjectLiteral = {

    variableKey: variableValue,

    functionKey: function () {
      // ...
    }
};

对象字面量不需要使用 new 运算符来进行实例化,同时也不应该在语句的开头使用,因为{通常被解释为语句块的开始。在一个对象的外部,可以使用 myModule.property = "someValue"; 这种方法来分配添加新成员。

下面我们可以看到一个更完整的使用对象字面量定义一个模块的例子:

var myModule = {
 
  myProperty: "someValue",
 
  // 对象字面量包含了属性和方法(properties and methods).
  // 我们可以定义一个对象来表示此模块的配置
  myConfig: {
    useCaching: true,
    language: "en"
  },
 
  // 定义一个非常基本的方法
  saySomething: function () {
    console.log( "Where in the world is Paul Irish today?" );
  },
 
  // 基于当前配置输出一个值
  reportMyConfig: function () {
    console.log( "Caching is: " + ( this.myConfig.useCaching ? "enabled" : "disabled") );
  },
 
  // 重写当前的配置(configuration)
  updateMyConfig: function( newConfig ) {
 
    if ( typeof newConfig === "object" ) {
      this.myConfig = newConfig;
      console.log( this.myConfig.language );
    }
  }
};
 
// 输出: Where in the world is Paul Irish today?
myModule.saySomething();
 
// 输出: Caching is: enabled
myModule.reportMyConfig();
 
// 输出: fr
myModule.updateMyConfig({
  language: "fr",
  useCaching: false
});
 
// 输出: Caching is: disabled
myModule.reportMyConfig();

使用对象字面量有助于封装和组织您的代码,如果您希望进一步阅读对象字面量,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;
    }
  };
 
})();
 
// 使用:
 
// Increment our counter
testModule.incrementCounter();
 
// 检查 counter 的值并重置
// 输出: counter value prior to reset: 1
testModule.resetCounter();

在这里我们看到,其它部分的代码不能直接访问我们的 incrementCounter() 或者 resetCounter() 的值。counter 变量被完全从全局域中隔离起来了,因此其表现的就像一个私有变量一样,它的存在只局限于模块的闭包内部,因此只有两个函数可以访问 counter。上述方法进行了有效的命名空间设置,因此在测试代码时,我们需要在任何调用前面加上模块的名称(例如 testModule )。

在使用模块化模式时,我们可能会发现定义一个用于开始使用它的简单模板是很有用的。这里有一个涵盖了命名空间,公共和私有变量的例子:

var myNamespace = (function () {
 
  var myPrivateVar, myPrivateMethod;
 
  // 一个私有计数变量
  myPrivateVar = 0;
 
  // 一个打印入参的私有方法
  myPrivateMethod = function( foo ) {
    console.log( foo );
  };
 
  return {
 
    // 一个公共变量
    myPublicVar: "foo",
 
    // A public function utilizing privates
    myPublicFunction: function( bar ) {
 
      // Increment our private counter
      myPrivateVar++;
 
      // Call our private method using bar
      myPrivateMethod( bar );
 
    }
  };
 
})();

看一下另外一个例子,下面我们看到一个使用这种模式实现的购物车。这个模块完全自包含在一个叫做 basketModule 全局变量中。模块中的购物车数组是私有的,应用的其它部分不能直接读取。只存在与模块的闭包中,因此只有可以访问其域的方法可以访问这个变量(例如 addItem()getItemCount() 等)。

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;
    }
  };
})();

在模块内部,你可能注意到我们返回了一个 object,并将其赋值给了 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
// public API
console.log( basketModule.basket );
 
// This also won't work as it only exists within the scope of our
// basketModule closure, but not in the returned public object
console.log( basket );

上面的方法都处于 basketModule 的命名空间中。

请注意在上面的购物车模块中域函数是如何在我们所有的函数中被封装起来的,以及我们如何立即调用这个域函数,并且将返回值保存下来。这种方式有以下的优势:

  • 可以创建只能被我们模块访问的私有函数。这些函数没有暴露出来(只有一些 API 是暴露出来的),它们被认为是完全私有的。
  • 当我们在一个调试器中,需要发现哪个函数抛出异常的时候,可以很容易的看到调用栈,因为这些函数是正常声明的并且是命名的函数。
  • 正如过去 T.J Crowder 指出的,这种模式同样可以让我们在不同的情况下返回不同的函数。我见过有开发者使用这种方式用于执行 UA 测试,目的是为了在他们的模块里面针对 IE 专门提供一条代码路径,但是现在我们也可以简单的使用特征检测达到相同的目的。

模块化模式变化

Import mixins(引入混入)

模式的这种变化演示了如何将全局变量(例如 jQuery 、下划线)作为参数传递给我们模块的匿名函数。这允许我们引入它们,并按照我们所希望的为它们取个本地别名。

// Global module
var myModule = (function ( jQ, _ ) {
 
  function privateMethod1(){
    jQ(".container").html("test");
  }

  function privateMethod2(){
    console.log( _.min([10, 5, 100, 2, 1000]) );
  }

  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.setObject() 。其中第一个参数是用点号分割的字符串,如 myObj.parent.child,它在 parent 对象中引用一个称为 child 的属性,parent 对象是在 myOjb 内部定义的。我们可以手你用 setObject() 设置子级的值,如果中间对象不存在的话,也可以通过点号分割将中间的字符串作为中间对象进行创建。

例如,如果我们想将 basket.core 声明为 store 命名空间的一个对象,可以使用传统的方法来实现,如下所示:

var store = window.store || {};
 
if ( !store["basket"] ) {
  store.basket = {};
}
 
if ( !store.basket["core"] ) {
  store.basket.core = {};
}
 
store.basket.core = {
  // ...剩余的逻辑
};

或使用 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() 方法的信息,请参阅官方文档 documentation

ExtJS

对于这些使用 Sencha 的 ExtJS 的人们,你们很幸运,因为官方文档包含一些例子,用于演示在 ExtJS 框架下如何正确地使用模块化模式。

下面我们可以看到一个例子关于如何定义一个命名空间,然后填入一个包含有私有和公有 API 的模块。除了一些语义上的不同之外,这个例子和使用纯 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 = 11;
 
  // 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 本身的模块化模式,但是和纯 Javascript 实现的版本比较起来差异也不是很大。

Y.namespace( "store.basket" ) ;
Y.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 函数,这个函数声明了一个新的库,并且在新的库(即模块)创建的时候,自动将 init 函数绑定到 document.ready 上。

function library( module ) {
 
  $( function() {
    if ( module.init ) {
      module.init();
    }
  });
 
  return module;
}
 
var myLibrary = library(function () {
 
  return {
    init: function () {
      // module implementation
    }
  };
}());

优点

既然我们已经看到单例模式很有用,为什么还是使用模块化模式呢?首先,对于有面向对象背景的开发者来讲,至少从 JavaScript 语言上来讲,模块化模式相对于真正的封装概念更清晰。

其次,模块化模式支持私有数据,因此,在模块化模式中,公共部分代码可以访问私有数据,但是在模块外部,不能访问类的私有部分

缺点

模块模式的缺点是因为我们采用不同的方式访问公有和私有成员,因此当我们想要改变这些成员的可见性的时候,我们不得不在所有使用这些成员的地方修改代码。

我们也不能在对象之后添加的方法里面访问这些私有变量。也就是说,很多情况下,模块模式很有用,并且当使用正确的时候,潜在地可以改善我们代码的结构。

其他缺点包括:无法为私有成员创建自动化单元测试, bug 需要修正补丁时会增加额外的复杂性。为私有方法打补丁时不可能的。相反,我们必须覆盖所有与有 bug 的私有方法进行交互的公有方法。另外开发人员也无法轻易地扩展私有方法,所以要记住,私有方法并不像它们最初显现出来的那么灵活。