# 十、模塊
> 原文:[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》

理想的程序擁有清晰的結構。 它的工作方式很容易解釋,每個部分都起到明確的作用。
典型的真實程序會有機地增長。 新功能隨著新需求的出現而增加。 構建和維護結構是額外的工作,只有在下一次有人參與該計劃時,才會得到回報。 所以它易于忽視,并讓程序的各個部分變得深深地糾纏在一起。
這導致了兩個實際問題。 首先,這樣的系統難以理解。 如果一切都可以接觸到一切其它東西,那么很難單獨觀察任何給定的片段。 你不得不全面理解整個東西。 其次,如果你想在另一個場景中,使用這種程序的任何功能,比起試圖從它的上下文中將它分離出來,重寫它可能要容易。
術語“大泥球”通常用于這種大型,無結構的程序。 一切都粘在一起,當你試圖挑選出一段代碼時,整個東西就會分崩離析,你的手會變臟。
## 模塊
模塊試圖避免這些問題。 模塊是一個程序片段,規定了它依賴的其他部分,以及它為其他模塊提供的功能(它的接口)。
模塊接口與對象接口有許多共同之處,我們在第 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`對象時,會出現什么問題?