RequireJS 其一

为何用 Web 模块的方式?

前一阵围观 RequireJS 的文档,做得很好看,发现两篇讲设计思想的文章, 很符合我这《Why SeaJS》的开篇立意,所以不妨拿来翻译一下,拓宽点思路。

以下译文

本文讨论为何网站模块化很有用,以及模块化的各种实现方式的可行性。同时,还有个 独立页面 讨论 RequireJS 采用的函数封装的设计驱使。

问题

  • 网站正在变成网络应用
  • 代码复杂度随着网站变大而增加
  • 代码组织变难了
  • 开发者希望 JS 文件模块化
  • 部署时又希望将代码优化进一到数次 HTTP 请求

解决办法

前端开发者需要一个满足以下条件的解决方案:

  • 类似 #includeimportrequire (译注:分别对应 C、Python、node.js)
  • 能够加载嵌套的依赖
  • 对开发者友好并且能提供帮助部署的优化工具

脚本加载的 API

首要任务是厘清加载脚本的 API。有以下备选方案:

  • Dojo:dojo.require('some.module')
  • LABjs:$LAB.script('some/module.js')
  • CommonJS: require('some/module')

以上所有都将映射模块到 some/path/some/module.js。因为 CommonJS 的语法用者越来越广, 而我们希望代码复用,所以理想地,我们选择它。

我们还想要某种语法,使得当前的 JavaScript 文件不经修改即可被加载 —— 开发者不需要因为想用脚本加载的方式而重写所有的 JavaScript 文件。

但是,我们还需要这语法能够在浏览器中执行良好。CommonJS 里的 require() 是个同步的调用,意味着它得立即返回模块。 在浏览器里头,这可不太好使。

异步对同步

下例说明了浏览器(实现模块化)的基本问题。假设我们有个 Employee (职工)对象, 我们想要 Manager(经理)继承自 Employee 对象。 以此为例 ,用我们的脚本加载 API,我们可能会把它写成这样:

var Employee = require("types/Employee");

function Manager () {
    this.reports = [];
}

// 如果 require 调用是异步的,这里就出错了
Manager.prototype = new Employee();

如上边注释所示,如果 require() 是异步的,这段代码就跑不起来。然而,在浏览器里同步加载脚本又严重影响性能。 那该怎么办呢?

脚本加载:XHR

XMLHttpRequest(XHR)加载脚本看起来很靠谱。如果用了 XHR,我们就可以调戏上例中的代码 —— 我们可以用正则表达式找出所有的 require() 调用,确保加载了这些脚本之后,再使用 eval() 或者动态创建 script 节点将 XHR 获取的代码塞进去来执行上例代码。

eval() 来执行模块代码很糟糕:

  • 开发者们已经被教育了 eval() 很糟糕
  • 有些环境不允许 eval()
  • 调试很困难。WebKit 的查看器,和 Firebug,都有个 //@ sourceURL=convention 来帮你给执行的文本命名, 但是这个特性并不是所有浏览器都有的
  • 不同浏览器中,eval() 的上下文也是不同的。你可以在 IE 里改用 execScript,但这意味着更多的不确定部分。

script 节点插入模块代码来执行模块也不好:

  • 调试的时候,错误消息中的行号并不是指向到实际文件的

XHR 还有个问题,它不支持跨域的请求。有些浏览器支持跨域 XHR 请求,但这个特性并不是所有浏览器都支持的, 而 IE 又创造了一个不同的专门用来处理跨域请求的 API 对象,叫做 XDomainRequest。 越多的不确定部分意味着事情越容易变糟。特别是,你要确保不发送任何非标准的 HTTP 头信息,不然会有个预请求,以确保是可以跨域访问的。

Dojo 用的是基于 XHR 与 eval() 的加载器。尽管它管用,但成了开发者的沮丧之源。Dojo 有个跨域的加载器, 但是它需要模块在发布阶段改成函数包装的形式,从而可以用 script src="" 来加载模块。 此外,还有不少边界情况和不确定部分给开发者增加了额外的负担。

如果我们要创造一个新的脚本加载器,我们可以做得更好。

脚本加载:Web 工作线程

Web 工作线程或许可以作为加载脚本的另一种方式,不过:

  • 它没有很好的跨浏览器支持
  • 它是个消息传递的 API,而脚本基本上都要与 DOM 交互的,所以这意味着工作线程直通用来获取脚本内容, 传送文本到主 window 然后使用 evalscript 来执行脚本。于是 XHR 会有的问题,它也都会有。

