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 來說,這個過程需要三步:
- 構建——查詢、下載以及將所有檔案解析進入模組記錄。
- 例項化——查詢暴露出的值應該放在記憶體中的哪個位置(但是不會給它們填充值),然後在記憶體中建立 exports 和 imports 應該存在的地方。這被稱作連結。
- 求值——執行程式碼,把記憶體中的變數賦予真實的值。
人們都說 ES modules 是非同步的。你完全可以將它想成非同步的,因為整個流程被分成三個不同的階段——載入,例項化以及求值——還有,這些步驟都是被分開執行的。
這就意味著,這個規則是一種非同步的而且不從屬於 CommonJS。我將在稍後解釋它,在 CommonJS 中,一個模組的依賴是在模組載入之後才立刻進行載入、例項化、求值的,中間不會有任何的打斷(也就是同步)。
無論如何,這些步驟本身並不一定是非同步的。它們可以被同步處理。這就依賴於載入的過程取決於什麼?那是因為並不是所有的東西都尊崇於 ES modules 規範。這其實是兩部分工作,從屬於不同的規範。
ES module 規範闡述了你應該如何將這些檔案解析成模組記錄,以及你應該如何去例項化和進行求值。但是,它沒有說明如何去首先獲得這些檔案。
獲取這些檔案有相應的載入器,在不同的說明中,載入器都被明確定義了。對於瀏覽器,它的規範是HTML spec。但是你可以在不同平臺使用不同的載入器。
載入器同樣明確指出了控制模組應該如何被載入。這被稱作 ES 模組方法 —— ParseModule
,Module.Instantiate
,以及Module.Evaluate
。這就像JS 引擎操縱的木偶一樣。
現在我們來一起探尋每一步到底發生了什麼。
構建
構建階段每一個模組發生了三件事。
- 判斷應該從何處下載檔案所包含的模組(又叫模組解決方案)。
- 獲取檔案(通過 url 下載 或者 通過檔案系統載入)
- 將檔案解析進模組記錄
查詢到檔案然後獲取到它
載入器將會盡可能的去找到檔案然後去下載它。首先要去找到入口檔案。在 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),緊接著開始準備開始獲取下一個檔案。
<img src="http://o8gh1m5pi.bkt.clouddn.com/18-4-15/64202072.jpg"/ height="300px">
那當其他模組也依賴這個同樣的檔案時會發生什麼呢?載入器將會在模組對映集合中去遍歷這個 URL,如果它發現這個檔案正在被獲取,那麼載入器會直接查詢下一個 URL 去。
但是模組對映集合並不會去儲存已經被獲取過的檔案的棧。接下來我們會看到,模組對映集合對於模組而言同樣也會被作為一個快取。
解析
現在我們已經獲取到了這個檔案,我們需要將它解析為一條模組記錄。這會幫助瀏覽器知道這些模組不一樣的部分。
一旦這條模組記錄被建立,它將會被放置到模組對映集合內。這就意味著,無論何時它在這被請求,載入器都會從對映集合中錄取它。
在編譯過程中有一個看似微不足道的細節,但是它卻有著重大的影響。所有的模組被解析後都會被當做在頂部有use strict
。還有另外兩個細節。用例子來說明吧,await
關鍵詞會被預先儲備到模組程式碼的最頂部,以及頂級作用域中this
是undefined
。
這種不同的解析方式被稱作“解析目標”。如果你解析相同的檔案,但是目標不同,你將會得到不同的結果。因此,在開始解析你要解析的檔案型別之前,你需要知道它是否是一個模組。
在瀏覽器中,這將非常的簡單,你只需要在 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 支援這種寫法。[解決模組問題的提案](module resolution proposal)也將平滑的同時支援瀏覽器和 Node.js。所以你們期待一下未來模組化的工作會做的越來越好。
翻譯原文