深入理解 ES Modules (手繪示例)

發表於2018-05-08

雖然花了近十年的標準化工作才走到這一步,ES 模組終於為 JavaScript 帶來了正式的,標準化的模組系統。

漫長的等待終於要結束了,隨著即將在五月釋出的 Firefox 60 (目前尚處於 beta 版本中),所有的主流瀏覽器都即將支援 ES 模組,並且 Node 模組工作小組目前也正在為 Node.js 新增對 ES 模組的支援。同時,ES 模組對 WebAssembly 的支援也正在進行當中。

許多 JavaScript 的開發者都知道 ES 模組一直存在著一些爭議,但是很少有人真正地知道 ES 模組的原理。

現在就讓我們來探索一下 ES 模組到底解決了什麼問題以及它和其他模組系統的區別。

模組到底解決了什麼問題?

仔細想想,使用 JavaScript 編碼在於正確地管理變數,在於給變數賦值,或者給變數賦以數值或者合併兩個變數並把它們賦值給另外一個變數。

Code showing variables being manipulated

因為你的大多數程式碼都是在更改變數,如何組織這些變數將會對你的編碼方式以及程式碼的維護產生重大的影響。

當一次只需要考慮幾個變數的時候使得事情變得非常簡單,JavaScript 有一個方式來幫助你實現這個目標,那就是 —— 作用域。因為作用域的存在,函式不能訪問 定義在其他函式內部的變數。

Two function scopes with one trying to reach into another but failing

這很棒。這意味著當你專注於實現一個函式的時候,你只需要專注於實現這個函式,而不需要擔心其他的函式會影響到你這個函式裡的變數。

不過,它也有一個缺陷,它使得不同的函式之間共享變數變得更加困難。

那麼假如你的確想要在作用域之外共享你的變數呢?通常的做法是將它放在當前作用域之上,比如:全域性作用域。

或許你還記得使用 jQuery 的那些日子,在你載入任何 jQuery 的外掛之前,你必須確保 jQuery 已經存在於全域性作用域內了。

Two function scopes in a global, with one putting jQuery into the global

這是可行的,但是會產生一些煩人的問題。

首先,你所有的 script 標籤都必須放置於一個正確的順序。那麼你就必須很小心並確保這些指令碼之間不會互相影響。

如果你確實不小心搞亂了順序,那麼在程式碼執行的時候,你的應用就會丟擲異常。當函式尋找 jQuery 物件的存在 —— 也就是全域性作用域之下,但是卻找不到的時候,函式就會報錯並停止執行。

The top function scope has been removed and now the second function scope can’t find jQuery on the global

這讓程式碼維護變得棘手。移除舊的程式碼或者是 script 標籤就像是玩賭場轉盤一樣。你無法預料到什麼程式碼可能崩潰。程式碼之間的依賴關係變得隱蔽。任何函式都可以獲取到全域性作用域上的任何東西,所以你並沒有辦法知道哪個函式依賴於哪個 script 標籤。

其次,由於你的變數都存在於全域性作用域上,所有處於這個作用域之上的程式碼都可以改變這些變數。惡意程式碼可以通過更改這些變數來讓你的程式碼做並非你本意的事情,或者非惡意的程式碼會不小心破壞你的變數。

模組如何提供幫助

模組為你提供了一個更加好的方式來組織這些變數和方法。有了模組,你可以將這些有意義的函式和變數組織在一起。

模組會將這些函式和變數放入一個模組作用域當中。模組作用域使得模組中的不同函式能夠共享這些變數。

但是不同與函式作用域,模組作用域有一種方法能夠使得其他的模組也可以訪問這個模組的變數。他們可以顯式地指定模組中的哪些變數,類或者是函式可以被其他模組訪問。

當一些東西對其他模組可用的時候,這叫做 “匯出(export)”。當模組的匯出存在的時候,其他模組就能夠顯式地指定它們依賴於這個模組的某些變數,類或者函式。

Two module scopes, with one reaching into the other to grab an export

因為存在這種顯式的關係,你可以明確的指出當你去掉了另外一個(匯出),哪個模組會崩潰掉。

一旦擁有了這種能在模組之間匯出和匯入變數的能力,把你的程式碼分割成更小並且能夠互相之間獨立工作的程式碼塊就變得很容易了。 然後你就可以結合或者重組這些程式碼塊,像組合樂高積木一樣,來使用同樣的模組建立不同的應用。