脚本加载:document.write()

document.write() 也可以用来加载脚本 —— 它可以从其他域名加载脚本,并且它的行为与浏览器正常加载脚本一致, 所以它是易于调试的。

但是,在异步对同步的例子中,我们的例子是不能直接执行的。理想情况是,我们在执行脚本之前就知道 require() 的依赖, 并保证这些依赖先被加载。但在此法中,我们不能在脚本被执行之前获取它的内容。

并且,document.write() 在页面加载完毕之后不管用。而取得大幅性能提升的好办法之一就是按需加载, 因为用户在下一步操作时才需要它(译注:即在可能页面加载完毕之后才加载,因此 document.write() 不好使)。

最后,通过 document.write() 加载脚本会阻滞页面渲染。当你专注于网站性能极限的时候,这种方法是不可容忍的。

脚本加载:head.appendChild(script)

我们可以按需创建节点,并插入到 head 中:

var head = document.getElementsByTagName('head')[0],
    script = document.createElement('script');

script.src = url;
head.appendChild(script);

这段小代码还是不够的,但它已经说明了基本的想法。这个方式比 document.write 的优势在于它不会阻碍页面渲染, 同时在页面加载完毕之后也是可用的。

但是,它仍然有异步对同步里提到的问题:理想情况是,我们可以在执行脚本之前知道 require() 依赖,并确保它们先被加载好。

函数封装

所以我们需要知道依赖,并且保证在执行脚本之前先加载它们。最好的办法就是将我们的模块加载 API 用匿名函数封装起来。 像这样:

define(
    // 模块名称
    "types/Manager",

    // 依赖数组
    ["types/Employee"],

    // 模块依赖加载完毕之后再执行的函数。
    // 这个函数的参数是依赖数组。
    function (Employee) {
        function Manager () {
            this.reports = [];
        }

        Manager.prototype = new Employee();

        // 返回 Manager 构造器,使其能为人所用
        return Manager;
    }
);

这就是 RequireJS 所采用的语法。还有一个简化的语法,方便你用来加载未使用 define 包装的 JavaScript 文件:

require(["some/script.js"], function() {
    //This function is called after some/script.js has loaded.
});

选择这种语法的原因是它简洁,同时又允许加载器使用 head.appendChild(script) 这种加载方式。

为了能够在浏览器里运行良好,它与 CommonJS 的语法的不同是必须的。坊间也有建议说常规的 CommonJS 语法也可以用来以 head.appendChild(script) 方式加载,只要服务端能够自动将模块代码以函数封装的形式包装起来。

我相信有一点很重要,不能强制用户使用后端服务来改变代码:

  • 调试的时候会很怪,因为服务端插入了函数封装,行号会和源文件有偏差。
  • 增加了技术负担。前端开发应该只需静态文件就好。

更多关于函数封装格式的设计驱使与用例的细节,称作异步模块定义(Asynchronous Module Definition,AMD), 可以在《为何 AMD?》页面找到。

RequireJS 其二

为何用 AMD 规范?

本文讨论异步模块规范(Asynchronous Module Definition,AMD)API,即 RequireJS 所支持的模块 API, 的设计驱使与使用方式。另有页面讨论 Web 模块化的基本方式

模块目的

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

  • 定义:如何把一段代码封装成一个有用的单元,以及如何注册此模块的能力、输出的值
  • 依赖引用:如何引用其它代码单元

现今 Web

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

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

现如今 JavaScript 代码段是如何定义的呢?

  • 通过立即执行的工厂函数定义。
  • 使用 HTML script 标签加载模块,通过全局变量来引用依赖。
  • 模块间依赖的声明很弱:开发者需要知道正确的依赖顺序。例如,包含 Backbone 的文件,不能放在 jQuery 标签之前。
  • 优化部署的时候,需要用额外的工具来把一系列 script 标签替换成一个。

这些都会使大型项目变得难以管理,尤其是当脚本们的诸多依赖还可能重叠、嵌套的时候。 手工写 script 标签可不怎么灵活,而且这么做就没法搞按需加载了。

CommonJS

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

