【翻譯】ES modules:通過漫畫進行深入理解

原文:ES modules: A cartoon deep-dive

ES modules 給 JavaScript 帶來了一個官方的規範的模塊化系統。將近花了10年的時間才完成了這個標準化的工作。

我們的等待即將結束。隨著 Firefox 60 在今年5月的發布(目前是測試階段),所有的主流瀏覽器都將支持 ES modules,與此同時,Node modules 工作小組目前正在嘗試讓 Node.js 能夠支持 ES module。另外的,針對 WebAssembly 的 ES module 整合也正在進行。

眾多 JS 開發者都知道 ES modules 至今已經飽受爭議。但是很少有人真正知道 ES modules 到底是如何工作的。

讓我們一起來看一下,ES modules 解決了什麼問題,以及它究竟和其他模塊化系統有什麼區別。

模塊化解決了什麼問題?

當我們在寫 JS 代碼的時候會去思考一般如何處理變數。我們的操作幾乎完全是為了給變數進行賦值或者是去將兩個變數相加又或者是去將兩個變數連接到一起並且將它們賦值給另外一個變數。

由於我們大部分的代碼都僅僅是為了去改變變數的值,你如何去組織這些變數將對你寫出怎樣的代碼以及如何更好的去維護這些代碼產生巨大的影響。

一次只用處理少量的變數將會讓我們的工作更容易。JS 本身提供了一種方法去幫助我們這麼做,叫作 作用域。由於 JS 中作用域的原因,在每個函數中不能去使用其他函數中定義的變數。

這很棒!這意味著當你在一個函數中編碼時,你只需要考慮當前這個函數了。你不必再擔心其他函數可能會對你的變數做什麼了。

雖然是這樣沒錯,但是它也有不好的地方。這會讓你很難去在不同的函數之間去共享變數。

假使你確實想要在作用域外去共享你的變數,將會怎麼樣呢?一個常用的做法是去將它們放在一個外層的作用域。舉個例子來說,全局作用域。

你可能還記得下面這個在 jQuery 中的操作。在你載入 jQuery 之前,你不得不去把 jQuery 引入到全局作用域。

ok,可以正常運行了。但是這裡存在相同的有爭議的問題。

首先,你的 script 標籤需要按正確的順序擺放。然後你不得不非常的謹慎去確認沒有人會去改變這個順序。

如果你搞砸了這個順序,然後你中間又使用到了前面的依賴,你的應用將會拋出一個錯誤~你函數將會四處查找 jQuery 在哪兒呢?在全局嗎?然後,並沒有找到它,它將會拋出一個錯誤然後你的應用就掛掉了。

這將會讓你的代碼維護變得非常困難。這會使你在刪除代碼或者刪除 script 標籤的時候就像在賭博一樣。你並不知道這個什麼時候會崩潰。不同代碼之間的依賴關係也不夠明顯。任何的函數都能夠使用在全局的東西,所以你不知道哪些函數會依賴哪些 script 文件。

第二個問題是因為這些變數存在於全局作用域,所有的代碼都存在於全局作用域內,並且可以去修改這些變數。可能是去讓這些變數變成惡意代碼,從而故意執行非你本意的代碼,還有可能是變成非惡意的代碼但是和你的變數有衝突。

模塊化是如何幫助我們的?

模塊化給你了一個方式去組織這些變數和函數。通過模塊化,你可以把變數和函數合理的進行分組歸類。

它把這些函數和變數放在一個模塊的作用域內。這個模塊的作用域能夠讓其中的函數一起分享變數。

但是不像函數的作用域,模塊的作用域有一種方式去讓它們的變數能過被其他模塊所用。它們能夠明確的安排其中哪些變數、類或者函數可以被其他模塊使用。

當某些東西被設置成能被其他模塊使用的時候,我需要一個叫做 export 的函數。一旦你使用了這個 export 函數,其他的模塊就明確的知道它們依賴於哪些變數、類或者函數。