正因為模組如此地有用,已經存在很多給 JavaScript 新增模組的嘗試。目前,有兩種模組系統被廣泛地使用著。CommonJS(CJS) 曾經被 Node.js 所使用。ESM(ECMAScript 模組)是一個更新的模組系統,並加入到 JavaScript 的規範當中。瀏覽器已經支援 ES 模組了,Node.js 也正在新增對它的支援。

現在,就讓我們更加深入地來看一下這個新的模組系統是如何運作的。

ES 模組是如何運作的

當使用模組來開發的時候,會建立一個模組模組依賴圖。不同依賴之間聯絡來自於你使用的任何 import 語句。

這些 import 語句是瀏覽器或者 Node 確切地知道你需要載入什麼樣的程式碼的關鍵之處。你需要提供一個檔案來作為依賴圖的入口。 從這個入口開始,根據這些 import 語句就可以找剩餘所需要的程式碼。

A module with two dependencies. The top module is the entry. The other two are related using import statements

但是瀏覽器並不能直接使用這些檔案本身。它必須要經過解析並轉換成一種叫做 “模組記錄(Module Records)”的資料結構。只有這樣,瀏覽器才能確切地知道這個檔案裡發生了什麼。

A module record with various fields, including RequestedModules and ImportEntries

在這之後,模組記錄需要轉變成模組例項。模組例項包含了兩個要素:編碼(code)狀態(state)

編碼基本上就是一些系列的指令。它就像配方一樣。但是隻有配方本身,什麼都做不了,所以還需要一些原材料來配合這些指令。

什麼是狀態?狀態就提供了這些原材料。狀態就是這些變數在任何時間點的具體值。當然,這些變數不過是記憶體中儲存這些變數的容器的別名。

所以模組例項就結合了編碼(一系列的指令)和狀態(所有的變數的值)。

A module instance combining code and state

我們需要的是每一個模組的模組例項。模組載入的過程就是從入口檔案開始最後得到整個模組例項的依賴圖。

對於 ES 模組來說,這個過程主要分三步來進行:

  1. 構造 —— 尋找,下載並解析所有檔案成模組記錄
  2. 例項化 —— 在記憶體中尋找位置存放所有匯出的值(但是暫時還不要給他們填上具體的值)然後讓匯出和匯入都指向這些記憶體中的位置。這個過程也叫做連結(linking)。
  3. 求值 —— 執行編碼並給例項化中所對應的記憶體的位置填充實際的值。

The three phases. Construction goes from a single JS file to multiple module records. Instantiation links those records. Evaluation executes the code.

人們說 ES 模組是非同步的。你可以認為它是非同步的因為實際的運作被分成了三個不同的階段 —— 載入,例項化以及求值,而這些階段都可以分開完成。

這意味著規範確實引入了一種在 CommonJS 中沒有的非同步。稍後我會作更多的解釋,但是在 CJS 中,一個模組下游的依賴關係是立即載入,例項化並求值的,不存在任何的間斷。

但是,這些步驟不一定要是非同步的,它們也可以以同步的方式完成。這取決於用什麼來載入。這是因為不是所有的東西都是由 ES 模組規範來定義的。實際上有兩部分工作,分別由不同的規範來覆蓋。

ES 模組規範 說明了應該如何將檔案解析成模組記錄,以及如何例項化和對模組求值。然而,它並沒有指明如何獲取這個模組。

載入檔案的是模組載入器(loader), 而載入器由不同的規範來指定。對於瀏覽器來說,這個規範就是 HTML 規範。但是,基於你使用的不同的平臺可以有不同的載入器。

Two cartoon figures. One represents the spec that says how to load modules (i.e., the HTML spec). The other represents the ES module spec.

載入器同時還精準地控制著模組的載入方式。這些方法叫做 ES 模組方法 —— ParseModuleModule.Instantiate 以及 Module.Evaluate。這有點像一個提線木偶操作師操作著 JS 引擎。

The loader figure acting as a puppeteer to the ES module spec figure.

現在讓我們更加詳細地介紹每一步的過程。

構造(Construction)

對於每一個模組,在構造過程都會經歷這三個過程:

  1. 找到在哪裡下載包含該模組的檔案(也稱作模組解析)
  2. 獲取檔案(通過 url 從檔案系統中下載)
  3. 將這些檔案解析成模組記錄

尋找並獲取檔案

載入器會處理查詢和下載檔案的過程,首先它需要找到入口檔案。在 HTML 當中,你通過 script 標籤來告訴載入器哪裡去查詢。

A script tag with the type=module attribute and a src URL. The src URL has a file coming from it which is the entry