最初的 CommonJS 小组 的参与者们决定弄一份于时下的 JavaScript 编程语言有效,但不必束缚于浏览器 JS 环境的限制,的模块规范。开始的愿景是在浏览器里使用一些权宜之计, 并希望能借此影响浏览器厂商,促使它们为这种模块规范的原生支持提供解决方案。权宜之计有:

  • 要么使用一个服务来转译 CJS 模块成浏览器中可用的代码
  • 要么使用 XMLHttpRequest(XHR)以文本形式加载模块,再在浏览器中做文本变换、解析的工作

CJS 模块规范仅允许每文件一个模块,所以为优化、打包,可使用某种“转换格式”将多个模块合并到单个文件。

通过这种方式,CommonJS 小组搞定了依赖引用、如何处理循环依赖,以及如何获得当前模块的某些属性等问题。 但是,他们并没能接纳浏览器环境里不可改变、并且仍将影响模块设计的某些特性:

  • 网络加载
  • 异步继承

这也同时意味着他们为了实现这个规范,将负担更多地放到了 Web 开发者身上,而这些权宜之计也使调试变得更麻烦。 调试 eval 的代码,或者调试多个文件合并之后的单个文件,都有实际使用时的坏处。 这些缺点或许在未来某天会被浏览器调试工具解决掉,但结论仍然是:在最普遍的 JS 环境,浏览器中,使用 CommonJS 模块并不是最好的办法

AMD

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

AMD 规范的缘起是,我们需要一个比时下那种“写一堆 script 标签、手工按序指明依赖”要好,并且容易在浏览器中直接使用的模块格式。 某种不需要服务端工具配合,又能够易于调试的模块格式。它超脱于 Dojo 使用 XHR+eval 的现实经验, 并且要规避 Dojo 的方式的缺点。

它比时下 Web 开发中“全局变量+script 标签”的方式要好,因为:

  • 应用 CommonJS 实践,使用字符串 ID 来声明依赖。使得依赖声明清晰,并且避免了使用全局变量。
  • 模块 ID 可以被映射到不同的路径。从而允许切换模块实现。这对创建单元测试模型很有帮助。 在上例中,代码实际期待的不过是某个实现了 jQuery API 与行为的模块而已。并不是非要 jQuery 本身不可。
  • 封装了模块定义。使你能够避免污染全局命名空间。
  • 清楚地定义模块输出。可以用 return value;,也可以用 CommonJS 的 exports 范式。 后者在循环依赖的时候很有用。

它是 CommonJS 模块规范的改善,因为:

  • 它在浏览器里跑得更好,它的坑是最少的。其他的解决方案,都有调试、跨域、CDN 使用,file:// 协议以及依赖服务端工具等问题。
  • 定义了将多个模块包含进单个文件的方式。在 CommonJS 的条款里,有个“传输规范”来做这个事情, 但 CommonJS 小组还没就此达成一致
  • 允许将函数作为返回值。这在模块返回值是构造函数的时候尤其有用。在 CommonJS 中就有点尴尬了, 永远要通过给 exports 对象设置属性来输出。Node 支持了 module.exports = function() {}, 不过这还没在 CommonJS 规范里面。

模块定义

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

命名的模块

请注意,上例中的模块并没有给自己取名。这使得这个模块可以随便移动。它允许开发者将模块放到不同的目录, 从而管它们叫不同的名字。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 () {};
});

你应该避免自行命名模块,并且在开发时保持每文件一个模块。只不过,在注重性能的阶段, 得有个模块规范以在编译后的资源中命名模块。

语法糖

上面的 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 兼容性

虽然这个语法糖形式被称作“简化 CommonJS 封装”,但它并非与 CommonJS 模块 100% 兼容。不过,CommonJS 模块一般认为依赖是同步加载的,完全兼容它的话,在浏览器里就会挂掉啦。

绝大部分 CJS(CommonJS)模块,根据个人经验(不太科学地毛估估)大约有 95%,是与简化 CommonJS 封装完美兼容的。

不兼容的模块,都是那些依赖是动态计算的,调用 require() 的时候不用字符串字面量的,以及其他不像个声明方式调用 require() 的。 所以,下边这种会挂:

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

// 不好
// 译注:这种应该不会挂,但是不能达到选择加载的效果,会在请求、加载完 a 和 a1 之后,
// 才执行模块代码
if (someCondition) {
    var a = require('a');
} else {
    var a = require('a1');
}

