第四版 现已上市。点击这里阅读

第 10 章模块

编写易于删除、不易扩展的代码。

Tef,《编程很糟糕》
Picture of a building built from modular pieces

理想的程序具有清晰的结构。它的工作方式易于解释,每个部分都扮演着明确的角色。

典型的真实程序是有机增长的。随着新需求的出现,新的功能部分会被添加进来。结构化—和保持结构—是额外的工作。这项工作只会将来得到回报,也就是下次有人使用该程序时。因此,人们很容易忽视它,并允许程序的各个部分变得相互交织。

这会导致两个实际问题。首先,理解这样的系统很困难。如果所有内容都可以相互接触,则难以单独查看任何特定部分。你被迫建立对整个事物的整体理解。其次,如果你想在其他情况下使用来自此程序的任何功能,重写它可能比试图将其从上下文中分离出来更容易。

术语“一团乱麻”经常用于描述这种大型的无结构程序。所有内容都粘在一起,当你试图挑出一块时,整个东西就会散架,你的手也会弄脏。

模块

模块是试图避免这些问题的尝试。模块是一段程序代码,它指定了它依赖的其他代码部分以及它为其他模块使用提供的功能(它的接口)。

模块接口与对象接口有很多共同点,正如我们在第 6 章中看到的。它们使模块的一部分对外部世界可用,并保持其他部分私有。通过限制模块相互交互的方式,系统变得更像乐高,其中各个部分通过定义明确的连接器进行交互,而不是像泥巴一样,所有东西都混在一起。

模块之间的关系称为依赖。当一个模块需要来自另一个模块的一部分时,据说它依赖于该模块。当这个事实明确地指定在模块本身中时,它可以用来确定哪些其他模块需要存在才能使用给定模块,以及自动加载依赖关系。

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

将 JavaScript 代码放到不同的文件中并不能满足这些要求。这些文件仍然共享相同的全局命名空间。它们可能有意或无意地干扰彼此的绑定。并且依赖关系结构仍然不清楚。我们可以做得更好,正如我们将在本章后面看到的那样。

为程序设计合适的模块结构可能很困难。在你还在探索问题的阶段,尝试不同的方法来查看哪些方法有效,你可能不想太担心它,因为它可能是一个很大的干扰。一旦你有了感觉坚实的东西,那就到了退一步并整理它的好时机。

将程序构建成单独的部分的优势之一是,你实际上能够单独运行这些部分,这意味着你可能能够在不同的程序中应用相同的部分。

但是,你如何设置它?假设我想在另一个程序中使用来自第 9 章parseINI函数。如果明确了该函数依赖的内容(在本例中,什么也不依赖),我可以将所有必要的代码复制到我的新项目中并使用它。但如果我发现该代码中存在错误,我可能会在当时正在使用的任何程序中修复它,并忘记在另一个程序中也修复它。

一旦你开始复制代码,你很快就会发现自己浪费时间和精力来移动副本并保持它们更新。

这就是的用武之地。包是一段可以分发的代码(复制和安装)。它可能包含一个或多个模块,并包含有关它依赖于哪些其他包的信息。包通常还附带文档,解释它所做的事情,以便那些没有编写它的人仍然可以使用它。

当在包中发现问题或添加新功能时,该包会更新。现在,依赖于它的程序(也可能是包)可以升级到新版本。

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

NPM 是两件事:一个可以从中下载(和上传)包的在线服务,以及一个程序(与 Node.js 捆绑在一起),可以帮助你安装和管理它们。

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

第 20 章将展示如何使用npm命令行程序在本地安装此类包。

能够下载高质量的包非常有价值。这意味着我们通常可以避免重新发明 100 个人之前已经编写的程序,并通过按几个键获得一个可靠的、经过良好测试的实现。

软件的复制成本很低,因此一旦有人编写了它,将其分发给其他人就是一个有效的过程。但首先编写它工作,而对那些发现代码中存在问题的人或想要提出新功能的人做出回应,更是工作。

默认情况下,你拥有你编写的代码的版权,其他人只有在你允许的情况下才能使用它。但是,由于有些人只是很好,并且发布好的软件可以帮助你在程序员中获得一些名气,因此许多包是在允许其他人使用的许可证下发布的。

NPM 上的大多数代码都是这样许可的。一些许可证要求你也以相同许可证发布你构建在包之上的代码。其他许可证要求没那么严格,只是要求你在分发代码时保留许可证。JavaScript 社区主要使用后一种类型的许可证。在使用其他人的包时,请确保了解它们的许可证。

临时模块

直到 2015 年,JavaScript 语言都没有内置的模块系统。然而,人们已经使用 JavaScript 构建大型系统超过十年,他们需要模块。

