# 模块化开发

# 模块简介

现代 JavaScript 开发毋庸置疑会遇到代码量大和广泛使用第三方库的问题。解决这个问题的方案通常需要把代码拆分成很多部分,然后再通过某种方式将它们连接起来。

很长一段时间,JavaScript 都没有语言级(language-level)的模块语法。这不是一个问题,因为最初的脚本又小又简单,所以没必要将其模块化。

但是最终脚本变得越来越复杂,因此社区发明了许多种方法来将代码组织到模块中,使用特殊的库按需加载模块。

列举一些(出于历史原因):

  • AMD -- 最古老的模块系统之一,最初由 require.js 库实现
  • CommonJS -- Node.js 创建的模块系统
  • UMD -- 另外一个模块系统,作为通用的模块系统,可以兼容 AMD 和 CommonJS

现在,它们都在慢慢成为历史的一部分,但我们仍然可以在旧脚本中找到它们。

语言级的模块系统在 2015 年的时候出现在了标准(ES6)中,此后逐渐发展,现在已经得到了所有主流浏览器和 Node.js 的支持。

题外话

CMD -- 试图对标AMD的模块系统,来源是国内大厂造的一个叫sea.js的轮子。早已消失在历史的滚滚洪流之中。

# 什么是模块?理解模块模式

TIP

一个模块(module)就是一个文件。一个脚本就是一个模块。就这么简单。

将代码拆分成独立的块,然后再把这些块连接起来可以通过模块模式来实现。
这种模式背后的思想很简单:把逻辑分块,各自封装,相互独立,每个块自行决定对外暴露什么,同时自行决定引入执行哪些外部代码。

# 模块标识符

模块标识符是所有模块系统通用的概念。模块系统本质上是键/值实体,其中每个模块都有个可用于引用它的标识符。这个标识符在模拟模块的系统中可能是字符串,在原生实现的模块系统中可能是模块文件的实际路径。

有的模块系统支持明确声明模块的标识,还有的模块系统会隐式地使用文件名作为模块标识符。不管怎样,完善的模块系统一定不会存在模块标识冲突的问题,且系统中的任何模块都应该能够无歧义地引用其他模块。

将模块标识符解析为实际模块的过程要根据模块系统对标识符的实现。原生浏览器模块标识符必须提供实际 JavaScript 文件的路径。除了文件路径, Node.js 还会搜索 node_modules 目录,用标识符去匹配包含 index.js 的目录。

# 模块依赖

模块系统的核心是管理依赖。指定依赖的模块与周围的环境会达成一种契约。本地模块向模块系统声明一组外部模块(依赖),这些外部模块对于当前模块正常运行是必需的。模块系统检视这些依赖,进而保证这些外部模块能够被加载并在本地模块运行时初始化所有依赖。

# 模块加载

在浏览器中,加载模块涉及几个步骤。加载模块涉及执行其中的代码,但必须是在所有依赖都加载并执行之后。如果浏览器没有收到依赖模块的代码,则必须发送请求并等待网络返回。

收到模块代码之后,浏览器必须确定刚收到的模块是否也有依赖。然后递归地评估并加载所有依赖,直到所有依赖模块都加载完成。只有整个依赖图都加载完成,才可以执行入口模块。

# 模块入口

相互依赖的模块必须指定一个模块作为入口( entry point),这也是代码执行的起点。这是理所当然的,因为 JavaScript 是顺序执行的,并且是单线程的,所以代码必须有执行的起点。入口模块也可能依赖其他模块,其他模块同样可能有自己的依赖。于是模块化 JavaScript 应用程序的所有模块会构成依赖图。

可以通过有向图来表示应用程序中各模块的依赖关系。

图中的箭头表示依赖方向:模块 A 依赖模块 B 和模块 C,模块 B 依赖模块 D 和模块 E,模块 C 依赖模块 E。因为模块必须在依赖加载完成后才能被加载,所以这个应用程序的入口模块 A 必须在应用程序的其他部分加载后才能执行。

