JavaScript 編程精解 中文第三版 10、模塊

來源: ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目

原文:Modulesjavascript

譯者:飛龍html

協議:CC BY-NC-SA 4.0java

自豪地採用谷歌翻譯node

編寫易於刪除,而不是易於擴展的代碼。git

Tef,《Programming is Terrible》程序員

理想的程序擁有清晰的結構。 它的工做方式很容易解釋,每一個部分都起到明確的做用。github

典型的真實程序會有機地增加。 新功能隨着新需求的出現而增長。 構建和維護結構是額外的工做,只有在下一次有人蔘與該計劃時,纔會獲得回報。 因此它易於忽視,並讓程序的各個部分變得深深地糾纏在一塊兒。算法

這致使了兩個實際問題。 首先,這樣的系統難以理解。 若是一切均可以接觸到一切其它東西,那麼很難單獨觀察任何給定的片斷。 你不得不全面理解整個東西。 其次,若是你想在另外一個場景中,使用這種程序的任何功能,比起試圖從它的上下文中將它分離出來,重寫它可能要容易。apache

術語「大泥球」一般用於這種大型,無結構的程序。 一切都粘在一塊兒,當你試圖挑選出一段代碼時,整個東西就會分崩離析,你的手會變髒。npm

模塊

模塊試圖避免這些問題。 模塊是一個程序片斷,規定了它依賴的其餘部分,以及它爲其餘模塊提供的功能(它的接口)。

模塊接口與對象接口有許多共同之處,咱們在第 6 章中看到。它們向外部世界提供模塊的一部分,並使其他部分保持私有。 經過限制模塊彼此交互的方式,系統變得更像積木,其中的組件經過明肯定義的鏈接器進行交互,而不像泥漿同樣,一切都混在一塊兒。

模塊之間的關係稱爲依賴關係。 當一個模塊須要另外一個模塊的片斷時,就說它依賴於這個模塊。 當模塊中明確規定了這個事實時,它能夠用於肯定,須要哪些其餘模塊才能使用給定的模塊,並自動加載依賴關係。

爲了以這種方式分離模塊,每一個模塊須要它本身的私有做用域。

將你的 JavaScript 代碼放入不一樣的文件,不能知足這些要求。 這些文件仍然共享相同的全局命名空間。 他們能夠有意或無心干擾彼此的綁定。 依賴性結構仍不清楚。 咱們將在本章後面看到,咱們能夠作得更好。

合適的模塊結構可能難覺得程序設計。 在你還在探索這個問題的階段,嘗試不一樣的事情來看看什麼是可行的,你可能不想過多擔憂它,由於這可能讓你分心。 一旦你有一些感受可靠的東西,如今是後退一步並組織它的好時機。

從單獨的片斷中構建一個程序,並實際上可以獨立運行這些片斷的一個優勢是,你可能可以在不一樣的程序中應用相同的部分。

但如何實現呢? 假設我想在另外一個程序中使用第 9 章中的parseINI函數。 若是清楚該函數依賴什麼(在這種狀況下什麼都沒有),我能夠將全部必要的代碼複製到個人新項目中並使用它。 可是,若是我在代碼中發現錯誤,我可能會在當時正在使用的任何程序中將其修復,並忘記在其餘程序中修復它。

一旦你開始複製代碼,你很快就會發現,本身在浪費時間和精力來處處複製並使他們保持最新。

這就是包的登場時機。包是可分發(複製和安裝)的一大塊代碼。 它可能包含一個或多個模塊,而且具備關於它依賴於哪些其餘包的信息。 一個包一般還附帶說明它作什麼的文檔,以便那些不編寫它的人仍然可使用它。

在包中發現問題或添加新功能時,會將包更新。 如今依賴它的程序(也多是包)能夠升級到新版本。

以這種方式工做須要基礎設施。 咱們須要一個地方來存儲和查找包,以及一個便利方式來安裝和升級它們。 在 JavaScript 世界中,這個基礎結構由 NPM 提供。

