为什么需要Web模块?

本页面讨论了网络上模块为何有用,以及目前在网络上可以使用的启用机制。还有一个单独的页面讲述特定函数包装格式的设计动因RequireJS所使用的格式。

问题 § 1

  • 网站正在变成Web应用
  • 随着网站规模的增长,代码复杂度也在提高
  • 组装过程变得更加困难
  • 开发者希望使用独立的JS文件/模块
  • 部署时希望只需一个或几个HTTP请求就能加载优化后的代码

解决方案§ 2

前端开发者需要一种解决方案具备:

  • 某种类似#include/import/require的方式
  • 加载嵌套依赖的能力
  • 对开发者易用,但同时背后有优化工具支持部署需求

脚本加载API§ 3

第一步要确定的是脚本加载API。以下是一些候选方案:

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

所有这些方式都对应到加载某个路径下的some/module.js文件。理想情况下我们可以选择CommonJS语法,因为它随时间推移可能会越来越普及,并且我们希望建立可重用的代码库。

我们也希望有一种语法能够加载现有的普通JavaScript文件 —— 开发者不应该为了获得脚本加载的好处而必须重写其所有JavaScript代码。

然而,我们需要一种在浏览器中能良好运行的方案。CommonJS中的require()是同步调用,它被期望立即返回模块。这在浏览器环境中效果不佳。

异步与同步§ 4

这个例子应该说明了浏览器面对的基本问题。假设我们有一个Employee对象,并希望Manager对象继承自Employee对象。以这个例子为例,我们可能用如下代码使用脚本加载API实现:

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

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

//Error if require call is async
Manager.prototype = new Employee();

正如上述注释所示,如果require()是异步调用,则这段代码将无法正常工作。但是,在浏览器中同步加载脚本会严重影响性能。那么,该如何做?

脚本加载:XHR§ 5

使用XMLHttpRequest(XHR)来加载脚本颇具吸引力。如果使用XHR,则我们可以处理上面文本——可以用正则表达式查找require()调用,确保先加载这些脚本,然后通过eval()或者设置body文本为通过XHR加载的脚本内容的script元素执行它们。

使用eval()来执行模块存在以下几个问题:

  • 开发者一直认为eval()是不安全的做法。
  • 一些环境不允许使用eval()。
  • 调试较为困难。Firebug和WebKit的检查器有一个//@ sourceURL=约定,可用于给eval出的文本命名,但这种支持并未在所有浏览器中通用。
  • eval上下文在各浏览器中有所不同。你也许可以在IE中使用execScript来解决这个问题,但这意味着更多组件。

使用script标签并设置body文本为文件内容同样存在问题:

  • 调试过程中错误显示的行号不能映射回原始源文件。

XHR还存在跨域请求的问题。现在一些浏览器已经有了跨域XHR支持,但并未普遍支持,而且IE决定为跨域调用创建一个不同的API对象XDomainRequest。这样又带来了更多组件和出错的机会。特别是,你必须确保不发送任何非标准的HTTP头,否则可能触发另一个“预检”请求,以确认是否允许跨域访问。

Dojo曾经使用基于XHR的加载器配合eval(),虽然可行,但它一直是开发者的困扰来源。Dojo也提供了xdomain加载器,但它要求通过构建步骤修改模块以使用函数包装,从而可以使用script src=""标签加载模块。这包含了许多边缘情况和多个组件,对开发者形成了额外负担。

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

脚本加载:Web Workers§ 6

Web Workers可能是加载脚本的另一种方式,但:

  • 它没有强大的跨浏览器支持
  • 它是一个消息传递API,而脚本很可能需要与DOM交互,这意味着只能使用worker获取脚本文本,再将文本传回主窗口,然后使用eval/script配合文本主体执行脚本。这与上面提到的XHR方法一样存在问题。

脚本加载:document.write()§ 7

document.write()可以用来加载脚本——它可以加载来自其他域的脚本,并且映射到浏览器通常消费脚本的方式,因此便于调试。

然而,在异步与同步示例中,我们不能直接执行该脚本。理想情况下,我们能够在执行脚本前知道require()的依赖项,并确保先加载这些依赖项。但我们无法在脚本执行前访问它。

另外,document.write()在页面加载之后不起作用。在用户需要下一步操作时按需加载代码,这是提升网站感知性能的一个好办法。

最后,通过document.write()加载的脚本会阻塞页面渲染。当追求网站的最佳性能表现时,这种情况是不理想的。

脚本加载:head.appendChild(script)§ 8

我们可以按需创建脚本并将其添加到 head 中:

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

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

实际涉及的内容比上面的代码片段略多一些,但这就是基本思路。这种方法相较于 document.write 的优势在于它不会阻塞页面渲染,并且在页面加载完成后仍然有效。

然而,这种方法仍然存在一个问题:异步与同步示例理想情况下,我们希望在执行脚本之前就能知道 require() 所依赖的模块,并确保这些依赖项已被先加载。

函数包装§ 9

因此我们需要了解这些依赖项,并确保在执行脚本之前加载它们。实现这一点的最佳方式是通过函数包装来构建我们的模块加载 API。如下所示:

define(
    //The name of this module
    "types/Manager",

    //The array of dependencies
    ["types/Employee"],

    //The function to execute when all dependencies have loaded. The
    //arguments to this function are the array of dependencies mentioned
    //above.
    function (Employee) {
        function Manager () {
            this.reports = [];
        }

        //This will now work
        Manager.prototype = new Employee();

        //return the Manager constructor function so it can be used by
        //other modules.
        return Manager;
    }
);

这就是 RequireJS 所使用的语法。如果你只想加载一些没有定义模块的普通 JavaScript 文件,也有一种简化语法:

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

之所以选择这种类型的语法,是因为它简洁明了,并允许加载器使用类似 head.appendChild(script) 的加载方式。

它与普通的 CommonJS 语法不同,这是为了在浏览器中良好运行所必需的。有人建议,如果服务器进程将模块转换为带有函数包装的传输格式,则可以在使用 head.appendChild(script) 类型加载时也使用普通的 CommonJS 语法。

我认为强制使用运行时服务器进程来转换代码是不合理的:

  • 这会使调试变得奇怪,由于服务器注入了函数包装,导致代码行号与源文件不符。
  • 它需要更多工具支持。前端开发应该可以通过静态文件完成。

更多关于这种称为异步模块定义(Asynchronous Module Definition,简称 AMD)的函数包装格式的设计动机和用例细节,可以在以下页面中找到:为何选择 AMD?页面。