但是,它如何找到接下來的一堆模組呢 —— main.js 直接依賴的模組。

這就是 import 語句發揮作用的地方。import 語句的其中一部分叫做模組識別符號,它會告訴載入器去哪尋找下一個模組。

An import statement with the URL at the end labeled as the module specifier

關於模組識別符號值得一提的地方是:有時候它們需要在瀏覽器和 Node 之間做不同的處理。每個宿主都有各自的方法來解析模組識別符號的字串。為了達到這個目的,它使用了名為模組解析演算法的東西,而這個演算法根據平臺的不同也有所不同。目前來說,一些在 Node 中能夠正常解析的模組識別符號無法在瀏覽器中正常解析,但是一個致力於修復它的工作正在進行中。

直到這個問題被修復之前,瀏覽器只接受 URL 作為模組識別符號。它們會從模組識別符號指定的 URL 中下載對應的模組檔案。對於模組的依賴圖的生成來說,這一步不是同時進行的。因為在你解析模組檔案之前,你無法明確該模組需要什麼依賴,並且你無法在獲取檔案之前就解析它。

這意味著我們必須一層一層地深入模組的結構樹,解析一個檔案然後理清楚改模組的依賴,然後進一步獲取並載入這些依賴。

A diagram that shows one file being fetched and then parsed, and then two more files being fetched and then parsed

如果主執行緒需要等等待每個檔案的下載,那麼其他的下載任務就會在佇列中等待。

這是因為在瀏覽器當中,下載的部分需要花費很長的時間。

A chart of latencies showing that if a CPU cycle took 1 second, then main memory access would take 6 minutes, and fetching a file from a server across the US would take 4 years

基於這張 圖表.

像這樣阻塞主執行緒會使得那些使用模組來構建的 App 的速度變得很慢。這也是 ES 模組規範將實現演算法劃分成多個階段的原因之一。將構建階段單獨劃分出來允許瀏覽器在處理同步的例項化階段之前就能夠下載檔案並且構建模組依賴圖。

這種實現方式 —— 將模組演算法劃分為不同階段,是 CommonJS 模組和 ES 模組的主要區別之一。

CommonJS 不採用這用的方式是因為從檔案系統中讀取檔案和從網路中下載檔案比起來要快得多。這意味著 Node 可以在載入檔案的時候阻塞主執行緒。然後既然檔案以及載入完了,那麼就順其自然地例項化和求值(在 CommonJS 中不是分開的階段)。這也意味著,在返回模組例項之前,需要遍歷整棵模組依賴樹並對任何模組依賴載入、例項化和求值。

A diagram showing a Node module evaluating up to a require statement, and then Node going to synchronously load and evaluate the module and any of its dependencies

CommonJS 的實現方式有一些影響,之後我會進行解釋。但值得注意的一點是,在使用 CommonJS 模組的 Node 環境中,你可以在模組識別符號中使用變數。因為在查詢下一個模組之前,會執行當前模組的所有程式碼(require 語句之前)。這意味著當 Node 進行模組解析的時候,模組識別符號中的變數已經有值了。

但是對於 ES 模組來說,我們在執行任何求值計算之前,事先構建了整個模組依賴圖。這意味著在模組識別符號當中不可以存在變數,因為這些變數還沒有具體的值。

A require statement which uses a variable is fine. An import statement that uses a variable is not.

但是有時候,在模組路徑中使用變數非常有用。比如,你可能會根據程式碼的不同條件或者當前執行環境的不同來切換載入不同的模組。

為了在 ES 模組中實現同樣的效果,這裡有一個叫做 dynamic import 的提案。有了它,你可以使用類似 import(${path}/foo.js) 的語句

這個方法的原理在於任何使用 import() 載入的檔案會被處理為一個分散的依賴圖的入口。而被動態引入的模組建立了一個新的模組依賴圖,這個模組依賴圖的處理是分開進行的。

Two module graphs with a dependency between them, labeled with a dynamic import statement

有一點需要注意 —— 同時處於兩個依賴圖中的任何一個模組將會共享同一個模組例項。這是因為模組載入器會對模組例項進行快取。對於特定全域性作用域中的每一個模組,都只會擁有一個模組例項。

這意味著引擎的工作量更少。比如,一個模組檔案只會獲取一次,即使有多個模組同時依賴於它(這是我們需要模組快取的原因之一,我們會在求值的章節中介紹另外一個原因)。

載入器通過一個叫做 模組對映(module map) 的東西來管理模組快取。每一個全域性環境都在一個單獨的模組對映裡跟蹤其模組。

