模块是任何大型应用程序架构中不可缺少的一部分,模块可以使我们清晰地分离和组织项目中的代码单元。在项目开发中,通过移除依赖,松耦合可以使应用程序的可维护性更强。与其他传统编程语言不同,在当前JavaScript里,并没有提供原生的、有组织性的引入模块方式。本文就来探讨一下目前的常见几种模块化解决方案。
对象字面量可以认为是包含一组键值对的对象,每一对键和值由冒号分隔。对象字面量不需要使用new运算符进行实例化,在对象的外部也可以给对象添加属性和方法。示例如下:
1 var myModule = { 2 myProperty: "jeri", 3 4 // 对象字面量可以包含属性和方法 5 // 例如,可以声明模块的配置对象 6 myConfig: { 7 useCaching: true, 8 language: "en" 9 }, 10 11 // 基本方法 12 myMethod1: function () { 13 console.log("method1"); 14 }, 15 16 // 根据当前配置输出信息 17 myMethod2: function () { 18 console.log("Caching is:" + '(this.myConfig.useCaching) ? "enabled" : "disabled"'); 19 }, 20 21 // 根据当前配置输出信息 22 myMethod3: function (newConfig) { 23 24 if (typeof newConfig === "object") { 25 this.myConfig = newConfig; 26 console.log(this.myConfig.language); 27 } 28 } 29 }
如上所述,使用对象字面量有助于封装和组织代码,然后不同的对象字面量模块再构成复杂的项目。
Module模式最初定义在传统的软件工程中,为类提供私有和公有封装的方法。在JavaScript中,并不能可以直接声明类,但我们可以使用 闭包 来封装私有的属性和方法,进而模拟类的概念,在JavaScript中实现Module模式。通过这种方式,我们就使得一个单独的对象拥有公有/私有方法和变量,从而屏蔽来自全局作用域的特殊部分,也就大大降低了变量声明之间和函数声明之间冲突的可能性。
1 var myModule = (function () { 2 3 // 私有变量 4 var privateVar = 0; 5 6 // 私有函数 7 var privateFun = function (foo) { 8 console.log(foo); 9 }; 10 11 return { 12 // 私有变量 13 publicVar: "foo", 14 15 // 公有函数 16 publicFun: function (arg) { 17 18 // 修改私有变量 19 privateVar ++; 20 21 // 传入bar调用私有方法 22 privateFun(arg); 23 } 24 }; 25 }) ();
如上所示,通过使用闭包我们封装了私有变量和方法,而只暴露了一个接口供其他部分调用。私有变量(privateVar)和方法(privateFun)被局限于模块的闭包之中,只有通过公有方法才能访问。该模式除了返回的是一个对象而不是一个函数之外,非常类似于一个立即调用函数表达式,我们 可以为返回的对象添加新的属性和方法 ,这些新增的属性和方法对外部调用者来说都是可用的。
Module模式的这种JavaScript实现对于具有面向对象开发经验的人来说非常简洁,但其也有自身的缺点和劣势。
由于我们访问公有和私有成员的方式不同,当我们想改变可见性时,我们需要修改每一个曾经使用该成员的地方,并不利于维护和升级,耦合度并不理想。而且,在之后新添加的方法里,我们并不能访问以前声明的私有方法和变量,因为闭包只在创建时完成绑定。我们也无法为私有方法创建自动化单元测试,修正私有方法也是极其困难的,我们需要复写所有与私有方法交互的公有方法,bug修正时工作量会很大。另外,我们也不能轻易的扩展私有方法。
要讨论 AMD 和 CommonJS 模块,我们必然会谈及一个显而易见的话题——脚本加载器。目前,脚本加载是为了让我们能在现今的各种应用中都能使用模块化的 JavaScript 这个目标而服务的。有很多加载器用于 AMD 和 CommonJS方式中的模块加载,比较出名的有 RequireJS 和 curl.js 。关于脚本加载器的使用方式和运行机制,大家可以自行了解一下。
AMD全称是Asynchronous Module Definition,即异步模块加载机制。它诞生于使用XHR+eval的Dojo开发经验,其整体目标是提供模块化的JavaScript解决方案,避免未来的任何解决方案受到过去解决方案缺点的影响。AMD模块格式本身就是对定义模块的建议,其模块和依赖都可以进行异步加载,而且具有高度的灵活性,清除了代码和模块之间可能惯有的紧耦合。
作为一个规范,只需定义其语法API,而不关心其实现。define函数定义如下:
1 define( 2 [module-name?] /*可选*/, 3 [array-of-dependencies?] /*可选*/, 4 [module-factory-or-object] 5 );
其中:
具体示例如下:
1 define( 2 "myModule", 3 ["foo", "bar"], 4 5 // 模块定义函数,依赖(foo,bar)作为参数映射到函数上 6 function (foo, bar) { 7 // 创建模块 8 var myModule = { 9 myFun: function () { 10 console.log("Jeri"); 11 } 12 } 13 14 // 返回定义的模块 15 return myModule; 16 } 17 );
require用于加载JavaScript文件或模块的代码,获取依赖。示例如下:
1 // foo,bar为外部模块,加载以后的输出作为回调函数的参数传入,以便访问 2 requrie(["foo", "bar"], function (foo, bar) { 3 4 // 其他代码 5 foo.doSomething(); 6 });
下面是一个动态加载依赖的示例:
1 define( 2 function (requrie) { 3 var isReady = false, 4 foobar; 5 6 requrie(["foo", "bar"], function (foo, bar) { 7 isReady = true, 8 foobar = foo() + bar(); 9 }); 10 11 // 返回定义的模块 12 return { 13 isReady: isReady, 14 foobar: foobar 15 }; 16 } 17 );
AMD模块可以使用插件,也就是说当我们加载依赖时,可以加载任意格式的文件。AMD对于如何完成灵活模块的定义提供了明确的建议,使用AMD编写模块化的JS代码,比现有的全局命名空间和<script>标签解决方案更加简洁,没有全局命名空间污染,在需要的时候也可以延迟加载脚本。
CommonJS规范建议指定一个简单的API来声明在浏览器外部工作的模块。与AMD不同,它试图包含更广泛的引人关注的问题,如IO、文件系统等。
从结构来看,CommonJS模块是JS中可以复用的部分,导出特定对象,以便可以用于任何依赖代码。与AMD表现形式不同的是,CommonJS模块并不使用define进行定义。 CommonJS模块由两部分组成:变量exports和require函数。 exports包含了一个模块希望其他模块能够使用的对象,require函数用来导入其他模块的导出,也就是用来加载其他模块依赖。示例如下:
1 // 新定义的模块方法 2 function log(arg) { 3 console.log(arg); 4 } 5 6 // 把方法暴露给其他模块 7 exports.log = log;
1 // ./lib是我们需要的一个依赖 2 var lib = requrie("./lib"); 3 4 // 新定义的模块方法 5 function foo() { 6 lib.log("jeri"); 7 } 8 9 // 把方法暴露给其他模块 10 exports.foo = foo;
虽然在浏览器端可以使用CommonJS组织模块,但有不少开发者认为CommonJS更适合于服务器端开发,因为很多CommonJS API具有面向服务器的特性,如io、system等。NodeJs使用的就是CommonJS规范。当一个模块可能用于服务器端时,一些开发人员倾向于选择CommonJS,其他情况下使用AMD。
AMD模块可以使用插件,也就是说当我们加载依赖时,可以加载任意格式的文件,并且可以定义更细粒度的东西,如构造函数和函数,但CommonJS模块仅能定义不易使用的对象。在模块的定义和引入方面,二者也有很大的不同。AMD和CommonJS都是非常优秀的模块模式,各自有不同的目标。
TC39——负责制定 ECMAScript 语法和语义以及其未来迭代的标准团体,在近几年一直在密切关注 JavaScript 在大规模开发中的使用情况的演进,而且也敏感地意识到了需要有更好的语言特性来编写更加模块化的 JS。基于这个原因,目前有提案已经提出了一系列令人振奋的对语言的补充。虽然Harmony还处于建议阶段,但我们可以先一览新的接口特性。
在ES.next中,已经为模块依赖和模块导出提供了更加简洁的方式,那就是import和export。
1 module staff { 2 // 指定导出 3 export var baker = { 4 bake: function(item) { 5 console.log('Woo! I just baked ' + item); 6 } 7 } 8 }; 9 10 module skills { 11 export var specialty = "baking"; 12 export var experience = "5 years"; 13 }; 14 15 module cakeFactory { 16 // 指定依赖项 17 import baker from staff; 18 19 // 通过通配符导入所有东西 20 import * from skills; 21 22 export var oven = { 23 makeCupcake: function(toppings) { 24 baker.bake('cupcake', toppings); 25 }, 26 makeMuffin: function(mSize) { 27 baker.bake('muffin', size); 28 } 29 } 30 };
在ES.next里还建议支持远程模块加载,示例如下:
1 module cakeFactory from 'http://****/cakes.js'; 2 3 cakeFactory.oven.makeCupcake('sprinkles'); 4 5 cakeFactory.oven.makeMuffin('large');
模块加载器建议一个动态的API在严格控制的上下问中加载模块。加载器支持的特征包括用来加载模块的 load( url, moduleInstance, error)
,以及 createModule( object, globalModuleReferences)
等等。
对于面向服务器的开发者来说,在 ES.next 中提出的模块系统并非局限于对浏览器端模块的关注。例如下面是一个在服务器端使用的类CommonJS模块:
1 // io/File.js 2 export function open(path) { 3 // ... 4 }; 5 export function close(hnd) { 6 // ... 7 };
1 // compiler/LexicalHandler.js 2 module file from 'io/File'; 3 4 import {open, close} from file; 5 export function scan( in ) { 6 try { 7 var h = open( in )... 8 } finally { 9 close(h) 10 } 11 }
1 module lexer from 'compiler/LexicalHandler'; 2 module stdlib from '@std'; 3 4 // ... scan(cmdline[0]) ...
ES Harmony有了很多令人振奋的新功能加入,以求简化应用程序的开发,并处理依赖管理等问题。然而,至今为止,还没有形成新的规范,并不能得到众多浏览器的支持。目前,要想使用Harmony语法的最佳选择是通过transpiler,如谷歌的Traceur或Esprima。在新规范发布之前,我们还是选择AMD和CommonJS较为稳妥。
本文论述了几种模块化编程的方式,它们各有优劣,各有适用场景。希望在以后的编程实践中,选择合适的模式,不断提高代码的可读性、可维护性和可扩展性。