因為這是一個明確的關係。一旦你想移除一個模塊時,你可以知道哪一個模塊將會被影響。

當你能夠去使用 export 和 import 去處理不同模塊之間的變數時,你將會很容易的將你的代碼分成一些小的部分,它們之間彼此獨立的運行。然後你可以組合或者重組這些部分,就像樂高積木一樣,去在不同的應用中引用這些公用的模塊。

由於模塊化真的非常有用,所以這裡有很多嘗試去在 JS 中添加一些實用的模塊。時至今日,有兩個比較常用的模塊化系統。一個是 Node.js 一直以來使用的 CommonJS。還有一個是晚一些但是專門為 JS 設計的 ES modules。瀏覽器端已經支持 ES modules,與此同時,Node 端正在嘗試去支持。

讓我們一起來深入了解一下,這個新的模塊化系統到底是如何進行工作的。

ES modules 是如何工作的?

當你在開發這些模塊時,你建立了一個圖。

瀏覽器或者 Node 是通過這些引入聲明,才明確的知道你需要載入哪些代碼。你需要創建一個文件作為這個依賴關係的入口。之後就會根據那些 import 聲明去查找剩餘的代碼。

但是這些文件不能直接被瀏覽器所用,這些文件會被解析成叫做模塊記錄的數據結構。

之後,這個模塊記錄將會被轉變成一個模塊實例。一個模塊實例是由兩部分組成:代碼和狀態。

代碼是這一列指令的基礎。它就像該如何去做的引導。但是只憑它你並不能做什麼。你需要材料才能夠去使用這些引導。

什麼是狀態?狀態給你提供了材料!在任何時候,狀態都會為你提供這些變數真實的值。當然這些變數都僅僅只是作為內存中存儲這些值的別名而已(引用)。

模塊實例將代碼(一系列的引導)和狀態組合起來(所有變數在內存中的值)。

我們需要的是每個模塊擁有自己的模塊實例。模塊的載入過程是通過入口文件,找到整個模塊實例的關係表。

對於 ES modules 來說,這個過程需要三步:

  1. 構建——查找、下載以及將所有文件解析進入模塊記錄。
  2. 實例化——查找暴露出的值應該放在內存中的哪個位置(但是不會給它們填充值),然後在內存中創建 exports 和 imports 應該存在的地方。這被稱作鏈接。
  3. 求值——運行代碼,把內存中的變數賦予真實的值。

人們都說 ES modules 是非同步的。你完全可以將它想成非同步的,因為整個流程被分成三個不同的階段——載入,實例化以及求值——還有,這些步驟都是被分開執行的。

這就意味著,這個規則是一種非同步的而且不從屬於 CommonJS。我將在稍後解釋它,在 CommonJS 中,一個模塊的依賴是在模塊載入之後才立刻進行載入、實例化、求值的,中間不會有任何的打斷(也就是同步)。

無論如何,這些步驟本身並不一定是非同步的。它們可以被同步處理。這就依賴於載入的過程取決於什麼?那是因為並不是所有的東西都尊崇於 ES modules 規範。這其實是兩部分工作,從屬於不同的規範。

ES module 規範闡述了你應該如何將這些文件解析成模塊記錄,以及你應該如何去實例化和進行求值。但是,它沒有說明如何去首先獲得這些文件。

獲取這些文件有相應的載入器,在不同的說明中,載入器都被明確定義了。對於瀏覽器,它的規範是HTML spec。但是你可以在不同平台使用不同的載入器。

載入器同樣明確指出了控制模塊應該如何被載入。這被稱作 ES 模塊方法 —— ParseModule,Module.Instantiate,以及Module.Evaluate。這就像JS 引擎操縱的木偶一樣。

現在我們來一起探尋每一步到底發生了什麼。

構建

構建階段每一個模塊發生了三件事。

  1. 判斷應該從何處下載文件所包含的模塊(又叫模塊解決方案)。
  2. 獲取文件(通過 url 下載 或者 通過文件系統載入)
  3. 將文件解析進模塊記錄