當載入器載入一個 URL 的時候,它會將這個 URL 放到模組對映裡,然後記下當前處於載入狀態。然後它會發出請求並開始處理下一個要載入的檔案。

The loader figure filling in a Module Map chart, with the URL of the main module on the left and the word fetching being filled in on the right

那麼,如果另外一個模組依賴了某個相同的檔案呢?載入器會檢查模組對映裡的每一個 URL,如果它發現已經處於載入狀態了,那麼載入器會跳過並處理下一個 URL。

但是,模組對映不僅僅跟蹤那些模組正在被載入,它還作為模組的快取,我們接下來會講到。

解析(Parsing)

現在我們已經獲取到了模組檔案,我們需要將它解析成模組記錄。這能夠幫助瀏覽器理解模組的各部分分別是什麼。

Diagram showing main.js file being parsed into a module record

一旦模組記錄被建立,就會被放到模組對映裡。這意味著,只要從外部請求對應的模組,模組載入器就可以從模組對映中拿到對應的模組記錄。

The “fetching” placeholders in the module map chart being filled in with module records

在解析的過程中,有一個細節看起來可能微不足道,但是實際上有很大的影響。所有的模組都被以有 "use strict" 在頂部的情況解析。還有一些其他細微的差別,比如,await 關鍵字被保留在模組的頂級程式碼中,以及 this 的值是 undefined

解析的不同被稱之為 解析目標(parsed goal),如果對同一個檔案使用不同的解析目標,那就會的到不同的結果。所以你需要在解析開始之前就知道檔案的型別,無論它是模組與否。

在瀏覽器當中非常簡單,你只需要在 script 標籤中加入 type=module。這告訴瀏覽器,當前檔案需要以模組的方式來解析,並且因為只有模組才能夠被匯入,瀏覽器就能夠知道當前檔案裡所有的匯入也都是模組。

The loader determining that main.js is a module because the type attribute on the script tag says so, and counter.js must be a module because it’s imported

但是在 Node 當中,我們不使用 HTML 標籤,所以我們沒辦法使用 type 屬性。社群嘗試解決這個問題的方法之一是 —— 使用 .mjs 副檔名。使用這個副檔名能告訴 Node,“當前這個檔案是一個模組”。所以你能看到在談論使用這個來作為解析目標的訊號。關於這個方案的討論還在不斷地進行中,所以目前來說 Node 社群最後到底會使用什麼樣的訊號還不明確。

無論使用哪種方式,載入器都會決定是否要將一個檔案當做模組來解析。如果它是一個模組,並且有一些匯入,那麼載入器將會再次進行這個過程,直到所有的檔案都被載入和解析。

當我們完成之後,也就是載入的過程結束之後,你已經從原來只有一個入口檔案的變成擁有一堆模組記錄。

A JS file on the left, with 3 parsed module records on the right as a result of the construction phase

下一步就是例項化這些模組並將這些例項連結在一起。

例項化(Instantiation)

像我之前所提到的,一個模組例項包含了編碼和狀態。這些狀態存在於記憶體之中,因此例項化的步驟就是將模組的內容連線到記憶體之中。

首先,JS 引擎會建立一個模組環境記錄。它為模組記錄管理變數,然後引擎會為模組匯出在記憶體中找到位置存放。模組環境變數會跟蹤記憶體中的哪個位置對應哪個匯出。

這些記憶體中的位置暫時還沒有具體的值在裡面。只有在求值之後才會為它們填充實際的值。對於這個規則有個要警惕的地方:任何匯出的函式宣告都會在這個階段初始化。這對於求值來說更加簡單。

為了例項化模組整個依賴圖,引擎會執行 “深度優先後序遍歷”,這意味著引擎會深入到依賴圖的底部 —— 到底部某個不依賴任何其他依賴的模組依賴,然後設定它們的匯出。

A column of empty memory in the middle. Module environment records for the count and display modules are wired up to boxes in memory.

當引擎完成連結模組下游的所有匯出的時候,然後回到上一個級別連結那個來自模組所有的匯入。

注意,匯入和匯出都指向記憶體中的同一個位置。連結所有的匯出首先確保了所有的匯入都能夠正確地匹配這些匯出。

這個過程和 CommonJS 模組是不同的。在 CommonJS 當中,整個匯出物件被複制到匯出上。這意味著任何的匯出值(比如數字)都是副本。

這也表示,如果匯出模組發生之後發生了改變,匯入模組並不會觀測到這個變化。