因此,他们在语言之上设计了自己的模块系统。你可以使用 JavaScript 函数来创建本地作用域,使用对象来表示模块接口。

这是一个用于在星期名称和数字(由DategetDay方法返回)之间转换的模块。它的接口由weekDay.nameweekDay.number组成,它将本地绑定names隐藏在一个立即调用的函数表达式的作用域内。

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,它将在当前作用域中执行字符串。这通常是一个坏主意,因为它破坏了作用域通常具有的某些属性,例如,很容易预测给定名称引用哪个绑定。

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

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

一种不那么可怕的将数据解释为代码的方法是使用Function构造函数。它接受两个参数:一个包含逗号分隔的参数名称列表的字符串,以及一个包含函数体的字符串。它将代码包装在一个函数值中,以便它获得它自己的作用域,并且不会对其他作用域做奇怪的事情。

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

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

CommonJS

最常用的 bolted-on 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” 这样的输出。

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 导出包含多个内容的对象 - daysmonths 是名称数组。解构在为导入的接口创建绑定时非常方便。

该模块将它的接口函数添加到 exports 中,以便依赖它的模块可以访问它。我们可以像这样使用这个模块:

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

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

我们可以像这样定义 require,以其最简单的形式:

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 来替换它。许多模块通过这样做来导出单个值而不是接口对象。

通过将 requireexportsmodule 定义为生成包装函数的参数(并在调用它时传递适当的值),加载器确保这些绑定在模块的作用域中可用。

传递给 require 的字符串转换为实际文件名或 Web 地址的方式在不同的系统中有所不同。当它以 "./""../" 开头时,它通常被解释为相对于当前模块的文件名。因此 "./format-date" 将是与当前目录中名为 format-date.js 的文件。

当名称不是相对的时,Node.js 会查找安装的包。在本节中的示例代码中,我们将解释这样的名称是指 NPM 包。我们将在 第 20 章 中详细介绍如何安装和使用 NPM 模块。

现在,我们可以从 NPM 使用 INI 文件解析器,而不是编写我们自己的 INI 文件解析器。

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 关键字。

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

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

类似地,export 关键字用于导出内容。它可能出现在函数、类或绑定定义(letconstvar)的前面。

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

当存在名为 default 的绑定时,它被视为模块的主要导出值。如果您像示例中那样导入 ordinal 模块,没有在绑定名称周围使用大括号,您将获得它的 default 绑定。这样的模块仍然可以在其 default 导出之外,使用不同的名称导出其他绑定。

要创建默认导出,您可以在表达式、函数声明或类声明之前写入 export default

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

可以使用 as 关键字重命名导入的绑定。

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包。该模块通过提供parsestringify(用于写入INI文件)函数来模仿标准的JSON对象,并且与JSON一样,在字符串和平面对象之间进行转换。因此,接口很小且熟悉,而且您使用过一次之后,很可能会记住如何使用它。

即使没有标准函数或广泛使用的包可供模仿,您也可以通过使用简单的数据结构并执行一项单一的、集中性的操作,使模块保持可预测性。例如,NPM上的许多INI文件解析模块提供了一个函数,该函数直接从硬盘读取此类文件并对其进行解析。这使得在浏览器中使用这些模块变得不可能,因为我们没有直接的文件系统访问权限,并且增加了复杂性,而这些复杂性原本可以通过将模块与一些文件读取函数组合来更好地解决。

这指出了模块设计另一个有用的方面——代码易于与其他代码组合。专注于计算值的模块适用于比执行具有副作用的复杂操作的更大模块范围更广的程序。坚持从磁盘读取文件的INI文件读取器在文件内容来自其他来源的情况下毫无用处。

相关地,有状态对象有时有用,甚至必要,但如果可以用函数完成,则使用函数。NPM上的几个INI文件读取器提供了需要您首先创建一个对象,然后将文件加载到您的对象中,最后使用专门的方法获取结果的接口样式。这种事情在面向对象的传统中很常见,而且很糟糕。您不必进行单一的函数调用并继续执行,而必须执行将对象移至各种状态的仪式。而且,由于数据现在包装在一个专门的对象类型中,所有与之交互的代码都必须了解该类型,从而导致不必要的相互依赖关系。

通常无法避免定义新的数据结构——语言标准只提供了一些基本的数据结构,而且许多类型的数据必须比数组或映射更复杂。但是,如果数组足够,请使用数组。

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

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

例如,有dijkstrajs包。一种众所周知的寻路方法,与我们的findRoute函数非常相似,被称为Dijkstra算法,以Edsger Dijkstra的名字命名,他最初写下了该算法。js后缀通常添加到包名中,以表明它们是用JavaScript编写的。这个dijkstrajs包使用与我们类似的图格式,但是它使用对象而不是数组,对象的值是数字——边的权重。

