原文:ES modules: A cartoon deep-dive, Lin Clarkhtml
ES modules(ESM) 是 JavaScript 官方的標準化模塊系統。
然而,它在標準化的道路上已經花費了近 10 年的時間。node
可喜的是,標準化之路立刻就要完成了。等到 2018 年 5 月 Firefox 60 發佈以後,全部的主流瀏覽器就都支持 ESM 了。同時,Node 模塊工做小組也正在爲 Node.js 添加 ESM 支持。爲 WebAssembly 提供 ESM 集成的工做也正在如火如荼的進行。git
許多 JS 開發者都知道,對 ESM 的討論從開始至今一直都沒停過。可是不多有人真正理解 ESM 的工做原理。github
今天,讓咱們來梳理梳理 ESM 到底解決了什麼問題,以及它跟其餘模塊系統之間有什麼區別。算法
說到 JS 編程,其實說的就是如何管理變量。
編程的過程都是關於如何給變量賦值,要麼直接賦值給變量,要麼是把兩個變量結合起來而後再把結果賦值給另外一個變量。編程
由於大部分代碼都是關於改變變量的,因此如何組織這些變量就直接影響了編碼質量,以及維護它們的成本。瀏覽器
若是代碼中僅有少許的變量,那麼組織起來實際上是很簡單的。
JS 自己就提供了一種方式幫你組織變量,稱爲函數做用域。由於函數做用域的緣故,一個函數沒法訪問另外一個函數中定義的變量。緩存
這種方式是頗有效的。它使得咱們在寫一個函數的時候,只須要考慮當前函數,而沒必要擔憂其它函數可能會改變當前函數的變量。
不過,它也有很差的地方。它會讓咱們很難在不一樣函數之間共享變量。服務器
若是咱們想跟當前函數之外的函數共享變量要怎麼辦呢?一種通用的作法是把要共享的變量提高到上一層做用域,好比全局做用域。網絡
在 jQuery 時代這種提高作法至關廣泛。在咱們加載任何 jQuery 插件以前,咱們必須確保 jQuery 已經存在於全局做用域。
這種作法也確實行之有效,可是也帶來了使人煩惱的影響。
首先,全部的 <script>
必須以正確的順序排列,開發者必須很是謹慎地確保沒有任何一個腳本排列錯誤。
若是排列錯了,那麼在運行過程當中,應用將會拋出錯誤。當函數在全局做用域尋找 jQuery 變量時,若是沒有找到,那麼它將會拋出異常錯誤,而且中止繼續運行。
這同時也使得代碼的後期維護變得困難。
它會使得移除舊代碼或者腳本標籤變得充滿不肯定性。你根本不知道移除它會帶來什麼影響。代碼之間的依賴是不透明的。任何函數均可能依賴全局做用域中的任何變量,以致於你也不知道哪一個函數依賴哪一個腳本。
其次,因爲變量存在於全局做用域,因此任何代碼均可以改變它。
惡意的代碼可能會故意改變全局變量,從而讓你的代碼作出危險行爲。又或者,代碼可能不是惡意的,可是卻無心地改變了你指望的變量。
模塊化爲你提供了一種更好的方式來組織變量和函數。你能夠把相關的變量和函數放在一塊兒組成一個模塊。
這種組織方式會把函數和變量放在模塊做用域中。模塊中的函數能夠經過模塊做用域來共享變量。
不過,與函數做用域不一樣的是,模塊做用域還提供了一種暴露變量給其餘模塊使用的方式。模塊能夠明確地指定哪些變量、類或函數對外暴露。
對外暴露的過程稱爲導出。一旦導出,其餘模塊就能夠明確地聲稱它們依賴這些導出的變量、類或者函數。
由於這是一種明確的關係,因此你能夠很簡單地辨別哪些代碼能移除,哪些不能移除。
擁有了在模塊之間導出和導入變量的能力以後,你就能夠把代碼分割成更小的、能夠獨立運行地代碼塊了。而後,你就能夠像搭樂高積木同樣,基於這些代碼塊,建立全部不一樣類型的應用。
因爲模塊化是很是有用的,因此歷史上曾經屢次嘗試爲 JS 添加模塊化的功能。不過截止到目前,真正獲得普遍使用的只有兩個模塊系統。
一個是 Node.js 使用的 CommonJS (CJS);另外一個是 JS 規範的新模塊系統 EcmaScript modules(ESM),Node.js 也正在添加對 ESM 的支持。
下面就讓咱們來深刻理解下這個新的模塊系統是如何工做的。
當你在使用模塊進行開發時,實際上是在構建一張依賴關係圖。不一樣模塊之間的連線就表明了代碼中的導入語句。
正是這些導入語句告訴瀏覽器或者 Node 該去加載哪些代碼。
咱們要作的是爲依賴關係圖指定一個入口文件。從這個入口文件開始,瀏覽器或者 Node 就會順着導入語句找出所依賴的其餘代碼文件。
可是呢,瀏覽器並不能直接使用這些代碼文件。它須要解析全部的文件,並把它們變成一種稱爲模塊記錄(Module Record)的數據結構。只有這樣,它才知道代碼文件中到底發生了什麼。
解析以後,還須要把模塊記錄變成一個模塊實例。模塊實例會把代碼和狀態結合起來。
所謂代碼,基本上是一組指令集合。它就像是製做某樣東西的配方,指導你該如何製做。
可是它自己並不能讓你完成製做。你還須要一些原料,這樣才能夠按照這些指令完成製做。
所謂狀態,它就是原料。具體點,狀態是變量在任什麼時候候的真實值。
固然,變量實際上就是內存地址的別名,內存纔是正在存儲值的地方。
因此,能夠看出,模塊實例中代碼和狀態的結合,就是指令集和變量值的結合。
對於模塊而言,咱們真正須要的是模塊實例。
模塊加載會從入口文件開始,最終生成完整的模塊實例關係圖。
對於 ESM ,這個過程包含三個階段:
你們都說 ESM 是異步的。
由於它把整個過程分爲了三個不一樣的階段:加載、實例化和運行,而且這三個階段是能夠獨立進行的。
這意味着,ESM 規範確實引入了一種異步方式,且這種異步方式在 CJS 中是沒有的。
後面咱們會詳細說到爲何,然而在 CJS 中,一個模塊及其依賴的加載、實例化和運行是一塊兒順序執行的,中間沒有任何間斷。
不過,這三個階段自己是不必異步化。它們能夠同步執行,這取決於它是由誰來加載的。由於 ESM 標準並無明確規範全部相關內容。實際上,這些工做分爲兩部分,而且分別是由不一樣的標準所規範的。
其中,ESM 標準 規範瞭如何把文件解析爲模塊記錄,如何實例化和如何運行模塊。可是它沒有規範如何獲取文件。
文件是由加載器來提取的,而加載器由另外一個不一樣的標準所規範。對於瀏覽器來講,這個標準就是 HTML。可是你還能夠根據所使用的平臺使用不一樣的加載器。
加載器也同時控制着如何加載模塊。它會調用 ESM 的方法,包括 ParseModule
、Module.Instantiate
和 Module.Evaluate
。它就像是控制着 JS 引擎的木偶。
下面咱們將更加詳細地說明每一步。
對於每一個模塊,在構建階段會作三個處理:
加載器負責定位文件而且提取。首先,它須要找到入口文件。在 HTML 中,你能夠經過 <script>
標籤來告訴加載器。
可是,加載器要如何定位 main.js
直接依賴的模塊呢?
這個時候導入語句就派上用場了。導入語句中有一部分稱爲模塊定位符(Module Specifier),它會告訴加載器去哪定位模塊。
對於模塊定位符,有一點要注意的是:它們在瀏覽器和 Node 中會有不一樣的處理。每一個平臺都有本身的一套方式來解析模塊定位符。這些方式稱爲模塊定位算法,不一樣的平臺會使用不一樣的模塊定位算法。
當前,一些在 Node 中能工做模塊定位符並不能在瀏覽器中工做,可是已經有一項工做正在解決這個問題。
在這個問題被解決以前,瀏覽器只接受 URL 做爲模塊定位符。
它們會從 URL 加載模塊文件。可是,這並非在整個關係圖上同時發生的。由於在解析完這個模塊以前,你根本不知道它依賴哪些模塊。並且在它下載完成以前,你也沒法解析它。
這就意味着,咱們必須一層層遍歷依賴樹,先解析文件,而後找出依賴,最後又定位並加載這些依賴,如此往復。
若是主線程正在等待這些模塊文件下載完成,許多其餘任務將會堆積在任務隊列中,形成阻塞。這是由於在瀏覽器中,下載會耗費大量的時間。
而阻塞主線程會使得應用變得卡頓,影響用戶體驗。這是 ESM 標準把算法分紅多個階段的緣由之一。將構建劃分爲一個獨立階段後,瀏覽器能夠在進入同步的實例化過程以前下載文件而後理解模塊關係圖。
ESM 和 CJS 之間最主要的區別之一就是,ESM 把算法化爲爲多個階段。
CJS 使用不一樣的算法是由於它從文件系統加載文件,這耗費的時間遠遠小於從網絡上下載。所以 Node 在加載文件的時候能夠阻塞主線程,而不形成太大影響。並且既然文件已經加載完成了,那麼它就能夠直接進行實例化和運行。因此在 CJS 中實例化和運行並非兩個相互獨立的階段。
這也意味着,你能夠在返回模塊實例以前,順着整顆依賴樹去逐一加載、實例化和運行每個依賴。
CJS 的方式對 ESM 也有一些啓發,這個後面會解釋。
其中一個就是,在 Node 的 CJS 中,你能夠在模塊定位符中使用變量。由於已經執行了 require
以前的代碼,因此模塊定位符中的變量此刻是有值的,這樣就能夠進行模塊定位的處理了。
可是對於 ESM,在運行任何代碼以前,你首先須要創建整個模塊依賴的關係圖。也就是說,創建關係圖時變量是尚未值的,由於代碼都還沒運行。
不過呢,有時候咱們確實須要在模塊定位符中使用變量。好比,你可能須要根據當前的情況加載不一樣的依賴。
爲了在 ESM 中實現這種方式,人們已經提出了一個動態導入提案。該提案容許你可使用相似 import(\`${path}/foo.js`)
的導入語句。
這種方式其實是把使用 import()
加載的文件當成了一個入口文件。動態導入的模塊會開啓一個全新的獨立依賴關係樹。
不過有一點要注意的是,這兩棵依賴關係樹共有的模塊會共享同一個模塊實例。這是由於加載器會緩存模塊實例。在特定的全局做用域中,每一個模塊只會有一個與之對應的模塊實例。
這種方式有助於提升 JS 引擎的性能。例如,一個模塊文件只會被下載一次,即便有多個模塊依賴它。這也是緩存模塊的緣由之一,後面說到運行的時候會介紹另外一個緣由。
加載器使用模塊映射(Module Map)來管理緩存。每一個全局做用域都在一個單獨的模塊映射中跟蹤其模塊。
當加載器要從一個 URL 加載文件時,它會把 URL 記錄到模塊映射中,並把它標記爲正在下載的文件。而後它會發出這個文件請求並繼續開始獲取下一個文件。
當其餘模塊也依賴這個文件的時候會發生什麼呢?加載器會查找模塊映射中的每個 URL 。若是發現 URL 的狀態爲正在下載,則會跳過該 URL ,而後開始下一個依賴的處理。
不過,模塊映射的做用並不只僅是記錄哪些文件已經下載。下面咱們將會看到,模塊映射也能夠做爲模塊的緩存。
至此,咱們已經拿到了模塊文件,咱們須要把它解析爲模塊記錄。
這有助於瀏覽器理解模塊的不一樣部分。
一旦模塊記錄建立完成,它就會被記錄在模塊映射中。因此,後續任什麼時候候再次請求這個模塊時,加載器就能夠直接從模塊映射中獲取該模塊。
解析過程當中有一個看似微不足道的細節,可是實際形成的影響卻很大。那就是全部的模塊都按照嚴格模式來解析的。
也還有其餘的小細節,好比,關鍵字 await
在模塊的最頂層是保留字, this
的值爲 undefinded
。
這種不一樣的解析方式稱爲解析目標(Parse Goal)。若是按照不一樣的解析目標來解析相同的文件,會獲得不一樣的結果。所以,在解析文件以前,必須清楚地知道所解析的文件類型是什麼,無論它是否是一個模塊文件。
在瀏覽器中,知道文件類型是很簡單的。只須要在 <script>
腳本中添加 type="module"
屬性便可。這告訴瀏覽器這個文件須要被解析爲一個模塊。並且,由於只有模塊才能被導入,因此瀏覽器以此推測全部的導入也都是模塊文件。
不過在 Node 中,咱們並不使用 HTML 標籤,因此也沒辦法經過 type
屬性來辨別。社區提出一種解決辦法是使用 .mjs
拓展名。使用該拓展名會告訴 Node 說「這是個模塊文件」。你會看到你們正在討論把這個做爲解析目標。不過討論仍在繼續,因此目前仍不明確 Node 社區最終會採用哪一種方式。
不管最終使用哪一種方式,加載器都會決定是否把一個文件做爲模塊來解析。若是是模塊,並且包含導入語句,那它會從新開始處理直至全部的文件都已提取和解析。
到這裏,構建階段差很少就完成了。在加載過程處理完成後,你已經從最開始只有一個入口文件,到如今獲得了一堆模塊記錄。
下一步會實例化這些模塊而且把全部的實例連接起來。
正如前文所述,一個模塊實例結合了代碼和狀態。狀態存儲在內存中,因此實例化的過程就是把全部值寫入內存的過程。
首先,JS 引擎會建立一個模塊環境記錄(Module Environment Record)。它管理着模塊記錄的全部變量。而後,引擎會找出多有導出在內存中的地址。模塊環境記錄會跟蹤每一個導出對應於哪一個內存地址。
這些內存地址此時尚未值,只有等到運行後它們纔會被填充上實際值。有一點要注意,全部導出的函數聲明都在這個階段初始化,這會使得後面的運行階段變得更加簡單。
爲了實例化模塊關係圖,引擎會採用深度優先的後序遍歷方式。
即,它會順着關係圖到達最底端沒有任何依賴的模塊,而後設置它們的導出。
最終,引擎會把模塊下的全部依賴導出連接到當前模塊。而後回到上一層把模塊的導入連接起來。
這個過程跟 CJS 是不一樣的。在 CJS 中,整個導出對象在導出時都是值拷貝。
即,全部的導出值都是拷貝值,而不是引用。
因此,若是導出模塊內導出的值改變了,導入模塊中導入的值也不會改變。
相反,ESM 則使用稱爲實時綁定(Live Binding)的方式。導出和導入的模塊都指向相同的內存地址(即值引用)。因此,當導出模塊內導出的值改變後,導入模塊中的值也實時改變了。
模塊導出的值在任什麼時候候均可以能發生改變,可是導入模塊卻不能改變它所導入的值,由於它是只讀的。
舉例來講,若是一個模塊導入了一個對象,那麼它只能改變該對象的屬性,而不能改變對象自己。
ESM 採用這種實時綁定的緣由是,引擎能夠在不運行任何模塊代碼的狀況下完成連接。後面會解釋到,這對解決運行階段的循環依賴問題也是有幫助的。
實例化階段完成後,咱們獲得了全部模塊實例,以及已完成連接的導入、導出值。
如今咱們能夠開始運行代碼而且往內存空間內填充值了。
最後一步是往已申請好的內存空間中填入真實值。JS 引擎經過運行頂層代碼(函數外的代碼)來完成填充。
除了填充值之外,運行代碼也會引起一些反作用(Side Effect)。例如,一個模塊可能會向服務器發起請求。
由於這些潛在反作用的存在,因此模塊代碼只能運行一次。
前面咱們看到,實例化階段中發生的連接能夠屢次進行,而且每次的結果都同樣。可是,若是運行階段進行屢次的話,則可能會每次都獲得不同的結果。
這正是爲何會使用模塊映射的緣由之一。模塊映射會以 URL 爲索引來緩存模塊,以確保每一個模塊只有一個模塊記錄。這保證了每一個模塊只會運行一次。跟實例化同樣,運行階段也採用深度優先的後序遍歷方式。
那對於前面談到的循環依賴會怎麼處理呢?
循環依賴會使得依賴關係圖中出現一個依賴環,即你依賴我,我也依賴你。一般來講,這個環會很是大。不過,爲了解釋好這個問題,這裏咱們舉例一個簡單的循環依賴。
首先來看下這種狀況在 CJS 中會發生什麼。
最開始時,main
模塊會運行 require
語句。緊接着,會去加載 counter
模塊。
counter
模塊會試圖去訪問導出對象的 message
。不過,因爲 main
模塊中還沒運行到 message
處,因此此時獲得的 message
爲 undefined
。JS 引擎會爲本地變量分配空間並把值設爲 undefined
。
運行階段繼續往下執行,直到 counter
模塊頂層代碼的末尾處。咱們想知道,當 counter
模塊運行結束後,message
是否會獲得真實值,因此咱們設置了一個超時定時器。以後運行階段便返回到 main.js
中。
這時,message
將會被初始化並添加到內存中。可是這個 message
與 counter
模塊中的 message
之間並無任何關聯關係,因此 counter
模塊中的 message
仍然爲 undefined
。
若是導出值採用的是實時綁定方式,那麼 counter
模塊最終會獲得真實的 message
值。當超時定時器開始計時時,main.js
的運行就已經完成並設置了 message
值。
支持循環依賴是 ESM 設計之初就考慮到的一大緣由。也正是這種分段設計使其成爲可能。
等到 2018 年 5 月 Firefox 60 發佈後,全部的主流瀏覽器就都默認支持 ESM 了。Node 也正在添加 ESM 支持,爲此還成立了工做小組來專門研究 CJS 和 ESM 之間的兼容性問題。
因此,在將來你能夠直接在 <script>
標籤中使用 type="module"
,而且在代碼中使用 import
和 export
。
同時,更多的模塊功能也正在研究中。
好比動態導入提案已經處於 Stage 3 狀態;import.meta
也被提出以便 Node.js 對 ESM 的支持;模塊定位提案 也致力於解決瀏覽器和 Node.js 之間的差別。
相信在不久的將來,跟模塊一塊兒玩耍將會變成一件更加愉快的事!