为什么要使用 Web 模块?

本页讨论了为什么 Web 上的模块很有用,以及当今 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/path/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() 或其正文文本设置为通过 XHR 加载的脚本的文本的脚本元素。

使用 eval() 来评估模块是不好的

  • 开发人员已经被告知 eval() 是不好的。
  • 某些环境不允许使用 eval()。
  • 更难调试。 Firebug 和 WebKit 的检查器有一个 //@ sourceURL= 约定,这有助于为 evaled 文本命名,但这种支持在浏览器中并不普遍。
  • eval 上下文在不同的浏览器中是不同的。 您也许可以在 IE 中使用 execScript 来解决这个问题,但这意味着更多的活动部件。

使用正文文本设置为文件文本的脚本标签是不好的

  • 调试时,您为错误获取的行号不会映射到原始源文件。

XHR 在跨域请求方面也存在问题。 一些浏览器现在支持跨域 XHR,但这并不普遍,并且 IE 决定为跨域调用创建一个不同的 API 对象 XDomainRequest。 更多的活动部件和更多出错的地方。 特别是,您需要确保不发送任何非标准 HTTP 标头,否则可能会执行另一个“预检”请求,以确保允许跨域访问。

Dojo 使用了基于 XHR 的加载器和 eval(),虽然它有效,但它一直是开发人员沮丧的根源。 Dojo 有一个 xdomain 加载器,但它需要通过构建步骤修改模块以使用函数包装器,以便可以使用脚本 src="" 标签来加载模块。 有很多边缘情况和活动部件会给开发人员带来负担。

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

脚本加载:Web Workers§ 6

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

  • 它没有强大的跨浏览器支持
  • 它是一个消息传递 API,脚本可能想与 DOM 交互,因此这意味着只使用工作线程来获取脚本文本,但将文本传递回主窗口,然后使用 eval/script 和文本正文来执行脚本。 这具有上面提到的 XHR 的所有问题。

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

document.write() 可用于加载脚本——它可以从其他域加载脚本,并且它映射到浏览器通常如何使用脚本,因此它允许轻松调试。

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

此外,document.write() 在页面加载后不起作用。 获得网站感知性能的一个好方法是按需加载代码,因为用户在下一步操作中需要它。

最后,通过 document.write() 加载的脚本会阻塞页面渲染。 当希望为您的网站获得最佳性能时,这是不可取的。

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

我们可以按需创建脚本并将它们添加到头部

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 语法不同,因为它需要在浏览器中良好地工作。 有人建议,如果服务器进程将模块转换为具有函数包装器的传输格式,则可以使用普通的 CommonJS 语法和 head.appendChild(script) 类型的加载。

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

  • 它使调试变得很奇怪,行号与源文件不符,因为服务器正在注入函数包装器。
  • 它需要更多的装备。 前端开发应该可以使用静态文件。

有关此函数包装格式(称为异步模块定义 (AMD))的设计力量和用例的更多详细信息,请参阅 为什么使用 AMD? 页面。