0%

EJ10

模块

理想的程序有一个如水晶般清澈的结构。他工作的方式很容易去解释,并且每部分都扮演着精心定义的角色。

一个典型的真正的程序是有机增长的。随着新需求的诞生新功能被添加。结构和保留结构是额外的工作。只有在未来,下一次某人继续这项工作的时候才会有回报。所以很容易忽视它并让程序的部分陷入困境。

这导致了两个实践上的问题。首先,理解这样的系统是苦难你的。如果所有事物可以触碰所有其他的事物,很难孤立地去看待某个给定的部分。你被迫要构建对于整个事物的全盘的理解。其次,如果你想要在其他的环境下从这样的程序中使用任何的功能,重写它可能比将其从它的上下文中抽离出来更加容易。

短语“一个大泥球”经常被用于这样巨大无结构的程序。任何事物粘连在一起,当你想要抽取其中一部分时,整个东西就散了,你的手就会变脏。

模块

模块是一种避免这些问题的尝试。一个模块就是一小段程序,指明它所依赖的其他部分以及它可以给别的模块提供的功能(它的接口)。

模块接口和对象接口有很多类似之处,如同我们第六章看到的那样。它们使得模块的部分可以对外界公开,并保持其余部分私有。通过限制模块与彼此交互的方式,这个系统表现得就像乐高,每一块通过精心定义的连接器彼此交互,不像泥巴,所有的东西混在了一起。

模块之间的关系叫做依赖。当一个模块需要另一个模块的部分时,我们说它依赖那个模块。当这个事实清晰地在模块本身中指明时,他可以被用作当使用给定模块时哪一个模块需要被呈现出来并且自动加载依赖项。

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

仅仅将你的JS代码分散在不同的文件不能满足这个要求。文件仍然共享同样的命名空间。它们可以有意的或者意外地,与彼此的绑定冲突。并且依赖结构变得不清晰。我们可以做的更好,如同我们后面看到的那样。

为程序设计一个适合的模块结构是很困难的。在你仍然探索问题的阶段,尝试不同的东西去看看什么工作,你可能不想过分担心这个问题因为这使人严重分神。一旦你感觉基础很坚实了,那就是回头看看并且组织它的好时机。

从独立的部分构建一个程序,并且实际上可以独立运行这些部分的优点之一就是你可以在不同的程序中使用相同的部分。

但是你怎么操作这个呢?假设我想要在另外的程序中使用第九章的parseINI函数。如果我已经知道这个函数明确依赖什么,那么我就可以复制所有必要的代码到我的项目中并且使用它。但是如果我发现了那个代码中的错误,我将可能要修复任何使用它的程序并可能会遗忘某个程序。

一旦你开始写重复的代码了,你将很快发现自己在重复复制并且让他们与时俱进上浪费太多时间精力。

这就是包大显身手的地方。一个包就是一大块可以被分发的代码(复制和粘贴)。它可能包含一个或者模块并且拥有它所依赖包的信息。一个包也通常和文档一起出现,文档解释包的用途并使得非包作者也能知道如何使用这个包。

当包中出现问题或者新特性被添加的时候,这个包被升级。现在依赖它的程序(也可能是包)可以升级到新版本。

以这种方式工作需要基础设施。我们需要一个存储和发现包的地方和一种便捷的方式去安装和升级他们。在JS的世界,这个基础设施由NPM提供。

NPM是有两种含义:一个在线服务让别人下载或者上传包,以及一个程序(和Node.js打包)帮助你安装和管理它们。

在写作的时候,可在NPM上获取超过五十万个不同的包。我不得不说它们中的大部分都是垃圾,但是差不多所有有用的公共可获取的包都可以在上面找到。例如,INI文件解析器,相似于我们第九章构建的那个。可在包名为ini下获得。

第20章将会给你展示如何在本地使用npm命令行程序安装这些包。

拥有可供下载的高质量软件包是非常有价值的。这意味着通常我们只需要按几个键就可以避免重新造一个100个人可能已经写过的程序并且得到一个稳定的测试良好的实现。

软件的复制成本很低,所以一旦有人已经写过了,将其分发给别人是一个高效的过程。但是首先书写代码就需要经历,并且回应那些在代码中发现问题的人或者那些想要提议新功能的人要花费更多的精力。

默认地,你拥有你所写代码的版权,并且其他人只有获得你的许可才可以使用它。但是因为有些人特别好并且因为发布一个好的软件可以使你在程序员圈中更有名气,所以许多包在一个明确允许其他人使用的许可下发布。