查找到文件然後獲取到它

載入器將會儘可能的去找到文件然後去下載它。首先要去找到入口文件。在 HTML 中,你應該通過 script 標籤告訴載入器入口文件在哪。

但是你應該如何查找到下一個模塊化文件呢——那些 main.js 直接依賴的模塊?

這個時候 import 聲明就登場了,import 聲明中有一部分叫做模塊聲明,它告訴了載入器可以在依次找到下一個模塊。

關於模塊聲明有一點需要注意的是:在瀏覽器端和 Node 端有不同的處理方式。每一個宿主環境有它自己的方法去解釋用來模塊聲明的字元串。為了完成這個,模塊聲明使用了一種叫做模塊解釋的演算法去區分不同的宿主環境。目前來說,一些能在 Node 端運行的模塊聲明方法並不能在瀏覽器端執行,但是我們有為了修復這個而在做的事情。

除非等到這個問題被修復,瀏覽器只能接受 URLs 作為模塊聲明。它們將從這個 URL 去載入這個模塊文件。但是對於整個圖而言,這並不是一個同步行為。你無法知道哪一個依賴你需要去獲取直到你把整個文件都解析完成。以及你只有等獲取到文件才能開始解析它。

這就意味著我們必須去解析這個文件通過一層一層的解析這個依賴關係。然後查明所有的依賴關係,最後找到並且載入這些依賴。

如果主線程在等待每一個文件下載,那麼其他的任務將會排在主線程事件隊列的後面。

持續的阻塞主線程就會像這樣讓你的應用在使用這些模塊時變得非常的慢。這就是 ES modules 規範將這個演算法拆分到多個階段任務的原因之一。在進行實例化之前把它的構建拆分到它自己的階段然後允許瀏覽器去獲取文件和理清依賴關係表。

ES modules 和 CommonJS modules 之間的區別之一就是將模塊聲明演算法拆分到各個階段去執行。

CommonJS 能夠比 ES modules 的不同是,通過文件系統去載入文件,要比從網上下載文件要花的時間少得多。這就意味著,Node 將會阻塞主線程當它正在載入文件的時候。只要文件載入完成,它就會去實例化並且去做求值操作(這也就是 CommonJS 不會在各個獨立階段去做的原因)。這同樣說明了,當你在返回模塊實例之前,你就會遍歷整個依賴關係樹然後去完成載入、實例化以及對各個依賴進行求值的操作。

CommonJS 帶來的一些影響,我會在稍後做更多的解釋。在使用 CommonJS 的 Node 中你可以去使用變數進行模塊聲明。在你查找下一個模塊之前,你將執行完這個模塊所有的代碼(直到通過require去返回這個聲明)。這就意味著你的這些變數將會在你去處理模塊解析時被賦值。

但是在 ES modules 中,你將在執行模塊解析和進行求值操作前就建立好整個模塊依賴關係圖表。這也就是說在你的模塊聲明時,你不能去使用這些變數,因為這些變數那時還並沒有被賦值。

但是有的時候我們有非常需要去使用變數作為模塊聲明,舉個例子,你可能會存在的一種情況是需要根據代碼的執行效果來決定你需要引入哪個模塊。

為了能在 ES modules 這麼去做,於是就存在一種叫做動態引入的提議。就像這樣,你可以像這樣去做引入聲明import(`${path}/foo.js`)

這種通過import()去載入任意文件的方法是把它作為每一個單獨的依賴圖表的入口。這種動態引入模塊會開始一個新的被單獨處理的圖。

即使如此,有一點要注意的是,對於任意模塊而言所有的這些圖都共享同一個模塊實例。這是因為載入器會緩存這些模塊實例。對於每一個模塊而言都存在於一個特殊的作用域內,這裡面僅僅只會存在一個模塊實例。

顯然,這會減少引擎的工作量。舉個例子,目標模塊文件只會被載入一次即使此時有多個模塊文件都依賴於它。(這就是緩存模塊的原因,我們將看到的只是另一次的求值過程而已)

