模块
理想情况下,程序应该具有清晰明了的结构。其工作方式易于解释,每个部分都发挥着明确定义的角色。
在实际应用中,程序会随着时间的推移而逐渐增长。程序员在识别出新的需求时会添加功能块。为了保持程序结构良好,需要不断地关注和努力。这种努力只有在未来才会带来回报,也就是在下一次有人修改程序时,所以很容易被忽视,导致程序的各个部分变得相互缠绕。
这会带来两个实际问题。首先,理解一个相互缠绕的系统很困难。如果一切都可能相互影响,那么就很难孤立地查看任何特定部分。你被迫建立对整个系统的全面理解。其次,如果你想在其他情况下使用来自该程序的任何功能,重写代码可能比尝试将其从其上下文中分离出来更容易。
短语“一团乱麻”通常用来形容这种大型无结构的程序。所有东西都粘在一起,当你试图提取一个部分时,整个系统就会崩溃,你只能徒劳地弄得一团糟。
模块化程序
模块是为了避免这些问题而提出的尝试。模块是程序的一部分,它指定了依赖哪些其他部分以及它为其他模块提供哪些功能(它的接口)。
模块接口与对象接口有很多共同点,我们在第 6 章中看到了这一点。它们将模块的一部分提供给外部世界,并将其他部分保持为私有。
但模块为其他模块提供的接口只是故事的一半。一个好的模块系统还需要模块指定它们从其他模块使用哪些代码。这些关系被称为依赖关系。如果模块 A 使用模块 B 的功能,那么它被认为是依赖于该模块。当这些依赖关系在模块本身中明确指定时,就可以用来确定哪些其他模块需要存在才能使用给定模块,以及自动加载依赖关系。
当模块之间交互的方式是明确的时,系统就更像乐高积木,积木通过明确定义的连接器进行交互,而不是像泥巴一样,所有东西都混合在一起。
ES 模块
原始的 JavaScript 语言没有模块的概念。所有脚本都在同一个作用域中运行,访问另一个脚本中定义的函数是通过引用该脚本创建的全局绑定来实现的。这实际上鼓励了代码的意外、难以察觉的相互缠绕,并引发了一些问题,比如无关的脚本尝试使用相同的绑定名称。
自 ECMAScript 2015 以来,JavaScript 支持两种不同类型的程序。脚本的行为方式与以前相同:它们的绑定在全局作用域中定义,并且它们无法直接引用其他脚本。模块拥有自己的独立作用域,并支持import
和export
关键字(脚本中不可用),用于声明其依赖关系和接口。这种模块系统通常被称为ES 模块(其中ES代表 ECMAScript)。
一个模块化程序由许多这样的模块组成,它们通过导入和导出进行连接。
以下示例模块在星期名称和数字(由Date
的getDay
方法返回)之间进行转换。它定义了一个不属于其接口的常量,以及两个属于其接口的函数。它没有任何依赖关系。
const names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; export function dayName(number) { return names[number]; } export function dayNumber(name) { return names.indexOf(name); }
export
关键字可以放在函数、类或绑定定义之前,表示该绑定是模块接口的一部分。这使得其他模块可以通过导入来使用该绑定。
import {dayName} from "./dayname.js"; let now = new Date(); console.log(`Today is ${dayName(now.getDay())}`); // → Today is Monday
import
关键字,后跟一组用大括号括起来的绑定名称,使另一个模块的绑定在当前模块中可用。模块由带引号的字符串标识。
这种模块名称如何解析为实际程序取决于平台。浏览器将它们视为网络地址,而 Node.js 将它们解析为文件。当你运行一个模块时,它依赖的所有其他模块(以及这些模块依赖的模块)都会被加载,并且导出的绑定会提供给导入它们的模块。
导入和导出声明不能出现在函数、循环或其他代码块内部。它们在模块加载时立即解析,而与模块中的代码如何执行无关。为了反映这一点,它们必须只出现在最外层模块主体中。
因此,模块的接口由一组命名的绑定组成,依赖于该模块的其他模块可以访问这些绑定。导入的绑定可以使用其名称后面的as
重命名以赋予它们新的本地名称。
import {dayName as nomDeJour} from "./dayname.js"; console.log(nomDeJour(3)); // → Wednesday
模块还可以有一个名为default
的特殊导出,通常用于只导出单个绑定的模块。要定义默认导出,请在表达式、函数声明或类声明之前写export default
。
export default ["Winter", "Spring", "Summer", "Autumn"];
import seasonNames from "./seasonname.js";
要同时从模块中导入所有绑定,可以使用import *
。你提供一个名称,该名称将绑定到一个包含所有模块导出的对象。当你要使用很多不同的导出时,这很有用。
import * as dayName from "./dayname.js"; console.log(dayName.dayName(3)); // → Wednesday
包
将程序构建成独立的块并能够独立运行这些块中的某些块的优点之一是,你可以在不同的程序中使用同一个块。
但如何设置呢?假设我想在另一个程序中使用来自第 9 章的parseINI
函数。如果函数的依赖关系很明确(在本例中,没有任何依赖关系),我就可以简单地将该模块复制到我的新项目中并使用它。但如果我在代码中发现错误,我可能会在当前正在使用的程序中修复它,而忘记在另一个程序中也修复它。
一旦你开始复制代码,你就会很快发现自己浪费时间和精力,到处移动副本并保持它们更新。这就是包的用处。包是可以分发(复制和安装)的代码块。它可能包含一个或多个模块,并包含有关它依赖哪些其他包的信息。包通常还会附带说明其功能的文档,以便那些没有编写过包的人也能使用它。
当包中发现问题或添加新功能时,包会更新。现在,依赖于它的程序(也可能是包)可以复制新版本以获取代码的改进。
以这种方式工作需要基础设施。我们需要一个存储和查找包的地方,以及一种方便的方式来安装和升级它们。在 JavaScript 世界中,这种基础设施由 NPM (https://npmjs.net.cn)提供。
NPM 包含两部分:一个在线服务,你可以在其中下载(和上传)包,以及一个程序(与 Node.js 捆绑在一起),可以帮助你安装和管理它们。
在撰写本文时,NPM 上有超过 300 万个不同的包。公平地说,其中很大一部分是垃圾。但几乎所有有用的公开可用的 JavaScript 包都可以在 NPM 上找到。例如,一个 INI 文件解析器,类似于我们在第 9 章中构建的解析器,可以在包名ini
下找到。
第 20 章将展示如何使用npm
命令行程序在本地安装此类包。
拥有高质量的包供下载非常有价值。这意味着我们通常可以避免重新发明 100 人之前编写过的程序,并且可以通过按几个键来获得一个可靠的、经过充分测试的实现。
软件的复制成本很低,因此一旦有人编写了软件,将其分发给其他人就是一个高效的过程。但最开始编写软件需要工作,而响应那些发现代码问题或想提出新功能的人则需要更多工作。
默认情况下,你拥有你编写的代码的版权,其他人只有在你允许的情况下才能使用它。但由于有些人只是心地善良,并且发布良好的软件可以帮助你在程序员中出名,因此许多包是在一个明确允许其他人使用的许可下发布的。
NPM 上的大多数代码都是以这种方式授权的。有些许可证要求您也必须以相同的许可证发布您在包之上构建的代码。其他许可证要求较低,只需要您在分发代码时保留许可证即可。JavaScript 社区主要使用后一种类型的许可证。在使用他人的软件包时,请确保您了解其许可证。
现在,我们无需编写自己的 INI 文件解析器,而是可以从 NPM 使用一个。
import {parse} from "ini"; console.log(parse("x = 10\ny = 20")); // → {x: "10", y: "20"}
CommonJS 模块
在 2015 年之前,JavaScript 语言还没有内置的模块系统,人们就已经在用 JavaScript 构建大型系统了。为了使这成为可能,他们需要模块。
社区在语言之上设计了自己的简易模块系统。这些系统使用函数为模块创建本地范围,使用普通对象表示模块接口。
最初,人们只是手动将整个模块包装在一个“立即调用函数表达式”中来创建模块的范围,并将它们的接口对象分配给单个全局变量。
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
这种模块样式在一定程度上提供了隔离,但它没有声明依赖关系。相反,它只是将它的接口放入全局范围,并期望它的依赖关系(如果有)也这样做。这不是理想的。
如果我们实现自己的模块加载器,我们可以做得更好。在 bolted-on JavaScript 模块中,最广泛使用的方法称为CommonJS 模块。Node.js 从一开始就使用了这种模块系统(尽管它现在也知道如何加载 ES 模块),并且它是 NPM 上许多软件包使用的模块系统。
CommonJS 模块看起来像一个常规脚本,但它可以访问两个绑定,用于与其他模块交互。第一个是一个名为require
的函数。当您使用依赖项的模块名称调用它时,它会确保模块被加载并返回其接口。第二个是一个名为exports
的对象,它是模块的接口对象。它一开始是空的,您向它添加属性以定义导出的值。
这个 CommonJS 示例模块提供了一个日期格式化函数。它使用来自 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
导出一个包含多个内容的对象——days
和months
是名称数组。在为导入的接口创建绑定时,解构非常方便。
模块将它的接口函数添加到exports
中,以便依赖它的模块可以访问它。我们可以像这样使用该模块
const {formatDate} = require("./format-date.js"); console.log(formatDate(new Date(2017, 9, 13), "dddd the Do")); // → Friday the 13th
CommonJS 是用一个模块加载器实现的,该加载器在加载模块时会将模块的代码包装在一个函数中(赋予它自己的本地范围),并将require
和exports
绑定作为参数传递给该函数。
如果我们假设我们可以访问一个readFile
函数,它可以通过名称读取文件并提供其内容,我们可以像这样定义require
的简化形式
function require(name) { if (!(name in require.cache)) { let code = readFile(name); let exports = require.cache[name] = {}; let wrapper = Function("require, exports", code); wrapper(require, exports); } return require.cache[name]; } require.cache = Object.create(null);
Function
是一个内置的 JavaScript 函数,它接受一个参数列表(以逗号分隔的字符串形式)和一个包含函数体的字符串,并返回一个具有这些参数和该函数体的函数值。这是一个有趣的概念——它允许程序从字符串数据创建新的程序片段——但也是一个危险的概念,因为如果有人可以欺骗你的程序将他们提供的字符串放入Function
中,他们就可以让程序做任何他们想做的事情。
标准 JavaScript 不提供像readFile
这样的函数,但不同的 JavaScript 环境,例如浏览器和 Node.js,提供它们自己的访问文件的方式。示例只是假装readFile
存在。
为了避免多次加载同一个模块,require
会保留一个已加载模块的存储(缓存)。当被调用时,它首先检查请求的模块是否已被加载,如果没有,就加载它。这包括读取模块的代码,将其包装在一个函数中,并调用它。
通过将require
和exports
定义为生成的包装函数的参数(并在调用它时传递适当的值),加载器确保这些绑定在模块的范围内可用。
这个系统和 ES 模块的一个重要区别是,ES 模块导入发生在模块的脚本开始运行之前,而require
是一个普通函数,在模块已经运行时被调用。与import
声明不同,require
调用可以出现在函数内部,依赖项的名称可以是任何计算结果为字符串的表达式,而import
只允许使用普通引号字符串。
JavaScript 社区从 CommonJS 样式过渡到 ES 模块是一个缓慢而有些粗糙的过程。幸运的是,我们现在已经到了 NPM 上大多数流行的软件包都以 ES 模块的形式提供代码,并且 Node.js 允许 ES 模块从 CommonJS 模块导入。虽然 CommonJS 代码仍然是你会遇到的东西,但现在没有真正理由用这种方式编写新程序了。
构建和打包
许多 JavaScript 软件包在技术上并非用 JavaScript 编写的。TypeScript 等语言扩展(在第 8 章中提到的类型检查方言)被广泛使用。人们还经常在计划中的新语言特性被实际运行 JavaScript 的平台添加之前就开始使用它们。为了使这成为可能,他们编译他们的代码,将其从他们选择的 JavaScript 方言转换为普通的 JavaScript——甚至转换为过去的 JavaScript 版本——以便浏览器可以运行它。
在一个网页中包含一个由 200 个不同文件组成的模块化程序会产生自己的问题。如果通过网络获取单个文件需要 50 毫秒,则加载整个程序需要 10 秒,或者如果您可以同时加载多个文件,则可能需要一半的时间。这是很多浪费的时间。由于获取单个大文件往往比获取很多小文件更快,因此 Web 程序员开始使用将程序(他们煞费苦心地将其拆分为模块)合并成一个大文件的工具,然后才能将其发布到网上。这种工具被称为打包器。
我们可以更进一步。除了文件数量,文件的大小也会决定它们通过网络传输的速度。因此,JavaScript 社区发明了压缩器。这些工具会获取一个 JavaScript 程序,并通过自动删除注释和空白、重命名绑定以及用占用更少空间的等效代码替换代码片段来使其变小。
在 NPM 软件包中找到的代码或在网页上运行的代码经过多个转换阶段并不罕见——从现代 JavaScript 转换为历史 JavaScript,将模块合并成单个文件,以及压缩代码。我们不会在这本书中详细介绍这些工具,因为有很多工具,而且流行的工具会定期变化。只要知道这类工具存在,并在需要时查找它们。
模块设计
结构化程序是编程中比较微妙的方面之一。任何非平凡的功能都可以用各种方式进行组织。
良好的程序设计是主观的——它涉及权衡取舍,也是个人品味的问题。学习结构良好的设计的价值的最好方法是阅读或处理很多程序,并注意哪些有效,哪些无效。不要假设痛苦的混乱是“本来就是这样”。通过多加思考,你几乎可以改善所有东西的结构。
模块设计的一个方面是易用性。如果你设计的是供多人使用的东西——或者甚至是供你自己在三个月后使用,那时你已经记不清你做了什么——如果你的接口简单易懂,这将很有帮助。
这可能意味着遵循现有的约定。一个很好的例子是ini
软件包。这个模块模仿了标准的JSON
对象,提供了parse
和stringify
(用于写入 INI 文件)函数,并且与JSON
类似,它在字符串和平面对象之间进行转换。接口小而熟悉,在你使用过一次之后,你很可能会记住如何使用它。
即使没有标准函数或广泛使用的软件包可供模仿,你也可以通过使用简单的数据结构并执行一项集中式操作来使你的模块可预测。例如,NPM 上许多 INI 文件解析模块提供了一个函数,它直接从硬盘读取这样的文件并解析它。这使得在浏览器中使用这些模块变得不可能,因为在浏览器中我们没有直接的文件系统访问权限,并且增加了复杂性,而这些复杂性本可以通过将模块与一些文件读取函数组合来更好地解决。
这指出了模块设计的另一个有用方面——易于与其他代码组合。专注于计算值的模块比执行具有副作用的复杂操作的较大模块在更广泛的程序中适用。坚持从磁盘读取文件的 INI 文件读取器在文件内容来自其他来源的情况下毫无用处。
相关地,有状态的对象有时很有用甚至必要,但如果可以用函数完成,就使用函数。NPM 上的几个 INI 文件读取器提供了一种接口风格,要求您先创建一个对象,然后将文件加载到您的对象中,最后使用专门的方法来获取结果。这种事情在面向对象传统中很常见,而且很糟糕。与其进行一次函数调用并继续,您必须执行将您的对象通过其各种状态移动的仪式。由于数据现在被包装在一个专门的对象类型中,与之交互的所有代码都必须知道该类型,从而创建了不必要的相互依赖关系。
通常,定义新的数据结构是不可避免的——语言标准只提供了一些基本的数据结构,而且许多类型的数据必须比数组或映射更复杂。但是当数组足够时,请使用数组。
稍微复杂一些的数据结构的例子是来自第 7 章的图。在 JavaScript 中没有一个明显的表示图的方式。在那一章中,我们使用了属性保存字符串数组的对象——从该节点可达的其他节点。
NPM 上有几个不同的路径查找包,但没有一个使用这种图格式。它们通常允许图的边具有权重,即与其相关的成本或距离。在我们目前的表示中,这是不可能的。
例如,有 dijkstrajs
包。一种众所周知的路径查找方法,与我们的 findRoute
函数非常相似,称为迪杰斯特拉算法,以第一个写下它的 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.js
模块中。因为我宁愿使用 NPM 上的 dijkstrajs
而不是我们自己的路径查找代码,所以我们将构建 dijkstrajs
预期的图数据。此模块导出单个函数 buildGraph
。我会让 buildGraph
接受一个包含两个元素数组的数组,而不是包含连字符的字符串,以使模块对输入格式的依赖性更小。
roads.js
模块包含原始道路数据(roads
数组)和 roadGraph
绑定。此模块依赖于 ./graph.js
并导出道路图。
VillageState
类位于 state.js
模块中。它依赖于 ./roads.js
模块,因为它需要能够验证给定的道路是否存在。它还需要 randomPick
。由于这是一个三行函数,我们可以将其作为内部辅助函数放入 state.js
模块中。但 randomRobot
也需要它。因此,我们必须要么复制它,要么将其放入它自己的模块中。由于此函数恰好在 NPM 的 random-item
包中存在,因此一个合理的解决方案是让这两个模块都依赖于它。我们也可以将 runRobot
函数添加到此模块中,因为它很小并且与状态管理密切相关。该模块导出 VillageState
类和 runRobot
函数。
最后,机器人及其依赖的值(例如 mailRoute
)可以进入 example-robots.
模块,该模块依赖于 ./roads.js
并导出机器人函数。为了使 goalOrientedRobot
能够进行路径查找,此模块还依赖于 dijkstrajs
。
通过将部分工作卸载到 NPM 模块,代码变得更小了一些。每个单独的模块都执行一些相当简单的事情,并且可以单独阅读。将代码划分为模块通常也会建议对程序设计进行进一步的改进。在这种情况下,VillageState
和机器人依赖于特定的道路图似乎有点奇怪。可能更好的办法是将图作为参数传递给状态的构造函数,并让机器人从状态对象中读取它——这减少了依赖关系(这始终是好事)并使在不同地图上运行模拟成为可能(这更好)。
使用 NPM 模块来完成我们本来可以自己编写的事情是一个好主意吗?原则上,是的——对于像路径查找函数这样非平凡的事情,您很可能会犯错误,并浪费时间自己编写。对于像 random-item
这样的小函数,自己编写它们很容易。但是,将它们添加到任何需要它们的地方确实会导致模块杂乱无章。
但是,也不要低估查找适当 NPM 包所涉及的工作量。即使您找到了一个,它可能无法正常工作,或者可能缺少您需要的某些功能。除此之外,依赖于 NPM 包意味着您必须确保它们已安装,您必须将它们与您的程序一起分发,并且您可能必须定期升级它们。
道路模块
编写一个基于第 7 章中的示例的 ES 模块,该模块包含道路数组,并将表示它们的图数据结构导出为 roadGraph
。它依赖于一个模块 ./graph.js
,该模块导出一个函数 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" ];
显示提示...
循环依赖
循环依赖是指模块 A 依赖于 B,而 B 也直接或间接地依赖于 A 的情况。许多模块系统会简单地禁止这种情况,因为无论您选择加载这些模块的顺序是什么,您都无法确保每个模块的依赖关系都已在它运行之前加载。
CommonJS 模块允许有限形式的循环依赖。只要模块在完成加载之前不访问彼此的接口,循环依赖就可以接受。
在本章前面给出的 require
函数支持这种类型的依赖循环。你能看出它如何处理循环吗?