大多数在NPM上的包以这种方式被授权。有些许可证还要求您发布在同一许可证下的包之上构建的代码。其他的就没有这么苛求,仅仅需要你分发代码的时候保持授权。JS社区大多数使用后者的许可。当使用其他人的包的时候,确保你意识到它们的许可。

即兴的模块

直到2015年,JS还没有内建的模块系统。尽管人们已经通过JS构建大型系统十几年了,它们确实需要模块。

所以他们在其他语言之上设计了他们的模块系统。你可以使用JS函数去创建本地作用域,使用对象表示模块接口。

这是一个在星期和数字之间转换的模块(Date的getDay方法)。它的接口包含weekDay.nameweekday.number,并且在立即执行的函数表达式作用域中隐藏了局部绑定names

1
2
3
4
5
6
7
8
9
10
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编程的主要方法,但是现在大多已淘汰了。

如果我们想使得依赖关系成为代码的一部分,我们必须空值加载依赖(loading dependencies)。做这个需要能够将字符串执行为代码。JS可以这么做。

将数据作为代码执行

有几种方式将数据(一串代码)作为程序部分运行。

最显而易见的方式就是特殊操作符eval,将会在当前作用域执行一个字符串。这通常是一个坏主意,因为它打破了作用域通常具有的一些属性,例如很容易预测给定绑定的引用。

1
2
3
4
5
6
7
8
9
10
const x = 1;
function evalAndReturnX(code) {
eval(code);
return x;
}

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

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

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

这就是我们想在模块系统中想要的东西。我们可以将模块代码包装在函数中并且使用那个函数作用域作为模块作用域。

CommonJS

最广泛使用的JS模块方法被叫做CommonJS模块。Node.js使用它并且也是NPM上大多数包采用的模块系统。

CommonJS模块的主要概念是一个叫做require的函数。当用一个依赖的模块名字调用这个函数时,他将确保模块被加载并返回它的接口。

因为加载器在函数中包装模块代码,模块自动获取它们自己的全局作用域。他们需要做的就是调用require来获得它们的依赖并将他们的接口绑定到exports对象。

例子模块提供了一个日期格式化函数。它使用了NPM上的两个包,ordinal来转换数字到类似于”1st”和”2nd”之类的字符串,以及date-names来获得平日和月份的英语名字。它输出一个函数,formatDate接受一个Date对象和一个模板字符串。

模板字符串包含指导格式的代码,如YYYY获取全年和Do来获取月份的天。你可以给定一个类似MMMM Do YYYY来获得这样的输出”November 22nd 2017”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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();
// 获得的日子传递给ordinal函数可以获得类似1st,4th这样的形式
if (tag == "Do") return ordinal(date.getDate());
// 获得的星期数刚好传递给days数组,因为英语第一天的星期日,也即是0索引,其他正常。
if (tag == "dddd") return days[date.getDay()];
});
};

ordinal的接口是单个函数,而date-names输出一个包含多个东西的对象,daysmonths是名字数组。当对引入的接口创造绑定时解构是非常方便的方式。

模块将它的接口函数添加到exports来让依赖它的模块可以获取到它。我们可以这样使用模块。

1
2
3
4
const {formatDate} = require("./format-date");

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

我们可以像这样定义require函数的最简形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 创造一个纯对象,不以Object.prototype为原型
require.cache = Object.create(null);

// name为引入的模块名
function require(name) {
// 要加载的模块不在缓存中
if(!(name in require.cache)) {
// 将文件内容以字符串格式返回
let code = readFile(name);
// 最终对外所见的对象,包含exports属性
let module = {exports: {}};
// 置于cache中
require.cache[name] = module;
// 包装器函数接收三个参数,执行函数体中字符串代码
let wrapper = Function("require, exports, module", code);
wrapper(require, module.exports, module);
}
// 在缓存中直接返回对应属性对象的exports属性
return require.cache[name].exports;
}

在这段代码种,readFile是一个捏造的读取文件并将其内容作为字符串返回的函数。标准JS没有提供这样的功能,但是不同的JS环境,如浏览器和Node,提供了他们自己的获取文件的方式。这个例子仅仅是假装readFile是存在的。

为了避免多次加载同样的模块,require保存一个已经加载的模块的商店(缓存)。当被调用的时候,首先检查是否请求的模块已经被加载了,如果没有的话才加载它。这涉及到读取模块的代码,将其包装在一个函数中,并且调用它。

我们之前看到的ordinal包的接口不是一个对象而是一个函数。CommonJS的一个怪癖就是即便模块系统可以创建一个空的接口对象(绑定到exports),你也可以通过重写module.exports来用任何值代替它。许多模块输出单个值而不是一个接口对象都是这么做的。

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

