原文: http://www.sitepen.com/blog/2012/06/25/amd-the-definitive-source/
作者:Kris Zyp
译者:Elaine Liu
究竟什么是AMD?
随着web应用不断发展和对JavaScript依赖的进一步加深,出现了使用模块(Modules)来组织代码和依赖性。模块使得我们创建明确清晰的组件和接口,这些组件和接口能够很容易的加载并连接到其依赖组件。 AMD模块系统提供了使用JavaScript模块来构建Web应用的完美方式,并且这种方式具有形式简单,异步加载和广泛采用的特点。
异步模块定义(AMD)格式是一套API,它用于定义可重用的并能在多种框架使用的模块。开发AMD是为了提供一种定义模块的方式,这种方式可以使用原生的浏览器脚本元素机制来实现模块的异步加载。AMD API由2009年Dojo 社区的讨论中产生,然后移动到讨论CommonJS如何更好的为浏览器适应CommonJS模块格式(被NodeJS使用)。 CommonJS已经发展成为单独的一个标准并有其专门的社区。AMD已经广泛普及,形成了众多模块加载实现并被广泛使用。在SitePen公司,我们广泛的使用Dojo的AMD机制工作,为其提供支持,并积极的建设这一机制。
本文中用到的一些重要词汇
- 模块(module) —— 一个经过封装的JavaScript文件,它遵循模块的格式,指定依赖和提供模块输出。
- 模块标识(module ID)——唯一标识模块的字符串,相对模块标识将根据当前模块的标识解释为绝对模块标识
- 模块路径 (module path)——用于检索模块的URL。一个模块标识对应于一个模块路径,该路径是由加载器配置规则设定的(缺省情况下,模块路径假定为该模块对于根路径的相对路径,根路径通常是模块加载器包所在的父目录)。
- 模块加载器(module loader)——解析和加载模块以及相关依赖的JavaScript代码,它与插件交互,并处理加载配置。
- 包(package)——一组模块集合。例如dojo,dijit以及dgrid都是包。
- 构建器(builder)——用于将模块(或者多个模块)以及其依赖连接在一起产生单个JavaScript文件的工具,这样使得一个应用程序能够包含多个模块,并能创建多个构建层次,从而使得它们在被加载时实现HTTP请求数目最小化。
- 层(layer)——一个文件,它包含若干模块并由构建器优化生成单个文件。
- 依赖(dependency)——为了使另一个模块正常工作而必须加载的模块。
- AMD——异步模块定义,一种为浏览器开发提供最优体验的模块定义格式。
- 工厂方法(factory)——通过define定义的并提供给模块加载器的函数,它在所有依赖加载完后执行一次。
为什么需要AMD模块?
模块化系统的基础前提是:
- 允许创建被封装的代码片段,也就是所谓的模块
- 定义本模块与其他模块之间的依赖
- 定义可以被其他模块使用的输出的功能
- 谨慎的使用这些模块提供的功能
AMD满足以上需求,并将依赖模块设置为其回调函数的参数从而实现在模块代码被执行前异步的加载这些依赖模块。AMD还提供了加载非AMD资源的插件系统。
虽然有其他的load JavaScript的替代方法,但使用脚本元素来加载JavaScript有特有的优势,包括性能,减少调试(尤其在一些老版本的浏览器上)以及跨域的支持。因此AMD致力于提供基于浏览器开发的最优体验。
AMD格式提供了几个关键的好处。首先,它提供了一种紧凑的声明依赖的方式。通过简单的字符串数组来定义模块依赖,使得开发者能够花很小的代价轻松列举大量模块依赖性。
AMD帮助消除对全局变量的需求。 每个模块都通过局部变量引用或者返回对象来定义其依赖模块以及输出功能。因此,模块不需要引入全局变量就能够定义其功能并实现与其他模块的交互。AMD同时是“匿名的”,意味着模块不需要硬编码指向其路径的引用, 模块名仅依赖其文件名和目录路径,极大的降低了重构的工作量。
通过将依赖性映射为局部变量, AMD鼓励高效能的编码实践。如果没有AMD模块加载器,传统的JavaScript代码必须依赖层层嵌套的对象来“命名”给定的脚本或者模块。如果使用这种方式,通常需要通过一组属性来访问某个功能,这会造成全局变量的查找和众多属性的查找,增加了额外的开发工作同时降低了程序的性能。通过将模块依赖性映射为局部变量,只需要一个简单的局部变量就能访问某个功能,这是极其快速的并且能够被JavaScript引擎优化。
使用AMD
最基础的AMD API是define()方法,用于定义一个模块及其依赖。通常我们这样来写一个模块:
- define(dependencyIds, function(dependency1, dependency2,...){
-
- });
dependencyIds 参数是一个字符串数组,用于表示需要加载的依赖模块。这些依赖模块将会被加载和执行。一旦所有依赖都被执行完毕,它们的输出将作为参数提供给回调函数(define()方法的第二个参数)
为了展示AMD的基础用法,我们可以定义一个使用dojo/query(css选择器查询)和dojo/on(事件处理)的模块。
- define(["dojo/query", "dojo/on"],
- function(query, on){
- return {
- flashHeaderOnClick: function(button){
- on(button, "click", function(){
- query(".header").style("color", "red");
- });
- }
- };
- });
一旦dojo/query和dojo/on被加载(当然也必须等到它们本事的依赖也被加载,以此类推), 回调函数将被调用,同时dojo/query的输出(一个负责CSS选择器查询的函数)作为参数query,dojo/on的输出(一个可以添加事件监听器的函数)作为参数on被传到这个回调函数中。回调函数(通常认为是模块的工厂方法)被保证只调用一次。
列在依赖集合中的每个模块标识是一个抽象的模块路径。说它是抽象的因为它被模块加载器转移成真正的URL。正如你所见,模块路径并不需要包含“.js”后缀,这个后缀在加载的时候会自动添加。当模块标识直接由模块名打头时,该名称是模块的绝对标识。相比之下,我们也可以通过由“./”或者"../"打头表示当前目录或者父目录来指定相对标识。这些相对标识会通过标准路径解析规则来解析成绝对标识。你可以定义一个模块路径规则来决定这些模块路径将如何转换成URL。缺省情况下,模块根目录定义为相对于模块加载器包的父目录的路径。例如,如果我们用下面的方法加载Dojo(注意在这里我们设置async属性为true来保证异步AMD加载)
- <script src="/path/to/dojo/dojo.js" data-dojo-config="async:true"></script>
那么,假设根目录到模块的路径为“/path/to/”。如果我们指定依赖于“my/module”,这个依赖将被解析为“/path/to/my/module.js”.
初始模块加载
我们已经描述了如何创建一个简单的模块。然而,我们还需要一个入口来触发这些依赖链。我们可以通过使用require() API来做到这一点。这个函数签名基本跟define()一致,区别在于它用于加载依赖但而不需要定义一个模块(当一个模块被定义时,如果它不被别的模块请求它是不会执行的)我们可以像下面这样加载我们的应用程序:
- <script src="/path/to/dojo/dojo.js"></script>
- <script type="text/javascript"></script>
Dojo提供了加载初始模块的快捷方式。初始模块能够通过指定deps配置属性来加载。
- <script src="/path/to/dojo/dojo.js"></script>
这是加载应用程序的一个非常棒的方式,因为JavaScript代码能够完全从HTML中消除,仅需留下一个脚本标记来引导整个剩余的程序。同时,这种方式让你能够轻松的创建强劲的build,它能够将你的应用程序代码和dojo.js组合成为单独的一个文件而不需要在build之后改变HTML脚本标签。RequireJS和其他模块加载器也有类似的加载顶层模块的选项。
上图展示了由require()调用引起的一连串的依赖加载。require()的调用开启加载第一个模块,接着根据需要加载各模块的依赖模块。那些不需要的模块(如上图中的模块d)则永远不会被加载或者执行。
require()函数还可用于配置模块路径查找以及其他选项,但这一般来说对各个模块加载器都有特定的实现。更多信息请参考各个加载器关于配置细节的文档。
插件和Dojo最优化
AMD还支持加载其它资源的插件。这一点对于加载非AMD依赖非常有价值,例如加载HTML片段和模板,CSS,国际化相关的特定资源等。插件机制让我们在依赖列表中引用这些非AMD资源。语法如下:
dojo/text插件就是一个常用的插件,它允许你直接将一个文件加载为一段文本。使用这个插件时,我们将目标文件列为资源名。在小部件加载它们的HTML模板时经常使用这个插件。例如,我们用下面的方式可以通过Dojo创建我们自己的小部件:
- define(["dojo/_base/declare", "dijit/_WidgetBase", "dijit/_TemplatedMixin", "dojo/text!./templates/foo.html"],
- function(declare, _WidgetBase, _TemplatedMixin, template){
- return declare([_WidgetBase, _TemplatedMixin], {
- templateString: template
- });
- });
这是一个多层面创建Dojo小部件的范例。首先,它展示了利用Dijit基类创建小部件的标准用法。你可能也注意到我们如何创建一个小部件类并如何返回它。我们没有使用任何命名空间或者类名来使用declare()(类构造方法)。因为AMD消除了对命名空间的需求,我们不再需要用declare()来创建全局的类名。这一点与AMD模块中写匿名模块的策略是一致的。同样的,一个匿名的模块是不需要在其模块内部硬编码任何相对于自身的路径或者名称的。我们可以轻松的对模块重命名或者将其移动到其他的路径而不需要改模块内的任何代码。通常我们推荐使用这种方法来定义匿名类,但如果你需要用声明式的标记来使用这个小部件的话,为了创建一个具有命名空间的全局变量,使其能够让Dojo 解析器在Dojo1.7中引用,你还是需要包含命名空间/类名来定义类。Dojo 1.8中对此作了改进,你可以使用模块标识来做到这一点。
还有一些Dojo包含的插件是非常有用的。dojo/i18n插件在加载国际化区域性包(常用于翻译文本或者区域信息格式化)时使用。另外一个重要的插件是dojo/domReady,它通常被推荐用于取代dojo.ready。如果一个模块除了加载其他依赖还需要等待整个DOM可用才执行的时候,这个插件使得这一过程非常简单,不需要再加一层额外的回调。我们将dojo/domReady作为插件使用时,但需要对应的资源名。
- define(["dojo/query", "dojo/domReady!"],
- function(query){
-
- query(".some-class").forEach(function(node){
-
- });
- });
另一个有价值的插件是dojo/has。 这个模块用于辅助检测某些特征,帮助你基于当前浏览器的某些特征来选择不同的代码路径。然而这个模块也常被用作一个标准模块 ,提供一个has()函数,它也同时可以当作插件使用。作插件使用时能够帮助我们根据当前的特性条件性的加载某些依赖。dojo/has插件的语法采用了一个三元操作符,它将特性名称作为条件,而模块标识作为值。例如,我们可以在当然浏览器支持touch事件的条件下加载单独的touch UI模块:
- define(["dojo/has!touch?ui/touch:ui/desktop"],
- function(ui){
-
-
- ui.start();
- });
这个三元操作符可以是嵌套的,空字符串可用于表示不加载任何模块。
使用dojo/has的好处不仅仅是提供一个特征检测的运行时API。如果使用dojo/has,不光在你的代码中有has()形式,同时也作为依赖插件,build系统可以检测这些特性的分支。这意味着我们可以创建设备或者浏览器相关的build,它们能够为某些特定的特征集合进行高度优化,只需要在build里的staticHasFeatures选项定义期望的特性,然后build就会自动的处理相应的代码分支。
数据模块
对于没有任何依赖的模块,他们被简单的定义为一个对象(就像数据一样)。你可以使用仅有一个参数的define()调用,该参数就是那个对象。这是非常简单和直接明了的。
这与JSONP非常类似,支持基于脚本的JSON数据传输。但是,实际上AMD对于JSONP的优势在于它不需要请求任何URL参数,其目标可以是一个静态文件,不需要服务器端任何支持代码来为数据加上参数化的回调函数前缀。然而,这项技术必须小心使用。模块加载器总是会对模块进行缓存,因此后续的针对相同模块标识的require()请求会产生同样的缓存数据。这对你的检索需求可能会造成一定的困扰。
构建(builds)
AMD 被设计得非常容易被构建工具解析从而创建出将多个模块代码连接在一起并压缩的一个单独文件。模块化系统在这方面提供了巨大的优势因为构建工具能够基于模块中列出的依赖自动的生成这个构建文件,而不需要依赖任何手写或者更新的脚本来创建。由于请求数目的减少,构建极大的减少了加载时间,并且由于依赖已经清楚的列在代码中,因而使用AMD实现这一点简直轻而易举。
不使用构建
使用构建
(译者注:原文作者选用的实验截图可能是本地资源加载的情况,由于本地资源加载的随机性,在使用构建之后优势不明显。但实际在网络传输中,使用构建会大大减少加载时间。)
性能
就像前面提到的那样,使用脚本元素注入比其他的方法快是因为它更依赖于原生的浏览器脚本加载机制。我们基于dojo.js创建了一些模块的测试用例,脚本元素加载比使用XHR eval的方式快了大概60-90%。在Chrome中,如果有大量的小模块,每个模块加载的时间大概是5-6ms
,而XHR+eval方式平均每个模块加载时间则接近9-10ms。在Firefox中,同步XHR方式比异步方式更快,而在IE中异步XHR比同步的快,但脚本元素加载无疑是最快的一个。让我们感到意外的是IE9是最快的一个浏览器,不过这有可能是因为在Firefox和Chrome中debugger/inspector增加了一些额外的性能开销。
模块加载器
AMD API是开放的,现在已有有多个AMD模块加载器和构造器的实现。这里介绍几个重要的AMD加载器:
- Dojo – 这是一个完全的包括插件和构造器的AMD加载器。这是我们通常用来实现Dojo工具包的加载器。
- RequireJS – 这是AMD加载器的元老也是AMD加载器的典范。其作者James Burke是AMD的主要作者和倡导者。这也是一个完整的包含构造器的加载器。
- curl.js – 这是一个快速的AMD加载器,具有超级棒的插件支持(以及它自带的插件库)和自带的构造器。
- lsjs – 这是一个专门设计用于在本地存储缓存模块的AMD模块加载器。其作者同时还写了一个独立的优化器。
- NeedJS – 一个轻量级的AMD模块加载器。
- brequire – 另一个轻量级的AMD模块加载器。
- inject – 它是由LinkedIn创建并使用的,是一个快速轻量级的加载器,不提供对插件的支持。
- Almond – 这是RequireJS的轻量级版本。
获取AMD模块
现在可以找到越来越多的AMD格式的包和模块。Dojo 基础包网站集中的给出了一个可以找到的包的列表。CPM 安装器 可以用于安装任何通过Dojo基础包网站注册的包(同时自动安装他们的依赖)。
James Burke, RequireJS的作者创建了 Volo——一个支持直接从github上面安装包的安装器。当然你也可以直接从他们的项目网站(在github或者其他地方)上直接下载模块,然后自己组织你的目录结构。
有了AMD,我们就能够轻松的使用任何包而不仅仅是Dojo模块来构造应用程序。将普通的脚本转换成AMD也是非常简单的。你只需要在define中用一个空数组表面依赖,然后将脚本直接包含在define的回调函数体中。当然如果该脚本必须在其他某些脚本之后执行,你也可以添加依赖。例如:
- my-script.js:
-
- defined([], function(){
-
- ...
-
- });
我们可以构造用各种方式导入模块的应用程序。
- require(["dgrid/Grid", "dojo/query", "my-script"], function(Grid, query){
- new Grid(config, query("#grid")[0]);
- });
必须提醒的一点是当将脚本转换成模块时,如果脚本含有顶层的函数或者变量,它们原本是全局函数或者变量,但是在define()回调函数之内它们变成了回调函数的局部函数和变量,因此不会产生全局的函数或者变量。你或者显示的改变你的代码来创建一个全局函数或者变量(删掉那些变量前面的var或者function前缀(当你知道该脚本要与其他依赖于这些全局变量的脚本共同工作时你很可能需要这么做),或者改变转换后的模块使其返回函数或者变量作为模块输出并且让其依赖模块来使用这些输出(这种方法使你能够追求无全局变量的AMD典范)。
直接加载非AMD脚本
大多数模块加载器同时支持直接加载非AMD脚本。我们可以将一个普通脚本包含在我们的依赖中,并用“.js”后缀或者提供一个URL绝对路径或者用“\”开头的URL来表示它们是非AMD的。加载的脚本不能够提供直接的AMD输出,但能够通过标准的创建全局变量或者函数的形式来提供它自身的功能。例如我们可以加载Dojo和jQuery:
- require(["dojo", "jquery.js"], function(dojo){
- dojo.query(...);
- $(...);
- });
保持小的代码体积
AMD能够轻松的与多种脚本库协同组合工作。然而,虽然能够很容易实现这一点,但在某些方面你必须小心。将Dojo和jQuery这样的脚本库组合也许能够正常工作,但因为Dojo和jQuery在功能上绝大部分是重合的,它会导致增加很多不必要的下载量。事实上,Dojo新模块策略的一个关键之处就在于避免下载任何多余的模块。除了向AMD转换,Dojo基础功能也被拆分成多个能够单独使用的模块,使得Dojo能够尽可能的为某个应用程序保持最小的体积。事实上,最新的Dojo 应用程序和组件开发(像dgrid)经常使得整个应用程序比原先版本的Dojo基类还要小。
AMD的反对意见
当然也有一些对AMD的反对声音存在。一个反对意见是使用原来的CommonJS格式(AMD正是由此产生)比AMD更简单明了,更不容易出错。CommonJS格式的确没有繁复的使用“礼节”。然而,这种格式也是有一些问题的。我们可以让源文件原封不动的传给浏览器。这需要模块加载器将代码封装在一个注入了必须的CommonJS变量的头部中,用这种方式来调用XHR和eval来加载模块。这种方法的缺点我们之前已经讨论过了,包括降低性能,难于在老版本的浏览器上调试,以及跨域访问的限制。另一种方法是使用一个实时的构造过程,或者在服务器端按需封装的机制对CommonJS模块只提供必要的封装,这实际上跟AMD的思想是一致的。这些方法在很多情况下不一定会带来太多麻烦,也是合理的开发方式。但是为了满足最广泛的用户需求,这里用户可以在工作在一个非常简单的服务器上,或者面对跨浏览器的情况,或者使用老版本的浏览器,AMD减少了这些问题发生的几率,这也是Dojo的使命之一。
AMD的依赖列举机制也因为容易出错而经常被诟病,因为它需要维护依赖列表和回调函数的参数列表时刻一致。如果这两个列表不一致,模块的引用就会大错特错。实际应用中,我们在这个问题上并没有碰见太多的困难。另外还有一种使用AMD的替代方法可以解决这一问题。AMD支持调用仅有单个回调参数的define(),回调参数是一个require工厂方法,它包含了多个require()调用而不是一个依赖列表。这种方法不仅帮助解决如何保持依赖列表的同步问题,而且还使得添加CommonJs封装变得轻而易举。因为这个工厂函数的函数体是符合CommonJS模块形式的。下面就是一个使用这种方法来定义模块的例子:
- define(function(require){
- var query = require("dojo/query");
- var on = require("dojo/on");
- ...
- });
当提供一个参数时,请求,输出和模块就会自动的提供给该工厂函数。AMD模块加载器将会扫描该工厂函数的require调用,并自动的在运行该工厂方法之前加载他们。因为require调用直接内联在变量赋值语句中,你可以很容易的删除某个依赖声明而不需要任何保持依赖同步的操作。
关于require() API的一个说明:当使用一个字符串来调用require(),它是同步执行的,但是如果把它放在队列里面,它是一部执行的。因此例子中的依赖模块仍然是在执行回调函数之前异步加载的,当依赖都加载到内存中,代码中单字符串的require调用就被以同步的方式执行。
AMD的局限
AMD为我们提供了一个模块加载并协同工作的重要层面。然而,AMD仅仅是模块定义。它并不能为模块创建的API开出任何通用的“特别处方”。比如,你不能指望模块加载器给你提供查询引擎,并期望它从一堆可替换的查询模块中给你返回一个通用的API。当然定义这样的API更利于模块交互,但这不在AMD的范畴内。大多数模块加载器不支持将模块标识映射到不同的路径,因此如果你有可替换的模块,你最好自己定义一个模块标识到不同目标路径的映射来解决这一点。
渐进式加载
目前我们看到的AMD最大的问题不在于API,而是在实际应用中有种趋势是预先声明所有的依赖(这一点上文中我们一直这么提,真抱歉我们现在才解释这个问题!)。然而,很多模块能够正常的工作,而将某些依赖的加载延迟到它们实际需要的时候再加载。采用延迟加载策略是非常对提供一个渐进式加载页面是非常有价值的。有了这样一个渐进式加载页面,页面上的组件能够在其组件代码下载完后就显示,整个页面不需要等到所有JavaScript代码都下载完就能渲染和使用。我们可以通过使用异步请求require([])API,将我们的模块编写成支持延迟加载特定模块的形式。在下面的例子中,我们只为该函数加载必须的代码来创建子容器节点,延迟加载容器内小部件的代码,从而实现即时的视觉交互:
-
- define(["dojo/dom-create", "require"],
- function(domCreate, require){
- return function(node){
-
-
-
- var slider = domCreate("div", {className:"slider"}, node);
- var progress = domCreate("div", {className:"progress"}, node);
-
-
- require(["dijit/form/HorizontalSlider"], function(Slider){
- new Slider({}, slider);
- });
- require(["dijit/Progress"], function(Progress){
- new Progress({}, progress);
- });
- }
这种方式提供了绝妙的用户体验,因为人们可以在组件一下载完成是就与之交互,而不需要等到整个页面全部加载完。如果用户看到页面逐步渲染,很可能会感觉程序运行得比原来快,更富有响应。
require,exports
在上面的例子中,我们使用了一个特殊的依赖——“请求(require)”,它是一个模块内的require()函数的引用,允许我们使用相对于该模块的引用。(如果你使用全局的“require”,相对模块标识则不是相对于当前模块的)
另外一个特殊的依赖是“输出(exports)”。有了输出依赖,输出对象体现在参数中,而不是返回输出对象。模块可以向输出对象添加属性。这一点在模块有循环引用的情况时特别有用。因为模块工厂函数可以开始运行,并添加输出对象,而另外一个函数可以在前者运行结束前就使用前者的输出对象。关于循环引用使用输出对象的一个简单例子如下:
- main.js:
- define(["component", "exports"],
- function(component, exports){
-
-
- exports.config = {
- title: "test"
- };
- exports.start = function(){
- new component.Widget();
- };
- });
- component.js:
- define(["main", "exports", "dojo/_base/declare"],
- function(main, exports, declare){
-
-
- exports.Widget = declare({
- showTitle: function(){
- alert(main.config.title);
- }
- });
- });
如果我们只是依赖于函数的返回值,这个例子就不可能正常的工作,因为在循环中工厂函数需要先执行完毕,不可能访问另一个函数的返回值。
就像再前面的例子提到的那样,如果我们省略依赖性列表,那么依赖就被认做是缺省的”require“和”exports“,require()调用会被扫描,因此上例可以写成:
- define(function(require, exports){
- var query = require("dojo/query");
- exports.myFunction = function(){
- ....
- };
- });
展望
EcmaScript 委员会致力于在JavaScript中增加原生模块的支持。它们提供的添加方法是基于JavaScript语言中定义和引用模块的新语法。这种新语法包括了一个module关键字用于在脚本中定义模块,一个expoert关键字用于定义输出,一个import关键字用以定义需要引入的模块属性。这些操作符与AMD中的概念一一对应,使得转换变得相对容易。如果我们要将本文的第一个例子用Harmony 模块系统定义的方法来写,下面是一种写法:
- import {query} from "dojo/query.js";
- import {on} from "dojo/on.js";
- export function flashHeaderOnClick(button){
- on(button, "click", function(){
- query(".header").style("color", "red");
- });
- }
现在提出的新模块系统包括支持定制的模块加载器,它能够与新的模块系统交互,还能够用于保留某些AMD现存的某些特性,例如用插件访问非JavaScript资源。
结论
AMD为基于浏览器的网络应用提供了强大的模块化系统,它利于原生的浏览器加载方式实现异步加载,支持对各种形式的资源灵活访问的插件,同时用一个简单明了的格式来实现它的功能。由于有了类似Dojo,RequireJS等众多出色的AMD项目,AMD世界是让人兴奋的,并且为发展快速,可操作的JavaScript模块带来栩栩生机。
.