因此,如果我们想使用那个包,我们必须确保我们的图以它期望的格式存储。所有边都获得相同的权重,因为我们的简化模型将每条路视为具有相同的成本(一次转弯)。

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包还是自己编写?

以下是我会怎么做(但同样,没有一种正确的设计给定模块的方法)

用于构建道路图的代码位于graph模块中。因为我宁愿使用来自NPM的dijkstrajs而不是我们自己的寻路代码,所以我们将使它构建dijkstrajs期望的图数据类型。该模块导出单个函数buildGraph。我将使buildGraph接受一个包含两个元素的数组,而不是包含连字符的字符串,以使模块对输入格式的依赖性降低。

roads模块包含原始道路数据(roads数组)和roadGraph绑定。该模块依赖于./graph,并导出道路图。

VillageState类位于state模块中。它依赖于./roads模块,因为它需要能够验证给定的道路是否存在。它还需要randomPick。由于这是一个三行函数,我们可以将其作为内部帮助函数直接放入state模块中。但randomRobot也需要它。因此,我们必须要么复制它,要么将其放入它自己的模块中。由于此函数碰巧在random-item包的NPM中存在,因此一个很好的解决方案是使两个模块都依赖于它。我们也可以将runRobot函数添加到此模块中,因为它很小并且与状态管理密切相关。该模块导出VillageState类和runRobot函数。

最后,机器人及其依赖的价值观(如mailRoute)可以进入example-robots模块,该模块依赖于./roads并导出机器人函数。为了使goalOrientedRobot能够进行寻路,该模块还依赖于dijkstrajs

通过将一些工作卸载到NPM模块,代码变得更小了一些。每个模块都做了一些比较简单的事情,可以独立阅读。将代码划分成模块通常还会提示对程序设计的进一步改进。在本例中,VillageState和机器人依赖于特定的道路图似乎有点奇怪。将图作为状态构造函数的参数,并使机器人从状态对象中读取它可能是一个更好的主意——这减少了依赖项(这始终是好事),并且可以对不同的地图运行模拟(这甚至更好)。

将NPM模块用于我们可以自己编写的东西是一个好主意吗?原则上,是的——对于像寻路函数这样的非平凡事物,您很可能会犯错误并浪费时间自己编写它们。对于像random-item这样的微型函数,自己编写它们很容易。但是,在需要它们的地方添加它们确实会使模块混乱。

但是,您也不应该低估查找适当的NPM包所涉及的工作。即使您找到了一个,它可能无法正常工作,或者可能缺少您需要的某些功能。最重要的是,依赖于NPM包意味着您必须确保它们已安装,您必须将它们与您的程序一起分发,并且您可能必须定期升级它们。

所以,再说一次,这是一个权衡,您可以根据包对您的帮助程度来决定。

道路模块

编写一个基于来自第7章的示例的CommonJS模块,该模块包含道路数组,并将表示它们的图数据结构导出为roadGraph。它应该依赖于模块./graph,该模块导出一个用于构建图的函数buildGraph。此函数期望一个包含两个元素的数组(道路的起点和终点)。

// 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"
];

由于这是一个CommonJS模块,您必须使用require来导入图形模块。正如所述,它导出一个buildGraph函数,您可以使用解构const声明从其接口对象中挑选出来。

要导出roadGraph,您需要向exports对象添加一个属性。由于buildGraph接受的数据结构与roads不完全匹配,因此道路字符串的拆分必须在您的模块中进行。

循环依赖关系

循环依赖关系是模块A依赖于B,而B也直接或间接依赖于A的情况。许多模块系统都简单地禁止这样做,因为无论您选择加载这些模块的顺序,您都无法确保每个模块的依赖项在它运行之前都被加载。

CommonJS模块允许有限形式的循环依赖关系。只要模块不替换它们的默认exports对象,并且在它们完成加载之前不访问彼此的接口,循环依赖关系是可以的。

本章前面提到的require函数支持这种类型的依赖循环。你能看到它如何处理循环吗?当循环中的模块确实替换了它的默认exports对象时,会发生什么问题呢?

诀窍在于require在开始加载模块之前将模块添加到其缓存中。这样,如果在运行过程中任何require调用尝试加载它,它就已经被识别,并且将返回当前接口,而不是再次开始加载模块(这最终会导致堆栈溢出)。

如果一个模块覆盖了它的module.exports值,任何在它完成加载之前已收到其接口值的模块都将获得默认接口对象(它很可能为空),而不是预期的接口值。