本文來自網易雲社區。html
本文翻譯自:ES modules: A cartoon deep-dive
ES 模塊爲 JavaScript 提供了官方標準化的模塊系統。然而,這中間經歷了一些時間 —— 近 10 年的標準化工做。
但等待已接近尾聲。隨着 5 月份 Firefox 60 發佈(目前爲 beta 版),全部主流瀏覽器都會支持 ES 模塊,而且 Node 模塊工做組也正努力在 Node.js 中增長 ES 模塊支持。同時用於 WebAssembly 的 ES 模塊集成 也在進行中。
許多 JavaScript 開發人員都知道 ES 模塊一直存在爭議。但不多有人真正瞭解 ES 模塊的運行原理。
讓咱們來看看 ES 模塊能解決什麼問題,以及它們與其餘模塊系統中的模塊有什麼不一樣。
模塊要解決什麼問題?
能夠這樣說,JavaScript 編程就是管理變量。所作的事就是爲變量賦值,或者在變量上作加法,或者將兩個變量組合在一塊兒並放入另外一個變量中。node
由於你的代碼中不少都是關於改變變量的,你如何組織這些變量會對你編碼方式以及代碼的可維護性產生很大的影響。
一次只須要考慮幾個變量就可讓事情變得更簡單。JavaScript 有一種方法能夠幫助你作到這點,稱爲做用域。因爲 JavaScript 中的做用域規則,一個函數沒法訪問在其餘函數中定義的變量。git
這很好。這意味着當你寫一個函數時,只需關注這個函數自己。你沒必要擔憂其餘函數可能會對函數內的變量作些什麼。
儘管如此,它仍然存在缺陷。這讓在函數間共享變量變得有點困難。
若是你想在做用域外共享變量呢?處理這個問題的一種常見方法是將它放在更外層的做用域裏……例如,在全局做用域中。
你可能還記得 jQuery 時代的這種狀況。在加載任何 jQuery 插件以前,你必須確保 jQuery 在全局做用域中。github
這在有效的同時也產生了反作用。
首先,全部的 script 標籤都須要按照正確的順序排列。因此你必須當心確保那個順序沒被打亂。
若是你搞亂了這個順序,那麼在運行的過程當中,你的應用程序就會拋出一個錯誤。當函數尋找它指望的 jQuery 時 —— 在全局做用域裏 —— 卻沒有找到它,它會拋出一個錯誤並中止運行。web
這使得維護代碼很是棘手。這讓移除老代碼或老 script 標籤變成了一場輪盤賭遊戲。你不知道會弄壞什麼。代碼的不一樣部分之間的依賴關係是隱式的。任何函數均可以獲取全局做用域中的任何東西,因此你不知道哪些函數依賴於哪些 script 標籤。
第二個問題是,由於這些變量位於全局範圍內,因此全局範圍內的代碼的每一個部分均可以更改該變量。惡意代碼可能會故意更改該變量,以使你的代碼執行某些你並不想要的操做,或者非惡意代碼可能會意外地弄亂你的變量。
模塊是如何提供幫助的?
模塊爲你提供了更好的方法來組織這些變量和函數。經過模塊,你能夠將有意義的變量和函數分組在一塊兒。
這會將這些函數和變量放入模塊做用域。模塊做用域可用於在模塊中的函數之間共享變量。
可是與函數做用域不一樣,模塊做用域也能夠將其變量提供給其餘模塊。它們能夠明確說明模塊中的哪些變量、類或函數應該共享。
當將某些東西提供給其餘模塊時,稱爲 export。一旦你聲明瞭一個 export,其餘模塊就能夠明確地說它們依賴於該變量、類或函數。算法
由於這是顯式的關係,因此當刪除了某個模塊時,你能夠肯定哪些模塊會出問題。
一旦你可以在模塊之間導出和導入變量,就能夠更容易地將代碼分解爲可獨立工做的小塊。而後,你能夠組合或重組這些代碼塊(像樂高同樣),從同一組模塊建立出各類不一樣的應用程序。
因爲模塊很是有用,歷史上有屢次向 JavaScript 添加模塊功能的嘗試。現在有兩個模塊系統正在大範圍地使用。CommonJS(CJS)是 Node.js 歷史上使用的。ESM(EcmaScript 模塊)是一個更新的系統,已被添加到 JavaScript 規範中。瀏覽器已經支持了 ES 模塊,而且 Node 也正在添加支持。
讓咱們來深刻了解這個新模塊系統的工做原理。
ES 模塊如何工做?
使用模塊開發時,會創建一個依賴圖。不一樣依賴項之間的鏈接來自你使用的各類 import 語句。
瀏覽器或者 Node 經過 import 語句來肯定須要加載什麼代碼。你給它一個文件來做爲依賴圖的入口。以後它會隨着 import 語句來找到全部剩餘的代碼。編程
但瀏覽器並不能直接使用文件自己。它須要把這些文件解析成一種叫作模塊記錄(Module Records)的數據結構。這樣它就知道了文件中到底發生了什麼。api
以後,模塊記錄須要轉化爲模塊實例(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,也畫過一些代碼漫畫。
Lin Clark 的更多文章……
本文已由做者受權網易雲社區發佈,未經容許不得轉載。
原文:漫畫:深刻淺出 ES 模塊(上篇)
漫畫:深刻淺出 ES 模塊 (下篇)