由于模块加载是“阻塞的”,这意味着前置操作必须完成才能执行后续操作。每个模块在自己的代码到达浏览器之后完成加载,此时其依赖必需已经加载并初始化。因此要上满足上面图中的依赖关系,我们可能需要这样。

<script src="moduleE.js"></script>
<script src="moduleD.js"></script>
<script src="moduleC.js"></script>
<script src="moduleB.js"></script>
<script src="moduleA.js"></script>

TIP

为一个应用程序而按顺序加载五个 JavaScript 文件并不理想,并且手动管理正确的加载顺序也颇为棘手。可是人生是苦短的,我们自然不能手动去处理模块的加载,因此将这个过程自动化也就成为了必然。

# 异步依赖

因为 JavaScript 可以异步执行,所以如果能按需加载就好了。换句话说,可以让 JavaScript 通知模块系统在必要时加载新模块,并在模块加载完成后提供回调。比如下面这段伪代码。

load("moduleB").then((moduleB) => {
  moduleB.doSomething();
});

模块 A 的代码使用了 moduleB 标识符向模块系统请求加载模块 B,并以模块 B 作为参数调用回调。模块 B 可能已加载完成,也可能必须重新请求和初始化,但这里的代码并不关心。这些事情都交给了模块加载器去负责。

# 动态依赖

有些模块系统要求开发者在模块开始列出所有依赖,而有些模块系统则允许开发者在程序结构中动态添加依赖。动态添加的依赖有别于模块开头列出的常规依赖,这些依赖必须在模块执行前加载完毕。 下面是动态依赖加载的例子:

if (loadCondition) {
  require("./moduleA");
}

在这个模块中,是否加载 moduleA 是运行时确定的。加载 moduleA 时可能是阻塞的,也可能导致执行,且只有模块加载后才会继续。

# 静态分析

模块中包含的发送到浏览器的 JavaScript 代码经常会被静态分析,分析工具会检查代码结构并在不实际执行代码的情况下推断其行为。对静态分析友好的模块系统可以让模块打包系统更容易将代码处理为较少的文件。它还将支持在智能编辑器里智能自动完成。比如TypeScript

# 循环依赖

要构建一个没有循环依赖的 JavaScript 应用程序几乎是不可能的,因此包括 CommonJS、 AMD 和 ES6 在内的所有模块系统都支持循环依赖。在包含循环依赖的应用程序中,模块加载顺序可能会出人意料。不过,只要恰当地封装模块,使它们没有副作用,加载顺序就应该不会影响应用程序的运行。

# 凑合的模块系统 IIFE

为按照模块模式提供必要的封装, ES6 之前的模块有时候会使用函数作用域和立即调用函数表达式( IIFE, Immediately Invoked Function Expression)将模块定义封装在匿名闭包中。模块定义是立即执行的,如下:

(function () {
  // 私有 Foo 模块的代码
  console.log("bar");
})();

如果把这个模块的返回值赋给一个变量,那么实际上就为模块创建了命名空间

var Foo = (function () {
  console.log("bar");
})();

为了让模块正确使用外部的值,可以将它们作为参数传给 IIFE:

var globalBar = "baz";
var Foo = (function (bar) {
  return {
    bar: bar,
    baz: function () {
      console.log(bar);
    }
  };
})(globalBar);
console.log(Foo.bar); // 'baz'
Foo.baz(); // 'baz'

# ES6 之前的模块

在 ES6 原生支持模块之前,使用模块的 JavaScript 代码本质上是希望使用默认没有的语言特性。因此,必须按照符合某种规范的模块语法来编写代码,另外还需要单独的模块工具把这些模块语法与 JavaScript 运行时连接起来。这里的模块语法和连接方式有不同的表现形式,通常需要在浏览器中额外加载库或者在构建时完成预处理。

# CommonJS