NPM 是兩個東西:可下載(和上傳)包的在線服務,以及可幫助你安裝和管理它們的程序(與 Node.js 捆綁在一塊兒)。

在撰寫本文時,NPM 上有超過 50 萬個不一樣的包。 其中很大一部分是垃圾,我應該提一下,但幾乎全部有用的公開包均可以在那裏找到。 例如,一個 INI 文件解析器,相似於咱們在第 9 章中構建的那個,能夠在包名稱ini下找到。

第 20 章將介紹如何使用npm命令行程序在局部安裝這些包。

使優質的包可供下載是很是有價值的。 這意味着咱們一般能夠避免從新建立一百人以前寫過的程序,並在按下幾個鍵時獲得一個可靠,充分測試的實現。

軟件的複製很便宜,因此一旦有人編寫它,分發給其餘人是一個高效的過程。但首先把它寫出來是工做量,迴應在代碼中發現問題的人,或者想要提出新功能的人,是更大的工做量。

默認狀況下,你擁有你編寫的代碼的版權,其餘人只有通過你的許可才能使用它。可是由於有些人不錯,並且因爲發佈好的軟件可使你在程序員中出名,因此許多包都會在許可證下發布,明確容許其餘人使用它。

NPM 上的大多數代碼都以這種方式受權。某些許可證要求你還要在相同許可證下發布基於那個包構建的代碼。其餘要求不高,只是要求在分發代碼時保留許可證。 JavaScript 社區主要使用後一種許可證。使用其餘人的包時,請確保你留意了他們的許可證。

即興的模塊

2015 年以前,JavaScript 語言沒有內置的模塊系統。 然而,儘管人們已經用 JavaScript 構建了十多年的大型系統,他們須要模塊。

因此他們在語言之上設計了本身的模塊系統。 你可使用 JavaScript 函數建立局部做用域,並使用對象來表示模塊接口。

這是一個模塊,用於日期名稱和數字之間的轉換(由DategetDay方法返回)。 它的接口由weekDay.nameweekDay.number組成,它將局部綁定名稱隱藏在當即調用的函數表達式的做用域內。

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

用於鏈接 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的字符串翻譯爲實際的文件名或網址的方式,在不一樣系統有所不一樣。 當它以"./""../"開頭時,它一般被解釋爲相對於當前模塊的文件名。 因此"./format-date"就是在同一個目錄中,名爲format-date.js的文件。

當名稱不是相對的時,Node.js 將按照該名稱查找已安裝的包。 在本章的示例代碼中,咱們將把這些名稱解釋爲 NPM 包的引用。 咱們將在第 20 章詳細介紹如何安裝和使用 NPM 模塊。

如今,咱們不用編寫本身的 INI 文件解析器,而是使用 NPM 中的某個:

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的模塊,而沒有綁定名稱周圍的大括號,則會得到其默認綁定。 除了默認綁定以外,這些模塊仍然能夠以不一樣名稱導出其餘綁定。

爲了建立默認導出,能夠在表達式,函數聲明或類聲明以前編寫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包是一個很好的例子。 此模塊模仿標準 JSON 對象,經過提供parsestringify(用於編寫 INI 文件)函數,就像 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 包仍是本身編寫?

roads模塊

根據第 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"
];

循環依賴

循環依賴是一種狀況,其中模塊 A 依賴於 B,而且 B 也直接或間接依賴於 A。許多模塊系統徹底禁止這種狀況,由於不管你選擇何種順序來加載此類模塊,都沒法確保每一個模塊的依賴關係在它運行以前加載。

CommonJS 模塊容許有限形式的循環依賴。 只要這些模塊不會替換它們的默認exports對象,而且在完成加載以後才能訪問對方的接口,循環依賴就沒有問題。

本章前面給出的require函數支持這種類型的循環依賴。 你能看到它如何處理循環嗎? 當一個循環中的某個模塊替代其默認exports對象時,會出現什麼問題?

相關文章
相關標籤/搜索