为何选择 AMD?

本页面讨论了设计动机及异步模块定义(AMD)API的使用,这是 RequireJS 支持的 JavaScript 模块 API。还有一篇不同的页面讲述了网络上模块的一般方法.

模块的目的 § 1

什么是 JavaScript 模块?它的目的是什么?

  • 定义:如何将一段代码封装成一个有用的单元,以及如何注册其功能 / 导出模块值。
  • 依赖引用:如何引用其他代码单元。

当今的网络 § 2

(function () {
    var $ = this.jQuery;

    this.myExample = function () {};
}());

当今的 JavaScript 代码片段是如何定义的?

  • 通过立即执行的工厂函数进行定义。
  • 对依赖项的引用是通过 HTML script 标签加载的全局变量名完成的。
  • 依赖关系声明得非常弱:开发者需要知道正确的依赖顺序。例如,包含 Backbone 的文件不能出现在 jQuery 标签之前。
  • 这需要额外的工具来将一组 script 标签替换为一个标签以进行优化部署。

在大型项目中管理起来可能比较困难,尤其是当脚本开始有许多相互重叠和嵌套的依赖时。手写 script 标签并不具备良好的扩展性,并且无法按需加载脚本。

CommonJS § 3

var $ = require('jquery');
exports.myExample = function () {};

最初的CommonJS (CJS) 列表参与者决定制定一种适用于当前 JavaScript 语言的模块格式,但不一定受限于浏览器 JS 环境的限制。希望在浏览器中使用一些临时措施,并可能影响浏览器厂商构建能够更好地原生支持这种模块格式的解决方案。这些临时措施包括:

  • 使用服务器将 CJS 模块转换为可在浏览器中使用的格式。
  • 或者使用 XMLHttpRequest (XHR) 加载模块文本,并在浏览器内进行文本转换/解析。

CJS 模块格式每个文件只允许一个模块,因此会使用一种“传输格式”来在一个文件中捆绑多个模块以便优化/打包。

使用这种方法,CommonJS 小组能够处理依赖引用、循环依赖问题以及获取有关当前模块的一些属性。但他们并没有完全接纳浏览器环境中一些无法改变但仍会影响模块设计的因素:

  • 网络加载
  • 固有的异步性

同时这也意味着给网页开发者带来了更大的负担,而这些临时措施也使得调试变得更差。基于 eval 的调试或调试多个合并为一个文件的文件存在实际的弱点。这些弱点未来可能会通过浏览器工具加以解决,但最终结果是:在最常用的 JS 环境即浏览器中,使用 CommonJS 模块目前并不是最优选择。

AMD § 4

define(['jquery'] , function ($) {
    return function () {};
});

AMD 格式源于对一种比当前“编写大量隐含依赖关系的 script 标签并手动排序”的方式更好的模块格式的需求,同时也希望能有一种易于在浏览器中直接使用的格式,具有良好的调试特性且不需要特定服务器工具即可开始使用。它起源于 Dojo 在使用 XHR + eval 时的实际经验,并希望在未来避免其缺点。

相较于当前网页中的“全局变量和 script 标签”,它是一种改进,因为:

  • 使用了 CommonJS 的字符串 ID 来表示依赖项。清晰地声明依赖,避免使用全局变量。
  • ID 可以映射到不同路径。这允许更换实现。这对于创建用于单元测试的模拟对象非常有用。对于上面的代码示例来说,代码只需要某个实现了 jQuery API 和行为的对象即可,不一定是 jQuery 本身。
  • 封装了模块定义。提供工具以避免污染全局命名空间。
  • 明确地定义模块值的方式。可以使用 "return value;" 或 CommonJS 的 "exports" 习惯用法,后者在处理循环依赖时很有用。

相较于 CommonJS 模块也是一种改进,因为:

  • 更适合在浏览器中运行,陷阱最少。其他方法在调试、跨域/CDN 使用、file:// 协议使用及需要特定服务器工具等方面存在问题。
  • 定义了一种方式可以在一个文件中包含多个模块。在 CommonJS 中这种格式称为“传输格式”,而该小组尚未就传输格式达成一致。
  • 允许将函数作为返回值。这对于构造函数非常有用。而在 CommonJS 中这种方式较为笨拙,必须始终设置 exports 对象上的属性。Node 支持 module.exports = function () {},但这不是 CommonJS 规范的一部分。

