# 十、模块

> 原文：[Modules](http://eloquentjavascript.net/10_modules.html)
> 
> 译者：[飞龙](https://github.com/wizardforcel)
> 
> 协议：[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/)
> 
> 自豪地采用[谷歌翻译](https://translate.google.cn/)

> 编写易于删除，而不是易于扩展的代码。
> 
> Tef，《Programming is Terrible》

![](img/10-0.jpg)

理想的程序拥有清晰的结构。 它的工作方式很容易解释，每个部分都起到明确的作用。

典型的真实程序会有机地增长。 新功能随着新需求的出现而增加。 构建和维护结构是额外的工作，只有在下一次有人参与该计划时，才会得到回报。 所以它易于忽视，并让程序的各个部分变得深深地纠缠在一起。

这导致了两个实际问题。 首先，这样的系统难以理解。 如果一切都可以接触到一切其它东西，那么很难单独观察任何给定的片段。 你不得不全面理解整个东西。 其次，如果你想在另一个场景中，使用这种程序的任何功能，比起试图从它的上下文中将它分离出来，重写它可能要容易。

术语“大泥球”通常用于这种大型，无结构的程序。 一切都粘在一起，当你试图挑选出一段代码时，整个东西就会分崩离析，你的手会变脏。

## 模块

模块试图避免这些问题。 模块是一个程序片段，规定了它依赖的其他部分，以及它为其他模块提供的功能（它的接口）。

模块接口与对象接口有许多共同之处，我们在第 6 章中看到。它们向外部世界提供模块的一部分，并使其余部分保持私有。 通过限制模块彼此交互的方式，系统变得更像积木，其中的组件通过明确定义的连接器进行交互，而不像泥浆一样，一切都混在一起。

模块之间的关系称为依赖关系。 当一个模块需要另一个模块的片段时，就说它依赖于这个模块。 当模块中明确规定了这个事实时，它可以用于确定，需要哪些其他模块才能使用给定的模块，并自动加载依赖关系。

为了以这种方式分离模块，每个模块需要它自己的私有作用域。

将你的 JavaScript 代码放入不同的文件，不能满足这些要求。 这些文件仍然共享相同的全局命名空间。 他们可以有意或无意干扰彼此的绑定。 依赖性结构仍不清楚。 我们将在本章后面看到，我们可以做得更好。

合适的模块结构可能难以为程序设计。 在你还在探索这个问题的阶段，尝试不同的事情来看看什么是可行的，你可能不想过多担心它，因为这可能让你分心。 一旦你有一些感觉可靠的东西，现在是后退一步并组织它的好时机。

## 包

从单独的片段中构建一个程序，并实际上能够独立运行这些片段的一个优点是，你可能能够在不同的程序中应用相同的部分。

但如何实现呢？ 假设我想在另一个程序中使用第 9 章中的`parseINI`函数。 如果清楚该函数依赖什么（在这种情况下什么都没有），我可以将所有必要的代码复制到我的新项目中并使用它。 但是，如果我在代码中发现错误，我可能会在当时正在使用的任何程序中将其修复，并忘记在其他程序中修复它。

一旦你开始复制代码，你很快就会发现，自己在浪费时间和精力来到处复制并使他们保持最新。

这就是包的登场时机。包是可分发（复制和安装）的一大块代码。 它可能包含一个或多个模块，并且具有关于它依赖于哪些其他包的信息。 一个包通常还附带说明它做什么的文档，以便那些不编写它的人仍然可以使用它。

在包中发现问题或添加新功能时，会将包更新。 现在依赖它的程序（也可能是包）可以升级到新版本。

以这种方式工作需要基础设施。 我们需要一个地方来存储和查找包，以及一个便利方式来安装和升级它们。 在 JavaScript 世界中，这个基础结构由 [NPM](https://npmjs.org) 提供。

NPM 是两个东西：可下载（和上传）包的在线服务，以及可帮助你安装和管理它们的程序（与 Node.js 捆绑在一起）。

在撰写本文时，NPM 上有超过 50 万个不同的包。 其中很大一部分是垃圾，我应该提一下，但几乎所有有用的公开包都可以在那里找到。 例如，一个 INI 文件解析器，类似于我们在第 9 章中构建的那个，可以在包名称`ini`下找到。

第 20 章将介绍如何使用`npm`命令行程序在局部安装这些包。

使优质的包可供下载是非常有价值的。 这意味着我们通常可以避免重新创建一百人之前写过的程序，并在按下几个键时得到一个可靠，充分测试的实现。

软件的复制很便宜，所以一旦有人编写它，分发给其他人是一个高效的过程。但首先把它写出来是工作量，回应在代码中发现问题的人，或者想要提出新功能的人，是更大的工作量。

默认情况下，你拥有你编写的代码的版权，其他人只有经过你的许可才能使用它。但是因为有些人不错，而且由于发布好的软件可以使你在程序员中出名，所以许多包都会在许可证下发布，明确允许其他人使用它。

NPM 上的大多数代码都以这种方式授权。某些许可证要求你还要在相同许可证下发布基于那个包构建的代码。其他要求不高，只是要求在分发代码时保留许可证。 JavaScript 社区主要使用后一种许可证。使用其他人的包时，请确保你留意了他们的许可证。

## 即兴的模块

2015 年之前，JavaScript 语言没有内置的模块系统。 然而，尽管人们已经用 JavaScript 构建了十多年的大型系统，他们需要模块。

所以他们在语言之上设计了自己的模块系统。 你可以使用 JavaScript 函数创建局部作用域，并使用对象来表示模块接口。

这是一个模块，用于日期名称和数字之间的转换（由`Date`的`getDay`方法返回）。 它的接口由`weekDay.name`和`weekDay.number`组成，它将局部绑定名称隐藏在立即调用的函数表达式的作用域内。

```js
const weekDay = function() {
  const names = ["Sunday", "Monday", "Tuesday", "Wednesday",
                 "Thursday", "Friday", "Saturday"];
  return {
    name(number) { return names[number]; },
    number(name) { return names.indexOf(name); }
  };
}();

console.log(weekDay.name(weekDay.number("Sunday")));
// → Sunday
```

这种风格的模块在一定程度上提供了隔离，但它不声明依赖关系。 相反，它只是将其接口放入全局范围，并希望它的依赖关系（如果有的话）也这样做。 很长时间以来，这是 Web 编程中使用的主要方法，但现在它几乎已经过时。

如果我们想让依赖关系成为代码的一部分，我们必须控制依赖关系的加载。 实现它需要能够将字符串执行为代码。 JavaScript 可以做到这一点。

## 将数据执行为代码

有几种方法可以将数据（代码的字符串）作为当前程序的一部分运行。

最明显的方法是特殊运算符`eval`，它将在当前作用域内执行一个字符串。 这通常是一个坏主意，因为它破坏了作用域通常拥有的一些属性，比如易于预测给定名称所引用的绑定。

```js
const x = 1;
function evalAndReturnX(code) {
  eval(code);
  return x;
}

console.log(evalAndReturnX("var x = 2"));
// → 2
console.log(x);
// → 1
```

将数据解释为代码的不太可怕的方法，是使用`Function`构造器。 它有两个参数：一个包含逗号分隔的参数名称列表的字符串，和一个包含函数体的字符串。 它将代码封装在一个函数值中，以便它获得自己的作用域，并且不会对其他作用域做出奇怪的事情。

```py
let plusOne = Function("n", "return n + 1;");
console.log(plusOne(4));
// → 5
```

这正是我们需要的模块系统。 我们可以将模块的代码包装在一个函数中，并将该函数的作用域用作模块作用域。

## CommonJS

用于连接 JavaScript 模块的最广泛的方法称为 CommonJS 模块。 Node.js 使用它，并且是 NPM 上大多数包使用的系统。

CommonJS 模块的主要概念是称为`require`的函数。 当你使用依赖项的模块名称调用这个函数时，它会确保该模块已加载并返回其接口。

由于加载器将模块代码封装在一个函数中，模块自动得到它们自己的局部作用域。 他们所要做的就是，调用`require`来访问它们的依赖关系，并将它们的接口放在绑定到`exports`的对象中。

此示例模块提供了日期格式化功能。 它使用 NPM的两个包，`ordinal`用于将数字转换为字符串，如`"1st"`和`"2nd"`，以及`date-names`用于获取星期和月份的英文名称。 它导出函数`formatDate`，它接受一个`Date`对象和一个模板字符串。

模板字符串可包含指明格式的代码，如`YYYY`用于全年，`Do`用于每月的序数日。 你可以给它一个像`"MMMM Do YYYY"`这样的字符串,来获得像`"November 22nd 2017"`这样的输出。

```js
const ordinal = require("ordinal");
const {days, months} = require("date-names");

exports.formatDate = function(date, format) {
  return format.replace(/YYYY|M(MMM)?|Do?|dddd/g, tag => {
    if (tag == "YYYY") return date.getFullYear();
    if (tag == "M") return date.getMonth();
    if (tag == "MMMM") return months[date.getMonth()];
    if (tag == "D") return date.getDate();
    if (tag == "Do") return ordinal(date.getDate());
    if (tag == "dddd") return days[date.getDay()];
  });
};
```

`ordinal`的接口是单个函数，而`date-names`导出包含多个东西的对象 - `days`和`months`是名称数组。 为导入的接口创建绑定时，解构是非常方便的。

该模块将其接口函数添加到`exports`，以便依赖它的模块可以访问它。 我们可以像这样使用模块：

```js
const {formatDate} = require("./format-date");

console.log(formatDate(new Date(2017, 9, 13),
                       "dddd the Do"));
// → Friday the 13th
```

我们可以用最简单的形式定义`require`，如下所示：

```js
require.cache = Object.create(null);

function require(name) {
  if (!(name in require.cache)) {
    let code = readFile(name);
    let module = {exports: {}};
    require.cache[name] = module;
    let wrapper = Function("require, exports, module", code);
    wrapper(require, module.exports, module);
  }
  return require.cache[name].exports;
}
```

在这段代码中，`readFile`是一个构造函数，它读取一个文件并将其内容作为字符串返回。标准的 JavaScript 没有提供这样的功能，但是不同的 JavaScript 环境（如浏览器和 Node.js）提供了自己的访问文件的方式。这个例子只是假设`readFile`存在。

为了避免多次加载相同的模块，`require`需要保存（缓存）已经加载的模块。被调用时，它首先检查所请求的模块是否已加载，如果没有，则加载它。这涉及到读取模块的代码，将其包装在一个函数中，然后调用它。

我们之前看到的`ordinal`包的接口不是一个对象，而是一个函数。 CommonJS 模块的特点是，尽管模块系统会为你创建一个空的接口对象（绑定到`exports`），但你可以通过覆盖`module.exports`来替换它。许多模块都这么做，以便导出单个值而不是接口对象。

通过将`require`，`exports`和`module`定义为生成的包装函数的参数（并在调用它时传递适当的值），加载器确保这些绑定在模块的作用域中可用。

提供给`require`的字符串翻译为实际的文件名或网址的方式，在不同系统有所不同。 当它以`"./"`或`"../"`开头时，它通常被解释为相对于当前模块的文件名。 所以`"./format-date"`就是在同一个目录中，名为`format-date.js`的文件。

当名称不是相对的时，Node.js 将按照该名称查找已安装的包。 在本章的示例代码中，我们将把这些名称解释为 NPM 包的引用。 我们将在第 20 章详细介绍如何安装和使用 NPM 模块。

现在，我们不用编写自己的 INI 文件解析器，而是使用 NPM 中的某个：

```js
const {parse} = require("ini");

console.log(parse("x = 10\ny = 20"));
// → {x: "10", y: "20"}
```

## ECMAScript 模块

CommonJS 模块很好用，并且与 NPM 一起，使 JavaScript 社区开始大规模共享代码。

但他们仍然是个简单粗暴的黑魔法。 例如，表示法有点笨拙 - 添加到`exports`的内容在局部作用域中不可用。 而且因为`require`是一个正常的函数调用，接受任何类型的参数，而不仅仅是字符串字面值，所以在不运行代码就很难确定模块的依赖关系。

这就是 2015 年的 JavaScript 标准引入了自己的不同模块系统的原因。 它通常被称为 ES 模块，其中 ES 代表 ECMAScript。 依赖和接口的主要概念保持不变，但细节不同。 首先，表示法现在已整合到该语言中。 你不用调用函数来访问依赖关系，而是使用特殊的`import`关键字。

```js
import ordinal from "ordinal";
import {days, months} from "date-names";

export function formatDate(date, format) { /* ... */ }
```

同样，`export`关键字用于导出东西。 它可以出现在函数，类或绑定定义（`let`，`const`或`var`）的前面。

ES 模块的接口不是单个值，而是一组命名绑定。 前面的模块将`formatDate`绑定到一个函数。 从另一个模块导入时，导入绑定而不是值，这意味着导出模块可以随时更改绑定的值，导入它的模块将看到其新值。

当有一个名为`default`的绑定时，它将被视为模块的主要导出值。 如果你在示例中导入了一个类似于`ordinal`的模块，而没有绑定名称周围的大括号，则会获得其默认绑定。 除了默认绑定之外，这些模块仍然可以以不同名称导出其他绑定。

为了创建默认导出，可以在表达式，函数声明或类声明之前编写`export default`。

```js
export default ["Winter", "Spring", "Summer", "Autumn"];
```

可以使用单词`as`重命名导入的绑定。

```js
import {days as dayNames} from "date-names";

console.log(dayNames.length);
// → 7
```

另一个重要的区别是，ES 模块的导入发生在模块的脚本开始运行之前。 这意味着`import`声明可能不会出现在函数或块中，并且依赖项的名称只能是带引号的字符串，而不是任意的表达式。

在撰写本文时，JavaScript 社区正在采用这种模块风格。 但这是一个缓慢的过程。 在规定格式之后，花了几年的时间，浏览器和 Node.js 才开始支持它。 虽然他们现在几乎都支持它，但这种支持仍然存在问题，这些模块如何通过 NPM 分发的讨论仍在进行中。

许多项目使用 ES 模块编写，然后在发布时自动转换为其他格式。 我们正处于并行使用两个不同模块系统的过渡时期，并且能够读写任何一种之中的代码都很有用。

## 构建和打包

事实上，从技术上来说，许多 JavaScript 项目都不是用 JavaScript 编写的。有一些扩展被广泛使用，例如第 8 章中提到的类型检查方言。很久以前，在语言的某个计划性扩展添加到实际运行 JavaScript 的平台之前，人们就开始使用它了。

为此，他们编译他们的代码，将其从他们选择的 JavaScript 方言翻译成普通的旧式 JavaScript，甚至是过去的 JavaScript 版本，以便旧版浏览器可以运行它。

在网页中包含由 200 个不同文件组成的模块化程序，会产生它自己的问题。如果通过网络获取单个文件需要 50 毫秒，则加载整个程序需要 10 秒，或者如果可以同时加载多个文件，则可能需要一半。这浪费了很多时间。因为抓取一个大文件往往比抓取很多小文件要快，所以 Web 程序员已经开始使用工具，将它们发布到 Web 之前，将他们（费力分割成模块）的程序回滚成单个大文件。这些工具被称为打包器。

我们可以再深入一点。 除了文件的数量之外，文件的大小也决定了它们可以通过网络传输的速度。 因此，JavaScript 社区发明了压缩器。 通过自动删除注释和空白，重命名绑定以及用占用更少空间的等效代码替换代码段，这些工具使 JavaScript 程序变得更小。

因此，你在 NPM 包中找到的代码，或运行在网页上的代码，经历了多个转换阶段 - 从现代 JavaScript 转换为历史 JavaScript，从 ES 模块格式转换为 CommonJS，打包并压缩。 我们不会在本书中详细介绍这些工具，因为它们往往很无聊，并且变化很快。 请注意，你运行的 JavaScript 代码通常不是编写的代码。

## 模块设计

使程序结构化是编程的一个微妙的方面。 任何有价值的功能都可以用各种方式建模。

良好的程序设计是主观的 - 涉及到权衡和品味问题。 了解结构良好的设计的价值的最好方法，是阅读或处理大量程序，并注意哪些是有效的，哪些不是。 不要认为一个痛苦的混乱就是“它本来的方式”。 通过多加思考，你可以改善几乎所有事物的结构。

模块设计的一个方面是易用性。 如果你正在设计一些旨在由多人使用，或者甚至是你自己的东西，在三个月之内，当你记不住你所做的细节时，如果你的接口简单且可预测，这会有所帮助。

这可能意味着遵循现有的惯例。 `ini`包是一个很好的例子。 此模块模仿标准 JSON 对象，通过提供`parse`和`stringify`（用于编写 INI 文件）函数，就像 JSON 一样，在字符串和普通对象之间进行转换。 所以接口很小且很熟悉，在你使用过一次后，你可能会记得如何使用它。

即使没有能模仿的标准函数或广泛使用的包，你也可以通过使用简单的数据结构，并执行单一的重点事项，来保持模块的可预测性。 例如，NPM 上的许多 INI 文件解析模块，提供了直接从硬盘读取文件并解析它的功能。 这使得在浏览器中不可能使用这些模块，因为我们没有文件系统的直接访问权，并且增加了复杂性，通过组合模块与某些文件读取功能，可以更好地解决它。

这指向了模块设计的另一个有用的方面 - 一些代码可以轻易与其他代码组合。比起执行带有副作用的复杂操作的更大的模块，计算值的核心模块适用于范围更广的程序。坚持从磁盘读取文件的 INI 文件读取器， 在文件内容来自其他来源的场景中是无用的。

与之相关，有状态的对象有时甚至是有用的，但是如果某件事可以用一个函数完成，就用一个函数。 NPM 上的几个 INI​​ 文件读取器提供了一种接口风格，需要你先创建一个对象，然后将该文件加载到对象中，最后使用特定方法来获取结果。这种类型的东西在面向对象的传统中很常见，而且很糟糕。你不能调用单个函数来完成，你必须执行仪式，在各种状态中移动对象。而且由于数据现在封装在一个特定的对象类型中，与它交互的所有代码都必须知道该类型，从而产生不必要的相互依赖关系。

通常，定义新的数据结构是不可避免的 - 只有少数非常基本的数据结构由语言标准提供，并且许多类型的数据一定比数组或映射更复杂。 但是当数组足够时，使用数组。

一个稍微复杂的数据结构的示例是第 7 章的图。JavaScript 中没有一种明显的表示图的方式。 在那一章中，我们使用了一个对象，其属性保存了字符串数组 - 可以从某个节点到达的其他节点。

NPM 上有几种不同的寻路包，但他们都没有使用这种图的格式。 它们通常允许图的边带有权重，它是与其相关的成本或距离，这在我们的表示中是不可能的。

例如，存在`dijkstrajs`包。 一种著名的寻路方法，与我们的`findRoute`函数非常相似，它被称为迪科斯特拉（Dijkstra）算法，以首先编写它的艾兹格尔·迪科斯特拉（Edsger Dijkstra）命名。 `js`后缀通常会添加到包名称中，以表明它们用 JavaScript 编写。 这个`dijkstrajs`包使用类似于我们的图的格式，但是它不使用数组，而是使用对象，它的属性值是数字 - 边的权重。

所以如果我们想要使用这个包，我们必须确保我们的图以它期望的格式存储。 所有边的权重都相同，因为我们的简化模型将每条道路视为具有相同的成本（一个回合）。

```js
const {find_path} = require("dijkstrajs");

let graph = {};
for (let node of Object.keys(roadGraph)) {
  let edges = graph[node] = {};
  for (let dest of roadGraph[node]) {
    edges[dest] = 1;
  }
}

console.log(find_path(graph, "Post Office", "Cabin"));
// → ["Post Office", "Alice's House", "Cabin"]
```

这可能是组合的障碍 - 当各种包使用不同的数据结构来描述类似的事情时，将它们组合起来很困难。 因此，如果你想要设计可组合性，请查找其他人使用的数据结构，并在可能的情况下遵循他们的示例。

## 总结

通过将代码分离成具有清晰接口和依赖关系的块，模块是更大的程序结构。 接口是模块中可以从其他模块看到的部分，依赖关系是它使用的其他模块。

由于 JavaScript 历史上并没有提供模块系统，因此 CommonJS 系统建立在它之上。 然后在某个时候，它确实有了一个内置系统，它现在与 CommonJS 系统不兼容。

包是可以自行分发的一段代码。 NPM 是 JavaScript 包的仓库。 你可以从上面下载各种有用的（和无用的）包。

## 练习

### 模块化机器人

这些是第 7 章的项目所创建的约束：

```
roads
buildGraph
roadGraph
VillageState
runRobot
randomPick
randomRobot
mailRoute
routeRobot
findRoute
goalOrientedRobot
```

如果你要将该项目编写为模块化程序，你会创建哪些模块？ 哪个模块依赖于哪个模块，以及它们的接口是什么样的？

哪些片段可能在 NPM 上找到？ 你愿意使用 NPM 包还是自己编写？

### `roads`模块

根据第 7 章中的示例编写 CommonJS 模块，该模块包含道路数组，并将表示它们的图数据结构导出为`roadGraph`。 它应该依赖于一个模块`./graph`，它导出一个函数`buildGraph`，用于构建图。 该函数接受包含两个元素的数组（道路的起点和终点）。

```js
// Add dependencies and exports

const roads = [
  "Alice's House-Bob's House",   "Alice's House-Cabin",
  "Alice's House-Post Office",   "Bob's House-Town Hall",
  "Daria's House-Ernie's House", "Daria's House-Town Hall",
  "Ernie's House-Grete's House", "Grete's House-Farm",
  "Grete's House-Shop",          "Marketplace-Farm",
  "Marketplace-Post Office",     "Marketplace-Shop",
  "Marketplace-Town Hall",       "Shop-Town Hall"
];
```

### 循环依赖

循环依赖是一种情况，其中模块 A 依赖于 B，并且 B 也直接或间接依赖于 A。许多模块系统完全禁止这种情况，因为无论你选择何种顺序来加载此类模块，都无法确保每个模块的依赖关系在它运行之前加载。

CommonJS 模块允许有限形式的循环依赖。 只要这些模块不会替换它们的默认`exports`对象，并且在完成加载之后才能访问对方的接口，循环依赖就没有问题。

本章前面给出的`require`函数支持这种类型的循环依赖。 你能看到它如何处理循环吗？ 当一个循环中的某个模块替代其默认`exports`对象时，会出现什么问题？