Same diagram as above, but with the module environment record for main.js now having its imports linked up to the exports from the other two modules.

與之相反,ES 模組使用了叫做 “活動繫結(live bindings)” 的機制。匯入和匯出模組都指向記憶體中的同一個位置。當匯出模組改變了其中匯出的某個值,這個變化也會迅速地顯現在匯入模組當中。

匯出值的模組可以隨時更改這些值,但匯入模組不能更改其匯入的值。也就是說,如果一個模組匯入了一個物件,它可以改變物件身上的屬性值。

Memory in the middle with an exporting common JS module pointing to one memory location, then the value being copied to another and the importing JS module pointing to the new location

使用活動繫結的原因是可以在不執行任何程式碼的的情況下就將這些模組連結在一起。這一點對於迴圈依賴的模組的執行很有幫助,接下來我會解釋。

所以在這一步的最後,我們將所有模組例項的匯入和匯出的變數在記憶體中的位置連結在一起。

現在我們就可以開始對程式碼求值,然後給這些記憶體中的地址填充上實際的值。

The exporting module changing the value in memory. The importing module also tries but fails.

Evaluation

最後一步就是對這些記憶體中的位置進行值的填充。JS 引擎通過執行頂層程式碼(函式之外的程式碼)來實現。

除了給記憶體裡的空間填值之外,求值過程也存在著一些副作用,比如,一個模組可能會請求一個伺服器。

A module will code outside of functions, labeled top level code

因為潛在的副作用,你只想對模組求值一次。與例項化中發生的連結相反,它們多次連結的結果都是一致的,但根據操作次數的不同,求值所產生的結果可能不同。

這也是需要模組對映的原因之一。模組對映通過規範的 URL 來快取模組,使得每個模組只有一個模組記錄。這保證了每個模組只會執行一次,和例項化一樣,這個過程也是以 “深度優先後續遍歷” 的方式來執行。

那麼我們之前提到的迴圈引用的問題呢?

在迴圈依賴之中,最終會在依賴圖中產生一個迴圈。通常來說,這會是一個長迴圈,但是為了解釋這個問題,我打算用一個模擬的短迴圈的例子。

A complex module graph with a 4 module cycle on the left. A simple 2 module cycle on the right.

首先我們來看看在 CommonJS 模組中,迴圈依賴是怎麼實現的。首先,main 模組會一直執行到 require 語句。然後它就會轉而載入 counter 模組。

A commonJS module, with a variable being exported from main.js after a require statement to counter.js, which depends on that import

然後 counter 模組會嘗試從匯出物件上訪問 message。但因為 message 在 main 模組中還沒有被求值,會返回 undefined。JS 引擎將會為本地變數分配記憶體空間並設定其值為 undefined

Memory in the middle with no connection between main.js and memory, but an importing link from counter.js to a memory location which has undefined

求值過程將會繼續在 counter 模組的的頂級程式碼中執行,並執行到底部。我們想看看 message 最終是否會獲取到正確的值(在 main.js 求值之後),所以我們設定了一個 timeout。然後求值將在 main.js 中恢復。

counter.js returning control to main.js, which finishes evaluating

message 變數將會初始化並被加到記憶體當中。但是因為 main 匯出的 message 和 counter 中的 require 還沒有關聯,所以在引入的模組中(counter.js),message 仍然保持為 undefined

main.js getting its export connection to memory and filling in the correct value, but counter.js still pointing to the other memory location with undefined in it

如果匯出都是以 “活動繫結” 的方式處理的,那麼 counter 模組最終將會得到正確的值。那麼等到 timeout 執行的時候,main.js 求值已經結束並且填充了值。

支援這些迴圈依賴的背後是 ES 模組設計的重要原理。是這個“三步驟”的設計才使得迴圈依賴得以實現。

ES 模組的當前狀態

隨著 Firefox 60 在五月早期的釋出,所有主流瀏覽器都將預設支援 ES 模組了。Node 也新增了對其的支援,並且有一個致力於解決 CommonJS 和 ES 模組之間的相容性的問題的工作小組在不斷地努力。

這意味著,你將可以使用 type=module 的方式來使用模組的匯入和匯出。然而,更多的模組特性也即將到來。處於 Stage 3 的提案 dynamic import 也在具體的程式中。import.meta 也是如此,它將支援 Node 上的一些用例。同時 module resolution 提案也會使得在瀏覽器和 Node.js 之間的差別變得更加平滑細微。所以我們能夠期待未來可以鞥更好地使用模組。

相關文章