CommonJS 规范概述了同步声明依赖的模块定义。这个规范主要用于在服务器端实现模块化代码组织,但也可用于定义在浏览器中使用的模块依赖。 CommonJS 模块语法不能在浏览器中直接运行。

CommonJS 模块定义需要使用require()指定依赖,而使用 module.exports 对象定义自己的公共 API。

var moduleB = require('./moduleB');
module.exports = {
  stuff: moduleB.doStuff();
};

module.exports 对象非常灵活,有多种使用方式。如果只想导出一个实体,可以直接给 module.exports 赋值:

// moduleB
module.exports = "foo";

这样,整个模块就导出一个字符串,可以像下面这样使用:

var moduleA = require("./moduleB");
console.log(moduleB); // 'foo'

如果想在浏览器中使用 CommonJS 模块,就需要与其非原生的模块语法之间构筑“桥梁”。模块级代码与浏览器运行时之间也需要某种“屏障”,因为没有封装的 CommonJS 代码在浏览器中执行会创建全局变量。这显然与模块模式的初衷相悖。

常见的解决方案是提前把模块文件打包好,把全局属性转换为原生 JavaScript 结构,将模块代码封装在函数闭包中,最终只提供一个文件。为了以正确的顺序打包模块,需要事先生成全面的依赖图。

# 异步模块定义 AMD

CommonJS 以服务器端为目标环境,能够一次性把所有模块都加载到内存,而异步模块定义( AMD,Asynchronous Module Definition)的模块定义系统则以浏览器为目标执行环境,这需要考虑网络延迟的问题。 AMD 的一般策略是让模块声明自己的依赖,而运行在浏览器中的模块系统会按需获取依赖,并在依赖加载完成后立即执行依赖它们的模块。

AMD 模块实现的核心是用函数包装模块定义。这样可以防止声明全局变量,并允许加载器库控制何时加载模块。包装函数也便于模块代码的移植,因为包装函数内部的所有模块代码使用的都是原生 JavaScript 结构。包装模块的函数是全局 define 的参数,它是由 AMD 加载器库的实现定义的。

// 定义一个模块A,它依赖于模块B
define('moduleA', ['moduleB'], function(moduleB) {
  return {
    stuff: moduleB.doStuff();
  };
});

AMD 也支持 require 和 exports 对象,通过它们可以在 AMD 模块工厂函数内部定义 CommonJS 风格的模块。这样可以像请求模块一样请求它们,但 AMD 加载器会将它们识别为原生 AMD 结构。

define("moduleA",["require","export"],function(require,exports){
  var moduleB = require("moduleB"); // 会被转化为AMD
  export.stuff = moduleB.doSomething()
})

# 通用模块定义 UMD

为了统一 CommonJS 和 AMD 生态系统,通用模块定义( UMD, Universal Module Definition)规范应运而生。 UMD 可用于创建这两个系统都可以使用的模块代码。本质上, UMD 定义的模块会在启动时检测要使用哪个模块系统,然后进行适当配置,并把所有逻辑包装在一个立即调用的函数表达式( IIFE)中。虽然这种组合并不完美,但在很多场景下足以实现两个生态的共存。

下面是只包含一个依赖的 UMD 模块定义的示例,我们不期望手写这个包装函数,它应该由构建工具自动生成。开发者只需专注于模块的内由容,而不必关心这些样板代码。

(function (root, factory) {
  if (typeof define === "function" && define.amd) {
    // AMD。注册为匿名模块
    define(["moduleB"], factory);
  } else if (typeof module === "object" && module.exports) {
    // Node。不支持严格 CommonJS
    // 但可以在 Node 这样支持 module.exports 的
    // 类 CommonJS 环境下使用
    module.exports = factory(require(" moduleB "));
  } else {
    // 浏览器全局上下文( root 是 window)
    root.returnExports = factory(root.moduleB);
  }
})(this, function (moduleB) {
  // 以某种方式使用 moduleB
  // 将返回值作为模块的导出
  // 这个例子返回了一个对象
  // 但是模块也可以返回函数作为导出值
  return {};
});