載入器是通過一個叫做模塊映射集合的東西來管理這個緩存。每一個全局作用域通過棧來保存這些獨立的模塊映射集合。

當載入器準備去獲取一個 URL 的時候,它會將這個 URL 放入模塊映射中,然後對當前正在獲取的文件做一個標記。然後它將發送一個請求(狀態為 fetching),緊接著開始準備開始獲取下一個文件。

那當其他模塊也依賴這個同樣的文件時會發生什麼呢?載入器將會在模塊映射集合中去遍歷這個 URL,如果它發現這個文件正在被獲取,那麼載入器會直接查找下一個 URL 去。

但是模塊映射集合併不會去保存已經被獲取過的文件的棧。接下來我們會看到,模塊映射集合對於模塊而言同樣也會被作為一個緩存。

解析

現在我們已經獲取到了這個文件,我們需要將它解析為一條模塊記錄。這會幫助瀏覽器知道這些模塊不一樣的部分。

一旦這條模塊記錄被創建,它將會被放置到模塊映射集合內。這就意味著,無論何時它在這被請求,載入器都會從映射集合中錄取它。

在編譯過程中有一個看似微不足道的細節,但是它卻有著重大的影響。所有的模塊被解析後都會被當做在頂部有use strict。還有另外兩個細節。用例子來說明吧,await關鍵詞會被預先儲備到模塊代碼的最頂部,以及頂級作用域中thisundefined

這種不同的解析方式被稱作「解析目標」。如果你解析相同的文件,但是目標不同,你將會得到不同的結果。因此,在開始解析你要解析的文件類型之前,你需要知道它是否是一個模塊。

在瀏覽器中,這將非常的簡單,你只需要在 script 標籤中設置type="module"。這就會高速瀏覽器,這個文件將被當做模塊進行解析。以及只有模塊才能被引用,瀏覽器知道任意引入都是模塊。

但是在 Node 端,你不會使用到 HTML 標籤,所以你沒辦法去使用type屬性。社區為此想出了一個解決辦法,對於這類文件使用了mjs的擴展名。通過這個擴展名告訴 Node,「這是一個模塊」。你可以看出人們把這個視為解析目標的信號。這個討論仍在進行中,現在還不清楚最後 Node 社區會採用哪種信號。

無論哪種方式,載入器將會決定是否將一個文件當做模塊去處理。如果這是一個模塊並且存在引用,那麼它將會再次進行剛才的過程,直到所有的文件都被獲取到,解析完。

下一步就是將這個模塊實例化並且將所有的實例鏈接起來。

實例化

就像我之前所說的,一個實例是由代碼和狀態結合起來的。狀態存在於內存中,所以實例化的步驟其實是將所有的內容連接到內存中。

首先,JS 引擎會創建一條模塊環境的記錄。它會為這條模塊記錄管理變數。然後它在內存中的相關區域找到所有導出的值。這條模塊環境記錄將會跟蹤內存中與每個導出相關聯的區域。

直到進行求值操作的時候這些內存區域才會被填充真實的值。對於這個規則,有一條警告:所有被導出的函數聲明將會在這個階段被初始化。這將會讓求值過程變得更容易。

在實例化模塊的過程,引擎將會採用深度優先後續遍歷的演算法。意思就是引擎一直往下直到圖的最底部——也就是依賴關係的最底部(不依賴於其它了),然後才會去設置它們的導出值。

引擎完成了這個模塊下所有導出的串聯——模塊依賴的所有導出。然後它就會返回頂部然後將這個模塊所有的引入串聯起來。

要注意的是導出和引入在內存中同一塊區域。將所有導出都串聯起來的前提是保證所有的引用能和與它對應的導出匹配(譯者註:這也說明了 ES mdules 中的 import 屬於引用)。

這不同於 CommonJS 的模塊化。在 CommonJS 中整個導出的對象是導出的一個複製。這就意味著,所有的值(比方說數字)都是導出值的複製。