require的参数字符串在不同的系统中解释为实际的文件名或者web地址方式是不同的。当开始于”./“或者”../“,它通常被解释为相对于当前模块文件名的。所以”./format-date”将会是相同文件夹下的叫做”format-date.js”的文件。

当名字不是相对的时,Node.js会通过这个名字找到一个被安装的包。在这章的例子代码中,我们将会把这样的名字作为NPM包解释。我们将在20章涉及更多安装和使用NPM模块的细节。

现在,代替写我们自己的INI文件解析器,我们可以从NPM中获取一个。

1
2
3
4
const {parse} = require("ini");

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

ECMASCRIPT模块

CommonJS模块工作的非常好,并且组合NPM,允许JS社区开始大规模共享代码。

但是它们保留了一些管道胶带hack。记号有点尴尬,如你对exports添加的东西在局部作用域无法获取。并且因为require是一个接受任何类型参数的普通函数调用,不只是字符串字面量,在不运行代码的时候很难确定模块的依赖。

这就是JS标准在2015年引入了内建的不同的模块系统的原因。它通常被叫做ES模块,ES代表ECMAScript。主要的依赖和接口的概念保持一致,但是细节有所区别。首先,标记已经集成到语言本身了。代替通过调用函数获取依赖,你可以使用特殊的import关键字。

1
2
3
4
import ordinal from "ordinal";
import {days, months} from "date-names";

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

相似地,export关键字被用于输出东西。它可以在函数,类或者绑定定义(let,var或者const)前出现。

一个ES模块的接口不是单个值而是一组命名的绑定。前面的模块将formatDate绑定到一个函数。当你从另外的模块引入时,你引入了绑定,而不是只,也意味着一个输出模块可能在任何时候改变绑定的值,并且引入它的模块能够看见它的新值。

当有一个叫做default的绑定时,他被当作模块默认的主要的输出值。如果你引入一个类似例子中的ordinal模块,在绑定名字周围没有括号,你将获得一个默认绑定。这样的模块仍然可以输出其他名字的绑定。

为了创造一个默认输出,你在表达式前书写export default,一个函数声明或者一个类声明。

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

可使用as关键字重命名引入的绑定:

1
2
3
4
import {days as dayNames} from "date-names";

console.log(dayNames.length);
// -> 7

另一个重要的区别是ES模块的import发生在模块脚本运行之前。那意味着import声明不会出现在函数或者块中,并且依赖的名字必须是引用的字符串,而不是任意表达式。

在写作的时候,JS社区正处在接纳这种模块风格的过程中。但这是一个缓慢的过程。在格式被指定之后要花费几年的时间让浏览器和Node开始支持它。即使他们大多都正在支持,但是这种支持仍然存在问题,并且对这样的模块应该怎样通过NPM分发的讨论仍在进行。

许多使用ES模块写的项目在发行时自动转换为其他格式。我们正处在两种不同的模块系统肩并肩的使用的过渡时期,能够在任何一种模块系统下读取和写代码是很有用的。

构建和打包

事实上,许多JS项目技术上甚至不是用JS写的。例如第八章提及的类型检查扩展。被广泛使用。人们也经常开始使用对语言的计划的扩展,即便是在这些扩展还没有添加到实际运行JS的平台。

为了使这个成为可能,它们编译它们的代码,将他们选择的JS方言解释为平实的古老的JS,或者甚至是过去版本的JS,以让旧的浏览器可以运行。

在一个web页面包含200个不同文件的模块程序会产生它本身的问题。如果通过网络获取单个文件需要花费50毫秒,那么加载整个程序要花费10秒时间,或者如果可以同时加载几个文件这个时间会减半。那确实有点浪费时间。因为获取一个单个大文件要比获取许多不同的小文件更快,web开发者已经开始使用工具,这些工具在它们将项目发行到web之前,将它们的程序(费力的分割的模块)卷成一个单个大文件。这样的工具叫做打包器。

我们可以更进一步。除了文件的数量,文件的大小也决定了它们可以在网络上以多块的速度传输。因此,JS社区发明了缩小器(minifier)。这些工具接收一个JS程序,并通过移除空白和注释,重命名绑定和用等价占据更少空间的代码替换成块的代码来使得JS程序更小。

所以对于你在NPM包中发现的代码或者运行在网页上的代码,已经经历过多个转换阶段是很常见的。从现代JS到历史的JS,从ES模块格式到CommonJS,打包,压缩…我们将不会在本书中涉及过多细节因为这些工具特别枯燥并且日新月异。仅仅意识到你运行的JS代码通常不是它所写时候的代码了。

模块设计