模块定义 § 5

使用 JavaScript 函数进行封装的方法被记录为模块模式:

(function () {
   this.myGlobal = function () {};
}());

这类模块依赖于将属性附加到全局对象上来导出模块值,并且在这种模型下很难声明依赖项。该模型假设依赖项在此函数执行时已经可用。这限制了依赖项的加载策略。

AMD 通过以下方式解决了这些问题:

  • 通过调用 define() 注册工厂函数,而不是立即执行它。
  • 将依赖项作为字符串数组传入,不要直接获取全局变量。
  • 只有在所有依赖项都已加载并执行后才执行工厂函数。
  • 将依赖模块作为参数传递给工厂函数。
//Calling define with a dependency array and a factory function
define(['dep1', 'dep2'], function (dep1, dep2) {

    //Define the module value by returning a value.
    return function () {};
});

命名模块 § 6

注意上面的模块没有为自己声明名称。这使得模块非常便于移植。它允许开发者将模块放置在不同的路径中以赋予其不同的 ID/名称。AMD 加载器会根据其他脚本如何引用该模块来为其分配一个 ID。

然而,为了性能而将多个模块合并在一起的工具需要一种方式来为优化后的文件中的每个模块命名。为此,AMD 允许将一个字符串作为 define() 的第一个参数:

//Calling define with module ID, dependency array, and factory function
define('myModule', ['dep1', 'dep2'], function (dep1, dep2) {

    //Define the module value by returning a value.
    return function () {};
});

在开发过程中,你应该避免手动命名模块,并且每个文件中只放一个模块。然而,对于工具和性能来说,模块解决方案需要一种方法来识别构建后的资源中的模块。

语法糖 § 7

上面的 AMD 示例可以在所有浏览器中正常工作。然而,存在命名依赖项与函数参数名称不匹配的风险,并且如果模块有很多依赖项,看起来可能会有点奇怪:

define([ "require", "jquery", "blade/object", "blade/fn", "rdapi",
         "oauth", "blade/jig", "blade/url", "dispatch", "accounts",
         "storage", "services", "widgets/AccountPanel", "widgets/TabButton",
         "widgets/AddAccount", "less", "osTheme", "jquery-ui-1.8.7.min",
         "jquery.textOverflow"],
function (require,   $,        object,         fn,         rdapi,
          oauth,   jig,         url,         dispatch,   accounts,
          storage,   services,   AccountPanel,           TabButton,
          AddAccount,           less,   osTheme) {

});

为了简化这一过程,并方便对 CommonJS 模块进行简单封装,支持如下形式的 define,有时被称为“简化版 CommonJS 封装”:

define(function (require) {
    var dependency1 = require('dependency1'),
        dependency2 = require('dependency2');

    return function () {};
});

AMD 加载器会通过使用 Function.prototype.toString() 解析出 require('') 调用,然后在内部将上述 define 调用转换为如下形式:

define(['require', 'dependency1', 'dependency2'], function (require) {
    var dependency1 = require('dependency1'),
        dependency2 = require('dependency2');

    return function () {};
});

这样可以让加载器异步加载 dependency1 和 dependency2,执行这些依赖项,然后再执行此函数。

并非所有浏览器都能提供可用的 Function.prototype.toString() 结果。截至 2011 年 10 月,PS3 和旧版 Opera Mobile 浏览器就不支持。这些浏览器更有可能因为网络或设备限制而需要模块的优化构建,所以只需使用能够将这些文件转换为规范化的依赖数组形式的优化器即可,例如:揭露了 RequireJS 优化器的功能.

由于无法支持 toString() 扫描的浏览器数量非常少,因此可以安全地将这种语法糖用在所有模块中,特别是如果你喜欢将依赖项名称与保存模块值的变量一一对应时。

CommonJS 兼容性 § 8

尽管这种语法糖被称为“简化版 CommonJS 封装”,但它并不能完全兼容所有的 CommonJS 模块。然而,那些不被支持的情况即使在浏览器中也很可能出错,因为它们通常假设依赖项是同步加载的。

根据我的(非常不科学的)个人经验,大多数 CJS 模块(大约 95%)都可以完美兼容这种简化版 CommonJS 封装。

出现问题的模块通常是动态计算依赖项的情况,即 require() 调用没有使用字符串字面量,或者不是声明式 require() 调用的情况。比如像这样的代码就会失败:

//BAD
var mod = require(someCondition ? 'a' : 'b');

//BAD
if (someCondition) {
    var a = require('a');
} else {
    var a = require('a1');
}

这些情况由callback-require, require([moduleName], function (){})通常在 AMD 加载器中存在的机制来处理。

AMD 的执行模型与 ECMAScript Harmony 模块的规范更加一致。无法在 AMD 包裹器中运行的 CommonJS 模块也无法作为 Harmony 模块运行。AMD 的代码执行行为更具未来兼容性。

冗余性 vs 实用性

至少相对于 CJS 模块而言,AMD 遭受的一个批评是它需要一定层级的缩进以及函数封装。

但实际情况是:使用 AMD 所需的额外输入和一定的缩进并不重要。当你编写代码时,你的时间花在了这里:

  • 思考问题本身。
  • 阅读代码。

编写代码的大部分时间都花在思考上,而不是打字上。虽然一般来说较少的代码更好,但这种方法也有其极限,而 AMD 中的额外输入其实并不多。

大多数 Web 开发者无论如何都会使用函数封装来避免污染页面上的全局作用域。看到功能被包裹在函数中是一种非常常见的现象,并不会增加模块的阅读成本。

同时,CommonJS 格式也有一些隐藏成本:

  • 工具依赖的成本
  • 浏览器中会出现问题的边界情况,如跨域访问
  • 更差的调试体验,这个成本会随着时间不断累积

AMD 模块对工具的依赖更少,边界情况也更少,并且调试支持更好。

最重要的事情在于:能够真正与他人共享代码。AMD 是实现这一目标所需投入最少的方式。

拥有一个可在当前浏览器中运行、易于调试的模块系统意味着我们能在未来打造最佳 JavaScript 模块系统方面积累宝贵的实际经验。

AMD 及其相关 API 已经帮助我们展示了任何未来的 JS 模块系统应具备的特点:

  • 将函数作为模块值返回,特别是构造函数,有助于更好的 API 设计。Node 中有 module.exports 来支持这一点,但使用 "return function (){}" 更加简洁明了。这意味着不必获取 "module" 对象来进行 module.exports 操作,代码表达也更清晰。
  • 动态代码加载(在AMD系统中通过require([], function (){}))是一种基本需求。CJS讨论过这个问题,并提出了一些方案,但并未被完全采纳。Node对此需求没有任何支持,而是依赖require('')的同步行为,这在网络环境中不可移植。
  • 加载器插件非常有用。它有助于避免基于回调编程中常见的嵌套大括号缩进问题。
  • 可以选择性地将一个模块映射到另一个位置加载,这样便于为测试提供模拟对象。
  • 每个模块最多只能有一个IO操作,并且应该简单直接。网页浏览器无法容忍多次IO查找来寻找一个模块。这表明当前Node使用的多个路径查找方式存在问题,应避免使用package.json中的"main"属性。只需使用能根据项目位置轻松映射到单一位置的模块名,采用合理的默认约定,无需繁琐配置,但在需要时允许简单配置。
  • 最好有一个“选择加入”的调用可以执行,以便旧的JS代码也可以参与新系统。

如果一个JS模块系统无法实现上述功能,则与AMD及其相关的callback-require, 加载器插件和基于路径的模块ID等API相比,将处于明显劣势。

当前使用的AMD § 9

截至2011年10月中旬,AMD已经在网页上获得了良好的采用:

你可以做的事情 § 10

如果你编写应用程序:

如果你是脚本/库作者::

  • 可选调用define()如果该函数可用的话。好处是你可以继续编写你的库而不依赖AMD,仅在AMD存在时参与其中。这使你的模块使用者可以:
    • 避免向页面中注入全局变量
    • 使用更多的代码加载选项、延迟加载
    • 使用现有的AMD工具优化他们的项目
    • 参与到一个目前适用于浏览器中JavaScript的可行模块系统中。

如果你为JavaScript编写代码加载器/引擎/环境:

  • 实现AMD API。这里有一个讨论列表兼容性测试。通过实现AMD,你将减少多模块系统的冗余代码,并帮助验证一个适用于网络上的JavaScript模块系统。这些经验可以反馈给ECMAScript流程,以构建更好的原生模块支持。
  • 同时支持callback-require加载器插件。加载器插件是一种有效的方式,可用于减少在回调/异步风格代码中常见的嵌套回调问题。