这些使用方式,在 require 回调 中支持, 即 AMD 加载器中提供的全局的

require([moduleName], function() {});

AMD 的执行模式比较向 ECMAScript 和谐版(Harmoney)的模块规范所约定的看齐。 CommonJS 模块规范在 AMD 中的不兼容部分在和谐版(Harmoney)模块也一样不兼容。 AMD 的代码执行逻辑的未来兼容性更好。

冗长与可用性

对 AMD 的批评其一,至少与 CJS 模块规范相比,是它要求一层缩进,以及一个函数封装。

但事实很简单:要用 AMD 就觉得需要多打点字并多缩进一层,其实这一点都没关系。你编程的时间是这么花的:

  • 思考问题
  • 阅读代码

绝大部分编程的时间是花在思考而非敲代码上的。虽然代码一般越短越好,但所能付出的代价终归是有限的, 何况用 AMD 要再打些的字其实也没那么多。

而且大部分 Web 开发者本来就在用匿名函数封装了,目的是避免污染全局变量。看到逻辑代码外边包了匿名函数,其实是很普遍的, 并不会给阅读模块代码带来困扰。

同时,CommonJS 格式还有些潜在代价:

  • 依赖工具的代价
  • 某些边界用例在浏览器中会挂,比如跨域访问
  • 调试更差,此代价随着时间增加,会越来越大

AMD 模块规范需要的工具更少,边界用例问题更少,调试支持也更好。

重要的是:能够真正与其他人分享代码。AMD 规范是达此目标更轻松的方式。

拥有一个可用的,易于调试,并且还在现如今的浏览器中能跑的模块系统,意味着在创造未来的 JavaScript 中最好的模块系统的同时,得到现实世界的体验。

AMD 和它相关的 API 们,已经帮助展示了任何未来 JS 的模块系统都具备的以下特性:

  • 返回一个函数作为模块值,尤其是构造函数,促使更好的 API 设计。Node 有 module.exports 来支持此特性, 但能够用 return function() {} 的话会更简洁。这意味着不需要持有 modulemodule.exports, 并且代码表达也更清晰。
  • 动态代码加载(AMD 模块系统中通过 require[],function(){}) 来实现) 是基本要求。CJS 小组谈到了这个问题,提了一些议案,但它并没有广为接受。Node 并不支持这个需求,转而依赖 require() 的同步行为, 而这在 Web 端是无从实现的。
  • 加载器插件 相当有用。它帮助避免基于回调的编程中常遇到的嵌套缩进的问题。
  • 选择映射某个模块 从另一个地址加载,使得提供模块模拟对象以供测试变得容易。
  • 每个模块最多一个 IO 行为,并且这个 IO 行为应该简单直接。Web 浏览器受不了找个模块还有多次 IO 查找。 这和 Node 里现在的多次寻址是相悖的,并且避免使用一个有 main 属性的 package.json。 用能够很容易地根据项目目录结构映射到某个地址的模块名,一个合理的不需要冗长的配置的约定就够了,不过要允许必要的简单配置。
  • 最好有个“选择加入”调用,用来使旧 JS 代码快速参与新的模块系统之中。

如果一个 JS 模块系统不能搞定以上特性,那么与 AMD 和它相关的 API 们,例如 callback-require加载器插件 和基于路径的模块 ID,相较之下,高下立见。

AMD 使用情况

截至 2011年10月,AMD 已经在 Web 上广为使用:

你可以做什么

如果你是写应用的:

如果你是脚本库开发者:

  • 如果可用,条件调用 define()。妙处在于不依靠 AMD 你仍然可以编写你的库,只要可用的时候参与一下就可以了。 这使得你的模块用户可以:
    • 避免往页面全局变量里头塞东西
    • 代码加载、延迟加载等更加有选择
    • 用现有的 AMD 工具来优化它们的项目
    • 参与到当今浏览器中可用的 JS 模块系统中去

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

  • 实现 AMD API。有个 讨论列表兼容性测试。通过实现 AMD 模块规范,你可以减少多模块系统的无谓竞争, 帮助为一个 Web 端可用的 JavaScript 模块系统证明。这也能反馈到 ECMAScript 工作组,以求更好的本地模块系统支持。
  • 也要支持 callback-require加载器插件。 加载器插件是个很好的减轻回调、异步风格的代码中常见的嵌套回调症的方式。
comments powered by Disqus