1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-24 04:22:20 +00:00
wizardforcel 89b1e6a5e5 10.
2018-05-08 22:18:01 +08:00

244 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 十、模块
> 编写易于删除,而不是易于扩展的代码。
> Tef《Programming is Terrible》
理想的程序拥有清晰的结构。 它的工作方式很容易解释,每个部分都起到明确的作用。
典型的真实程序会有机地增长。 新功能随着新需求的出现而增加。 构建和维护结构是额外的工作,只有在下一次有人参与该计划时,才会得到回报。 所以它易于忽视,并让程序的各个部分变得深深地纠缠在一起。
这导致了两个实际问题。 首先,这样的系统难以理解。 如果一切都可以接触到一切其它东西,那么很难单独观察任何给定的片段。 你不得不全面理解整个东西。 其次,如果你想在另一个场景中,使用这种程序的任何功能,比起试图从它的上下文中将它分离出来,重写它可能要容易。
术语“大泥球”通常用于这种大型,无结构的程序。 一切都粘在一起,当你试图挑选出一段代码时,整个东西就会分崩离析,你的手会变脏。
## 模块
模块试图避免这些问题。 模块是一个程序片段,规定了它依赖的其他部分,以及它为其他模块提供的功能(它的接口)。
模块接口与对象接口有许多共同之处,我们在第 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 代码通常不是编写的代码。