這同時也說明,導出的模塊如果在之後發生改變,那個引入該模塊的模塊並不會發現這個改變。

與此完全相反的是,ES modules 使用的是活躍綁定,所有的模塊引入和導出的全是指向相同的內存區域。意思就是說,一旦當模塊被導出的值發生了改變,那麼引入該模塊的模塊也會受到影響。

模塊本身可以對導出的值做出變化,但是去引入它們的模塊禁止去對這些值進行修改。話雖如此,但是如果一個模塊引入的是一個對象,是可以去修改這個對象上的值的。

使用活躍綁定的原因是,你可以將所有的模塊串聯起來,而不需要執行任何的代碼。這將有助於你去使用我接下來要講的循環依賴。

在這一步的最後,我們已經成功對模塊進行了實例化並且將內存中引入和導出的值串聯起來。

現在,我們可以開始對代碼進行求值並且給它們在內存中的值進行賦值。

求值操作

最後一步是對內存中的相關區域進行填充。JS 引擎是通過執行頂層代碼去完成這件事的——在函數外的代碼。

除了對內存中相關進行填充外,對代碼進行求值也會造成副作用。比如說,模塊可能會去調用一個服務。

由於潛在的副作用,你只需要對模塊進行一次求值。與發生實例化時產生的鏈接不同,在這裡相同的結果可以被多次使用。求值的結果也會隨著你求值次數的不同而產生不同的結果。

這就是我們去使用模塊映射集合的原因。模塊映射集合緩存規範的 URL ,所以每一個模塊只存在一條對應的模塊記錄。這就保證了每一個模塊只被執行一次。和實例化的過程一樣,它同樣採用的是深度優先後序遍歷的方法。

那麼,我們之前談到的循環依賴呢?

在循環依賴中,你最終在圖中是一個循環。通常來說,這是一個比較長的循環。但是為了去解釋這個問題,我將只會人為的去設計一個較短的循環去舉個例子。

讓我們來看看在 CommonJS 的模塊中是如何做的,首先,那個 main 模塊會執行 require 聲明。然後就去載入 counter 模塊。

這個 counter 模塊將會從導出的模塊中去嘗試獲取 message,但是它在 main 模塊中還並沒有被求值,於是它會返回 undefined。JS 引擎將會在內存中為它分配一個空間,然後將其賦值為 undefined。

求值操作會一直持續到 counter 模塊頂層代碼的末尾。我們想知道最後是否能夠得到 message 的值(在 main.js 進行求值操作之後),於是我們設置一個 timeout, 然後對 main.js 進行求值。

message 這個變數將會被初始化並且被添加到內存中去。但是這兩者並沒有任何關係,它仍在被 require 的模塊中是 undefined。

如果導出的值被活躍綁定處理,counter 模塊將在最後得到正確的值。當 timeout 被執行的時候,main.js 的求值操作已經被完成而且內存中的區域也被填充了真實的值。

去支持循環依賴是 ES modules去這麼設計的原因之一。正是這三個階段讓這一切變得可能。

ES modules 現在是什麼狀態?

隨著 Firefox 60 在今年五月早期發布,所有的主流瀏覽器都將默認支持 ES modules。Node 也將會支持這種方式,工作組正在嘗試去讓 CommonJS 和 ES modules 進行兼容。

這就意味著你將可以去使用 script 標籤 加上type=module,去使用引入和導出。無論如何,越來越多的模塊特性將會可以使用。動態引入的提案已經明確進入 Stage 3 階段,同時import.meta提案將會讓 Node.js 支持這種寫法。解決模塊問題的提案也將平滑的同時支持瀏覽器和 Node.js。所以你們期待一下未來模塊化的工作會做的越來越好。


推薦閱讀:

看別人吵架對你來說應該是好事兒
低仿vue-async-computed
Typescript玩轉設計模式 之 對象行為型模式(上)
淺學Ajax

TAG:前端開發 | 模塊化 |