- 原文地址:ES modules: A cartoon deep-dive
- 原文作者:Lin Clark
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:stormluke
- 校對者:Starrier、zephyrJS
ES 模組為 JavaScript 提供了官方標準化的模組系統。然而,這中間經歷了一些時間 —— 近 10 年的標準化工作。
但等待已接近尾聲。隨著 5 月份 Firefox 60 釋出(目前為 beta 版),所有主流瀏覽器都會支援 ES 模組,並且 Node 模組工作組也正努力在 Node.js 中增加 ES 模組支援。同時用於 WebAssembly 的 ES 模組整合 也在進行中。
許多 JavaScript 開發人員都知道 ES 模組一直存在爭議。但很少有人真正瞭解 ES 模組的執行原理。
讓我們來看看 ES 模組能解決什麼問題,以及它們與其他模組系統中的模組有什麼不同。
模組要解決什麼問題?
可以這樣說,JavaScript 程式設計就是管理變數。所做的事就是為變數賦值,或者在變數上做加法,或者將兩個變數組合在一起並放入另一個變數中。
因為你的程式碼中很多都是關於改變變數的,你如何組織這些變數會對你編碼方式以及程式碼的可維護性產生很大的影響。
一次只需要考慮幾個變數就可以讓事情變得更簡單。JavaScript 有一種方法可以幫助你做到這點,稱為作用域。由於 JavaScript 中的作用域規則,一個函式無法訪問在其他函式中定義的變數。
這很好。這意味著當你寫一個函式時,只需關注這個函式本身。你不必擔心其他函式可能會對函式內的變數做些什麼。
儘管如此,它仍然存在缺陷。這讓在函式間共享變數變得有點困難。
如果你想在作用域外共享變數呢?處理這個問題的一種常見方法是將它放在更外層的作用域裡……例如,在全域性作用域中。
你可能還記得 jQuery 時代的這種情況。在載入任何 jQuery 外掛之前,你必須確保 jQuery 在全域性作用域中。
這在有效的同時也產生了副作用。
首先,所有的 script 標籤都需要按照正確的順序排列。所以你必須小心確保那個順序沒被打亂。
如果你搞亂了這個順序,那麼在執行的過程中,你的應用程式就會丟擲一個錯誤。當函式尋找它期望的 jQuery 時 —— 在全域性作用域裡 —— 卻沒有找到它,它會丟擲一個錯誤並停止執行。
這使得維護程式碼非常棘手。這讓移除老程式碼或老 script 標籤變成了一場輪盤賭遊戲。你不知道會弄壞什麼。程式碼的不同部分之間的依賴關係是隱式的。任何函式都可以獲取全域性作用域中的任何東西,所以你不知道哪些函式依賴於哪些 script 標籤。
第二個問題是,因為這些變數位於全域性範圍內,所以全域性範圍內的程式碼的每個部分都可以更改該變數。惡意程式碼可能會故意更改該變數,以使你的程式碼執行某些你並不想要的操作,或者非惡意程式碼可能會意外地弄亂你的變數。
模組是如何提供幫助的?
模組為你提供了更好的方法來組織這些變數和函式。通過模組,你可以將有意義的變數和函式分組在一起。
這會將這些函式和變數放入模組作用域。模組作用域可用於在模組中的函式之間共享變數。
但是與函式作用域不同,模組作用域也可以將其變數提供給其他模組。它們可以明確說明模組中的哪些變數、類或函式應該共享。
當將某些東西提供給其他模組時,稱為 export。一旦你宣告瞭一個 export,其他模組就可以明確地說它們依賴於該變數、類或函式。
因為這是顯式的關係,所以當刪除了某個模組時,你可以確定哪些模組會出問題。
一旦你能夠在模組之間匯出和匯入變數,就可以更容易地將程式碼分解為可獨立工作的小塊。然後,你可以組合或重組這些程式碼塊(像樂高一樣),從同一組模組建立出各種不同的應用程式。
由於模組非常有用,歷史上有多次向 JavaScript 新增模組功能的嘗試。如今有兩個模組系統正在大範圍地使用。CommonJS(CJS)是 Node.js 歷史上使用的。ESM(EcmaScript 模組)是一個更新的系統,已被新增到 JavaScript 規範中。瀏覽器已經支援了 ES 模組,並且 Node 也正在新增支援。
讓我們來深入瞭解這個新模組系統的工作原理。
ES 模組如何工作
使用模組開發時,會建立一個依賴圖。不同依賴項之間的連線來自你使用的各種 import 語句。
瀏覽器或者 Node 通過 import 語句來確定需要載入什麼程式碼。你給它一個檔案來作為依賴圖的入口。之後它會隨著 import 語句來找到所有剩餘的程式碼。
但瀏覽器並不能直接使用檔案本身。它需要把這些檔案解析成一種叫做模組記錄(Module Records)的資料結構。這樣它就知道了檔案中到底發生了什麼。
之後,模組記錄需要轉化為模組例項(module instance)。一個例項包含兩個部分:程式碼和狀態。
程式碼基本上是一組指令。就像是一個告訴你如何製作某些東西的配方。但你僅依靠程式碼並不能做任何事情。你需要將原材料和這些指令組合起來使用。
什麼是狀態?狀態就是給你這些原材料的東西。指令是所有變數在任何時間的實際值的集合。當然,這些變數只是記憶體中儲存值的資料塊的名稱而已。
所以模組例項將程式碼(指令列表)和狀態(所有變數的值)組合在一起。
我們需要的是每個模組的模組例項。模組載入就是從此入口檔案開始,生成包含全部模組例項的依賴圖的過程。
對於 ES 模組來說,這主要有三個步驟:
- 構造 —— 查詢、下載並解析所有檔案到模組記錄中。
- 例項化 —— 在記憶體中尋找一塊區域來儲存所有匯出的變數(但還沒有填充值)。然後讓 export 和 import 都指向這些記憶體塊。這個過程叫做連結(linking)。
- 求值 —— 執行程式碼,在記憶體塊中填入變數的實際值。
人們說 ES 模組是非同步的。你可以把它當作時非同步的,因為整個過程被分為了三階段 —— 載入、例項化和求值 —— 這三個階段可以分開完成。
這意味著 ES 規範確實引入了一種在 CommonJS 中並不存在的非同步性。我稍後會再解釋,但是在 CJS 中,一個模組和其下的所有依賴會一次性完成載入、例項化和求值,中間沒有任何中斷。
當然,這些步驟本身並不必須是非同步的。它們可以以同步的方式完成。這取決於誰在做載入這個過程。這是因為 ES 模組規範並沒有控制所有的事情。實際上有兩部分工作,這些工作分別由不同的規範控制。
ES模組規範說明了如何將檔案解析到模組記錄,以及如何例項化和求值該模組。但是,它並沒有說明如何獲取檔案。
是載入器來獲取檔案。載入器在另一個不同的規範中定義。對於瀏覽器來說,這個規範是 HTML 規範。但是你可以根據所使用的平臺有不同的載入器。
載入器還精確控制模組的載入方式。它呼叫 ES 模組的方法 —— ParseModule
、Module.Instantiate
和 Module.Evaluate
。這有點像通過提線來控制 JS 引擎這個木偶。
現在讓我們更詳細地介紹每一步。
構造
在構造階段,每個模組都會經歷三件事情。
- 找出從哪裡下載包含該模組的檔案(也稱為模組解析)
- 獲取檔案(從 URL 下載或從檔案系統載入)
- 將檔案解析為模組記錄
查詢檔案並獲取
載入器將負責查詢檔案並下載它。首先它需要找到入口檔案。在 HTML 中,你通過使用 script 標記來告訴載入器在哪裡找到它。
但它如何找到剩下的一堆模組 —— 那些 main.js
直接依賴的模組?
這就要用到 import 語句了。import 語句中的一部分稱為模組識別符號。它告訴載入器哪裡可以找到餘下的模組。
關於模組識別符號有一點需要注意:它們有時需要在瀏覽器和 Node 之間進行不同的處理。每個宿主都有自己的解釋模組識別符號字串的方式。要做到這一點,它使用了一種稱為模組解析的演算法,它在不同平臺之間有所不同。目前,在 Node 中可用的一些模組識別符號在瀏覽器中不起作用,但這個問題正在被修復。
在修復之前,瀏覽器只接受 URL 作為模組識別符號。它們將從該 URL 載入模組檔案。但是,這並不是在整個依賴圖上同時發生的。在解析檔案前,並不知道這個檔案中的模組需要再獲取哪些依賴……並且在獲取檔案之前無法解析那個檔案。
這意味著我們必須逐層遍歷依賴樹,解析一個檔案,然後找出它的依賴關係,然後查詢並載入這些依賴。
如果主執行緒要等待這些檔案的下載,那麼很多其他任務將堆積在佇列中。
這是就是為什麼當你使用瀏覽器時,下載部分需要很長時間。
基於此圖表。
像這樣阻塞主執行緒會讓採用了模組的應用程式速度太慢而無法使用。這是 ES 模組規範將演算法分為多個階段的原因之一。將構造過程單獨分離出來,使得瀏覽器在執行同步的初始化過程前可以自行下載檔案並建立自己對於模組圖的理解。
這種方法 —— 將演算法分解成不同階段 —— 是 ES 模組和 CommonJS 模組之間的主要區別之一。
CommonJS 可以以不同的方式處理的原因是,從檔案系統載入檔案比在 Internet 上下載需要少得多的時間。這意味著 Node 可以在載入檔案時阻塞主執行緒。而且既然檔案已經載入了,直接例項化和求值(在 CommonJS 中並不區分這兩個階段)就理所當然了。這也意味著在返回模組例項之前,你遍歷了整棵樹,載入、例項化和求值了所有依賴關係。
CommonJS 方法有一些隱式特性,稍後我會解釋。其中一個是,在使用 CommonJS 模組的 Node 中,可以在模組識別符號中使用變數。在查詢下一個模組之前,你執行了此模組中的所有程式碼(直至 require
語句)。這意味著當你去做模組解析時,變數會有值。
但是對於 ES 模組,在進行任何求值之前,你需要事先構建整個模組圖。這意味著你的模組識別符號中不能有變數,因為這些變數還沒有值。
但有時候在模組路徑使用變數確實非常有用。例如,你可能需要根據程式碼的執行情況或執行環境來切換載入某個模組。
為了讓 ES 模組支援這個,有一個名為 動態匯入 的提案。有了它,你可以像 import(`${path}`/foo.js
這樣使用 import 語句。
它的原理是,任何通過 import()
載入的的檔案都會被作為一個獨立的依賴圖的入口。動態匯入的模組開啟一個新的依賴圖,並單獨處理。
有一點需要注意,同時存在於這兩個依賴圖中的模組都將共享同一個模組例項。這是因為載入器會快取模組例項。對於特定全域性作用域中的每個模組,都將只有一個模組例項。
這意味著引擎的工作量減少了。例如,這意味著即使多個模組依賴某個模組,這個模組的檔案也只會被獲取一次。(這是快取模組的一個原因,我們將在求值部分看到另一個。)
載入器使用一種叫做模組對映 的東西來管理這個快取。每個全域性作用域都在一個單獨的模組對映中跟蹤其模組。
當載入器開始獲取一個 URL 時,它會將該 URL 放入模組對映中,並標記上它正在獲取檔案。然後它會發出請求並繼續開始獲取下一個檔案。
如果另一個模組依賴於同一個檔案會發生什麼?載入器將查詢模組對映中的每個 URL。如果看到了 fetching
,它就會直接開始下一個 URL。
但是模組對映不只是跟蹤哪些檔案正在被獲取。模組對映也可以作為模組的快取,接下來我們就會看到。
解析
現在我們已經獲取了這個檔案,我們需要將它解析為模組記錄。這有助於瀏覽器瞭解模組的不同部分。
一旦模組記錄被建立,它會被記錄在模組對映中。這意味著在這之後的任意時間如果有對它的請求,載入器就可以從對映中獲取它。
解析中有一個細節可能看起來微不足道,但實際上有很大的影響。所有的模組都被當作在頂部使用了 "use strict"
來解析。還有一些其他細微差別。例如,關鍵字 await
保留在模組的頂層程式碼中,this
的值是 undefined
。
這種不同的解析方式被稱為「解析目標」。如果你使用不同的目標解析相同的檔案,你會得到不同的結果。所以在開始解析你想知道正在解析的檔案的型別 —— 它是否是一個模組。
在瀏覽器中這很容易。你只需在 script 標記中設定 type="module"
。這告訴瀏覽器此檔案應該被解析為一個模組。另外由於只有模組可以被匯入,瀏覽器也就知道任何匯入的都是模組。
但是在 Node 中,不使用 HTML 標籤,所以沒法選擇使用 type
屬性。社群試圖解決這個問題的一種方法是使用 .mjs
副檔名。使用該副檔名告訴 Node「這個檔案是一個模組」。你會看到人們將這個叫做解析目標的訊號。討論仍在進行中,所以目前還不清楚 Node 社群最終會決定使用什麼訊號。
無論哪種方式,載入器會決定是否將檔案解析為模組。如果是一個模組並且有匯入,則載入器將再次啟動該過程,直到獲取並解析了所有的檔案。
我們完成了!在載入過程結束時,從只有一個入口檔案變成了一堆模組記錄。
下一步是例項化此模組並將所有例項連結在一起。
例項化
就像我之前提到的,例項將程式碼和狀態結合起來。狀態存在於記憶體中,因此例項化步驟就是將內容連線到記憶體。
首先,JS 引擎建立一個模組環境記錄(module environment record)。它管理模組記錄對應的變數。然後它為所有的 export 分配記憶體空間。模組環境記錄會跟蹤不同記憶體區域與不同 export 間的關聯關係。
這些記憶體區域還沒有被賦值。只有在求值之後它們才會獲得真正的值。這條規則有一點需要注意:任何 export 的函式宣告都在這個階段初始化。這讓求值更加容易。
為了例項化模組圖,引擎將執行所謂的深度優先後序遍歷。這意味著它會深入到模組圖的底部 —— 直到不依賴於其他任何東西的底部 —— 並處理它們的 export。
引擎將某個模組下的所有匯出都連線好 —— 也就是這個模組所依賴的所有匯出。之後它回溯到上一層來連線該模組的所有匯入。
請注意,匯出和匯入都指向記憶體中的同一個區域。先連線匯出保證了所有的匯出都可以被連線到對應的匯入上。
這與 CommonJS 模組不同。在 CommonJS 中,整個 export 物件在 export 時被複制。這意味著 export 的任何值(如數字)都是副本。
這意味著如果匯出模組稍後更改該值,則匯入模組並不會看到該更改。
相比之下,ES 模組使用叫做動態繫結(live bindings)的東西。兩個模組都指向記憶體中的相同位置。這意味著當匯出模組更改一個值時,該更改將反映在匯入模組中。
匯出值的模組可以隨時更改這些值,但匯入模組不能更改其匯入的值。但是,如果一個模組匯入一個物件,它可以改變該物件上的屬性值。
之所以使用動態繫結,是因為這樣你就可以連線所有模組而不需要執行任何程式碼。這有助於迴圈依賴存在時的求值,我會在下面解釋。
因此,在此步驟結束時,我們將所有例項和匯出 / 匯入變數的記憶體位置連線了起來。
現在我們可以開始求值程式碼並用它們的值填充這些記憶體位置。
求值
最後一步是在記憶體中填值。JS 引擎通過執行頂層程式碼 —— 函式之外的程式碼來實現這一點。
除了在記憶體中填值,求值程式碼也會引發副作用。例如,一個模組可能會請求伺服器。
由於潛在的副作用,你只想對模組求值一次。對於例項化中發生的連結過程,多次連結會得到相同的結果,但與此不同的是,求值結果可能會隨著求值次數的不同而變化。
這是需要模組對映的原因之一。模組對映通過規範 URL 來快取模組,所以每個模組只有一個模組記錄。這確保了每個模組只會被執行一次。就像例項化一樣,這會通過深度優先後序遍歷完成。
那些我們之前談過的迴圈依賴呢?
如果有迴圈依賴,那最終會在依賴圖中產生一個迴圈。通常,會有一個很長的迴圈路徑。但為了解釋這個問題,我打算用一個短迴圈的人為的例子。
讓我們看看 CommonJS 模組如何處理這個問題。首先,main 模組會執行到 require 語句。然後它會去載入 counter 模組。
然後 counter 模組會嘗試從匯出物件訪問 message
。但是,由於這尚未在 main 模組中進行求值,因此將返回 undefined。JS 引擎將為區域性變數分配記憶體空間並將值設定為 undefined。
求值過程繼續,直到 counter 模組頂層程式碼的結尾。我們想看看最終是否會得到正確的 message 值(在 main.js 求值之後),因此我們設定了 timeout。之後在 main.js
上繼續求值。
message 變數將被初始化並新增到記憶體中。但是由於兩者之間沒有連線,它將在 counter 模組中保持 undefined。
如果使用動態繫結處理匯出,則 counter 模組最終會看到正確的值。在 timeout 執行時,main.js
的求值已經結束並填充了該值。
支援這些迴圈依賴是 ES 模組設計背後的一大緣由。正是這種三段式設計使其成為可能。
ES 模組的現狀如何?
隨著 5 月初會發布的 Firefox 60,所有主流瀏覽器均預設支援 ES 模組。Node 也增加了支援,一個工作組正致力於解決 CommonJS 和 ES 模組之間的相容性問題。
這意味著你可以在 script 標記中使用 type=module
,並使用 import 和 export。但是,更多模組特性尚未實現。動態匯入提議正處於規範過程的第 3 階段,有助於支援 Node.js 用例的 import.meta 也一樣,模組解析提議也將有助於抹平瀏覽器和 Node.js 之間的差異。所以我們可以期待將來的模組支援會更好。
致謝
感謝所有對這篇文章給予反饋意見,或者通過書面和討論提供資訊的人,包括 Axel Rauschmayer、Bradley Farias、Dave Herman、Domenic Denicola、Havi Hoffman、Jason Weathersby、JF Bastien、Jon Coppeard、Luke Wagner、Myles Borins、Till Schneidereit、Tobias Koppers 和 Yehuda Katz,也感謝 WebAssembly 社群組、Node 模組工作組和 TC39 的成員們。
關於 Lin Clark
Lin 是 Mozilla 開發者關係組的一名工程師。她研究 JavaScript、WebAssembly、Rust 和 Servo,也畫過一些程式碼漫畫。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。