程序的结构化式编程的一个更微妙的方面。任何重要的功能可以用多种方式去建模。

好的程序设计是主观的,涉及到折衷以及口味。学习定义良好结构的设计的价值的最佳方式就是读或者致力于一些程序来观察什么在工作,什么不工作。不要一位痛苦的混乱就本该是这样。你可以通过多加思索来改善结构。

模块设计的一个方面就是简单易用。如果你正在设计要被很多人使用的东西,或者仅仅供自己使用,三个月之后你不记得自己所写的细节时,如果你的接口简单并且可预测那么是很有帮助的。

这意味着遵循存在的规范。一个好的例子就是ini包。这个模块通过提供parsestringify(写入INI文件)函数,并且像JSON一样,在字符串和普通对象之间转换。所以接口小而熟悉,并且在和它工作一次之后就很容易记得如何使用它。

即使没有标准函数或广泛被使用的包供你模仿,你也可以通过使用简单的数据结构并且只做单一职能的东西来是你的模块可预测。例如许多INI文件解析模块提供一个函数从硬盘中直接读取这样的文件并且解析它。这使得在浏览器中不能够使用这样的模块,因为浏览器不能做直接的文件系统获取,并且增加了复杂性,如果使用一些文件读取函数来组合这些模块问题可能会很好的解决。

这指出了另一个模块设计的有益的方面,可以很容易与其他代码协同工作。与执行带有副作用的复杂行为的较大模块相比,专注计算值的程序更具有普适性。在文件内容来自其他源的时候,坚持从硬盘读取文件的INI文件阅读器是没用的。

相关地,有状态的对象有时有用,甚至是必要的,但是如果可以用函数完成某些事情,就用函数吧。NPM上的一些INI文件阅读器提供一个需要你首先创建对象的接口,然后将文件装载进你的对象中,并最终使用专门的方法去获取结果。这种类型的东西在面向对象编程中很常见,并且很糟糕。代替做单次函数调用,你不得不按照惯例的使你的对象在多种状态间游走。因为数据包装在专用的对象类型,所有的和他交互的代码必须知道那个类型,从而创造了不必要的相互依赖。

通常定义新的数据结构是不可避免地,因为只有一些基础的数据结构由语言标准提供,并且许多类型的数据要比一个数组或一个map复杂。但是当数组够用的时候,就使用数组。

一个稍微复杂的数据结构就是第七章中的图。没有一种显而易见的在JS中表示图的方式。在哪一章,我们使用了对象的属性来保存字符串数组,从那个结点可以到达的结点。

在NPM上有几种不同的寻路包,但是它们当中没有使用这种图格式的。它们通常允许图的边拥有一个权重,也就是和它关联的代价或者距离。在我们的表示中是不可能的。

例如,有一个dijkstrajs包。一个众所周知的寻路算法,非常类似于我们的findRoute函数,被叫做Dijkstra’s algorithm,以Edsger Dijkstra命名,也是首位写下这个算法的人。js后缀经常被加到包名中以表明它们是用JS书写的。这个dijkstrajs包使用一个类似于我们图格式的图格式,但是没有使用数组,它使用了对象,对象的属性值是数字也就是边的权重。

所以如果我们想要使用那个包,我们将确保我们的图存储在它期望的格式。由于我们的简化模型对待每一条路都是相同的代价所以所有边都获得相同的权重。

1
2
3
4
5
6
7
8
9
10
11
12
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"]

这可能是个创作的屏障,当多个包用不同的数据结构去描述相似的事物,将他们组合起来是困难的。因此,如果你想要为面向可组合性设计,看看别人是用什么数据结构,如果可能的话,用它们的例子。

总结

模块通过将代码分离为具有清晰接口和依赖的小块为较大的程序提供结构性。接口时模块对外可见的一部分,依赖是它所使用的其他模块。

因为JS历史上没有提供一个模块系统,CommonJS是在它上面构建的模块系统。然后在某个时刻确实有了内建的模块系统,不那么容易和CommonJS系统和谐共处。

一个包就是一块独立的可以自由分发的代码。NPM是JS包仓库。你可以从它下载各种有用的(无用的)包。

练习

一个模块化的机器人

这些是第七章项目创造的绑定:

1
2
3
4
5
6
7
8
9
10
11
roads
buildGraph
roadGraph
VillageState
runRobot
randomPick
randomRobot
mailRoute
routeRobot
findRoute
goalOrientedRobot

如果你想要将这个项目写作模块化的程序,你需要创建什么模块?哪一个模块要依赖其他的模块,它们的接口又应该是怎样的?

哪部分可能在NPM获得?你喜欢使用NPM包还是自己写一个?