ES modules 給 JavaScript 帶來了一個官方的規範的模塊化系統。將近花了10年的時間才完成了這個標準化的工做。node
咱們的等待即將結束。隨着 Firefox 60 在今年5月的發佈(目前是測試階段),全部的主流瀏覽器都將支持 ES modules,與此同時,Node modules 工做小組目前正在嘗試讓 Node.js 可以支持 ES module。另外的,針對 WebAssembly 的 ES module 整合也正在進行。git
衆多 JS 開發者都知道 ES modules 至今已經飽受爭議。可是不多有人真正知道 ES modules 究竟是如何工做的。github
讓咱們一塊兒來看一下,ES modules 解決了什麼問題,以及它究竟和其餘模塊化系統有什麼區別。web
當咱們在寫 JS 代碼的時候會去思考通常如何處理變量。咱們的操做幾乎徹底是爲了給變量進行賦值或者是去將兩個變量相加又或者是去將兩個變量鏈接到一塊兒而且將它們賦值給另一個變量。算法
因爲咱們大部分的代碼都僅僅是爲了去改變變量的值,你如何去組織這些變量將對你寫出怎樣的代碼以及如何更好的去維護這些代碼產生巨大的影響。api
一次只用處理少許的變量將會讓咱們的工做更容易。JS 自己提供了一種方法去幫助咱們這麼作,叫做 做用域。因爲 JS 中做用域的緣由,在每一個函數中不能去使用其餘函數中定義的變量。瀏覽器
這很棒!這意味着當你在一個函數中編碼時,你只須要考慮當前這個函數了。你沒必要再擔憂其餘函數可能會對你的變量作什麼了。緩存
雖然是這樣沒錯,可是它也有很差的地方。這會讓你很難去在不一樣的函數之間去共享變量。數據結構
假使你確實想要在做用域外去共享你的變量,將會怎麼樣呢?一個經常使用的作法是去將它們放在一個外層的做用域。舉個例子來講,全局做用域。
你可能還記得下面這個在 jQuery 中的操做。在你加載 jQuery 以前,你不得不去把 jQuery 引入到全局做用域。
ok,能夠正常運行了。可是這裏存在相同的有爭議的問題。
首先,你的 script 標籤須要按正確的順序擺放。而後你不得不很是的謹慎去確認沒有人會去改變這個順序。
若是你搞砸了這個順序,而後你中間又使用到了前面的依賴,你的應用將會拋出一個錯誤~你函數將會四處查找 jQuery 在哪兒呢?在全局嗎?而後,並無找到它,它將會拋出一個錯誤而後你的應用就掛掉了。
這將會讓你的代碼維護變得很是困難。這會使你在刪除代碼或者刪除 script 標籤的時候就像在搖色子同樣。你並不知道這個何時會崩潰。不一樣代碼之間的依賴關係也不夠明顯。任何的函數都可以使用在全局的東西,因此你不知道哪些函數會依賴哪些 script 文件。
第二個問題是由於這些變量存在於全局做用域,全部的代碼都存在於全局做用域內,而且能夠去修改這些變量。多是去讓這些變量變成惡意代碼,從而故意執行非你本意的代碼,還有多是變成非惡意的代碼可是和你的變量有衝突。
模塊化給你了一個方式去組織這些變量和函數。經過模塊化,你能夠把變量和函數合理的進行分組歸類。
它把這些函數和變量放在一個模塊的做用域內。這個模塊的做用域可以讓其中的函數一塊兒分享變量。
可是不像函數的做用域,模塊的做用域有一種方式去讓它們的變量能過被其餘模塊所用。它們可以明確的安排其中哪些變量、類或者函數能夠被其餘模塊使用。
當某些東西被設置成能被其餘模塊使用的時候,我須要一個叫作 export 的函數。一旦你使用了這個 export 函數,其餘的模塊就明確的知道它們依賴於哪些變量、類或者函數。
由於這是一個明確的關係。一旦你想移除一個模塊時,你能夠知道哪個模塊將會被影響。
當你可以去使用 export 和 import 去處理不一樣模塊之間的變量時,你將會很容易的將你的代碼分紅一些小的部分,它們之間彼此獨立的運行。而後你能夠組合或者重組這些部分,就像樂高積木同樣,去在不一樣的應用中引用這些公用的模塊。
因爲模塊化真的很是有用,因此這裏有不少嘗試去在 JS 中添加一些實用的模塊。時至今日,有兩個比較經常使用的模塊化系統。一個是 Node.js 一直以來使用的 CommonJS。還有一個是晚一些可是專門爲 JS 設計的 ES modules。瀏覽器端已經支持 ES modules,與此同時,Node 端正在嘗試去支持。
讓咱們一塊兒來深刻了解一下,這個新的模塊化系統究竟是如何進行工做的。
當你在開發這些模塊時,你創建了一個圖。
瀏覽器或者 Node 是經過這些引入聲明,才明確的知道你須要加載哪些代碼。你須要建立一個文件做爲這個依賴關係的入口。以後就會根據那些 import 聲明去查找剩餘的代碼。
可是這些文件不能直接被瀏覽器所用,這些文件會被解析成叫作模塊記錄的數據結構。
以後,這個模塊記錄將會被轉變成一個模塊實例。一個模塊實例是由兩部分組成:代碼和狀態。
代碼是這一列指令的基礎。它就像該如何去作的引導。可是隻憑它你並不能作什麼。你須要材料纔可以去使用這些引導。
什麼是狀態?狀態給你提供了材料!在任什麼時候候,狀態都會爲你提供這些變量真實的值。固然這些變量都僅僅只是做爲內存中存儲這些值的別名而已(引用)。
模塊實例將代碼(一系列的引導)和狀態組合起來(全部變量在內存中的值)。
咱們須要的是每一個模塊擁有本身的模塊實例。模塊的加載過程是經過入口文件,找到整個模塊實例的關係表。
對於 ES modules 來講,這個過程須要三步:
人們都說 ES modules 是異步的。你徹底能夠將它想成異步的,由於整個流程被分紅三個不一樣的階段——加載,實例化以及求值——還有,這些步驟都是被分開執行的。
這就意味着,這個規則是一種異步的並且不從屬於 CommonJS。我將在稍後解釋它,在 CommonJS 中,一個模塊的依賴是在模塊加載以後才馬上進行加載、實例化、求值的,中間不會有任何的打斷(也就是同步)。
不管如何,這些步驟自己並不必定是異步的。它們能夠被同步處理。這就依賴於加載的過程取決於什麼?那是由於並非全部的東西都尊崇於 ES modules 規範。這實際上是兩部分工做,從屬於不一樣的規範。
ES module 規範闡述了你應該如何將這些文件解析成模塊記錄,以及你應該如何去實例化和進行求值。可是,它沒有說明如何去首先得到這些文件。
獲取這些文件有相應的加載器,在不一樣的說明中,加載器都被明肯定義了。對於瀏覽器,它的規範是HTML spec。可是你能夠在不一樣平臺使用不一樣的加載器。
加載器一樣明確指出了控制模塊應該如何被加載。這被稱做 ES 模塊方法 —— ParseModule
,Module.Instantiate
,以及Module.Evaluate
。這就像JS 引擎操縱的木偶同樣。
如今咱們來一塊兒探尋每一步到底發生了什麼。
構建階段每個模塊發生了三件事。
加載器將會盡量的去找到文件而後去下載它。首先要去找到入口文件。在 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去這麼設計的緣由之一。正是這三個階段讓這一切變得可能。
隨着 Firefox 60 在今年五月早期發佈,全部的主流瀏覽器都將默認支持 ES modules。Node 也將會支持這種方式,工做組正在嘗試去讓 CommonJS 和 ES modules 進行兼容。
這就意味着你將能夠去使用 script 標籤 加上type=module
,去使用引入和導出。不管如何,愈來愈多的模塊特性將會可使用。動態引入的提案已經明確進入 Stage 3 階段,同時import.meta提案將會讓 Node.js 支持這種寫法。[解決模塊問題的提案](module resolution proposal)也將平滑的同時支持瀏覽器和 Node.js。因此大家期待一下將來模塊化的工做會作的愈來愈好。 翻譯原文