漫画:深入浅出 ES 模块
翻譯自:ES modules: A cartoon deep-dive
(https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/)
ES 模塊為 JavaScript 提供了官方標準化的模塊系統。然而,這中間經歷了一些時間 —— 近 10 年的標準化工作。
但等待已接近尾聲。隨著 5 月份 Firefox 60 發布,所有主流瀏覽器都會支持 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 加載模塊文件。但是,這并不是在整個依賴圖上同時發生的。在解析文件前,并不知道這個文件中的模塊需要再獲取哪些依賴……并且在獲取文件之前無法解析那個文件。
這意味著我們必須逐層遍歷依賴樹,解析一個文件,然后找出它的依賴關系,然后查找并加載這些依賴。
如果主線程要等待這些文件的下載,那么很多其他任務將堆積在隊列中。
這是就是為什么當你使用瀏覽器時,下載部分需要很長時間。
基于此圖表(https://twitter.com/srigi/status/917998817051541504)。
像這樣阻塞主線程會讓采用了模塊的應用程序速度太慢而無法使用。這是 ES 模塊規范將算法分為多個階段的原因之一。將構造過程單獨分離出來,使得瀏覽器在執行同步的初始化過程前可以自行下載文件并建立自己對于模塊圖的理解。
這種方法 —— 將算法分解成不同階段 —— 是 ES 模塊和 CommonJS 模塊之間的主要區別之一。
CommonJS 可以以不同的方式處理的原因是,從文件系統加載文件比在 Internet 上下載需要少得多的時間。這意味著 Node 可以在加載文件時阻塞主線程。而且既然文件已經加載了,直接實例化和求值(在 CommonJS 中并不區分這兩個階段)就理所當然了。這也意味著在返回模塊實例之前,你遍歷了整棵樹,加載、實例化和求值了所有依賴關系。
CommonJS 方法有一些隱式特性,稍后我會解釋。其中一個是,在使用 CommonJS 模塊的 Node 中,可以在模塊標識符中使用變量。在查找下一個模塊之前,你執行了此模塊中的所有代碼(直至?require?語句)。這意味著當你去做模塊解析時,變量會有值。
但是對于 ES 模塊,在進行任何求值之前,你需要事先構建整個模塊圖。這意味著你的模塊標識符中不能有變量,因為這些變量還沒有值。
但有時候在模塊路徑使用變量確實非常有用。例如,你可能需要根據代碼的運行情況或運行環境來切換加載某個模塊。
為了讓 ES 模塊支持這個,有一個名為?動態導入?(https://github.com/tc39/proposal-dynamic-import)的提案。有了它,你可以像?import(`${path}`/foo.js?這樣使用 import 語句。
它的原理是,任何通過?import()?加載的的文件都會被作為一個獨立的依賴圖的入口。動態導入的模塊開啟一個新的依賴圖,并單獨處理。
有一點需要注意,同時存在于這兩個依賴圖中的模塊都將共享同一個模塊實例。這是因為加載器會緩存模塊實例。對于特定全局作用域中的每個模塊,都將只有一個模塊實例。
這意味著引擎的工作量減少了。例如,這意味著即使多個模塊依賴某個模塊,這個模塊的文件也只會被獲取一次。(這是緩存模塊的一個原因,我們將在求值部分看到另一個。)
加載器使用一種叫做模塊映射的東西(https://html.spec.whatwg.org/multipage/webappapis.html#module-map)來管理這個緩存。每個全局作用域都在一個單獨的模塊映射中跟蹤其模塊。
當加載器開始獲取一個 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 之間的差異。所以我們可以期待將來的模塊支持會更好。
——推薦閱讀——
Wireshark抓包分析——TCP/IP協議
Wireshark對HTTPS數據的解密
網易云信IM小程序上線?我們是這么做的!>>
全面復盤!深度剖析直播答題產品架構的難點與坑>>
如何快速設計短信驗證碼>>
如何做好Android 端音視頻測試>>
總結
以上是生活随笔為你收集整理的漫画:深入浅出 ES 模块的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 如何做一个新产品的第一个版本:关于MVP
- 下一篇: WebRTC大会火爆上演,网易云信谈音视