本文參考 https://hacks.mozilla.org/201...,建議你們讀原文。html
ES6發佈了官方的,標準化的Module特性,這一特性花了整整10年的時間。可是,在這以前,你們也都在模塊化地編寫JS代碼。好比在server端的NodeJS,它是對CommonJS的一個實現;Require.js則是能夠在瀏覽器使用,它是對AMD的一個實現。git
ES6官方化了模塊,使得在瀏覽器端再也不須要引入額外的庫來實現模塊化的編程(固然瀏覽器的支持與否,這裏暫不討論)。ES Module的使用也很簡單,相關語法也不多,核心是import和export。可是,對於ES module究竟是如何工做的,它又和以前的CommonJS和AMD有什麼差異呢?這是接下來將要討論的內容。github
一:沒有模塊化的編程存在什麼問題?web
編寫JS代碼,主要是對於對變量的操做:給變量賦值或者變量之間進行各類運算。正由於大部分代碼都是對變量的操做,因此如何組織代碼裏面的變量對於如何寫好代碼和代碼維護就顯得相當重要了。算法
當只有少許的變量須要考慮的時候,JavaScript提供了「scope(做用域)」來幫助你。由於在JavaScript裏面,一個function不能訪問定義在別的function裏面的變量。編程
可是,這同時也帶來一個問題,假如functionA想要使用functionB的變量怎麼辦呢?一個通用的辦法就是把functionB的變量放到functionA的上一層做用域。典型的就是jQuery時代,若是要使用jQuery的API,先要保證jQuery在全局做用域。
可是這樣作的問題也不少:api
1: 全部的script標籤必須保證正確的順序,這使得代碼的維護變得異常艱難。 2: 全局做用域被污染。
二:模塊化編程如何解決上面提到的問題?瀏覽器
模塊,把相關的變量和function組織到一塊兒,造成一個所謂的module scope(模塊做用域)。在這個做用域裏面的變量和function之間彼此是可見的。緩存
與function不一樣的是,一個模塊能夠決定本身內部的哪些變量,類,或者function能夠被其餘模塊可見,這個決定咱們叫作「export(導出)」。而其餘的模塊也就能夠選擇性地使用這個模塊導出的內容,咱們經過「import(導入)」來實現。網絡
一旦有了導入和導出,咱們就能夠把咱們的程序按照指責劃分爲一個個模塊,大的模塊能夠繼續劃分爲更小的模塊,最終這些模塊組合到一塊兒,搭建起了咱們整個程序,就像樂高同樣。
三:ES Module的工做原理之Module Instances
當你在模塊化編程的時候,你就會建立一棵依賴樹。不一樣依賴之間的連接來源於你使用的每一條"import"語句。
就是經過這些"import"語句,瀏覽器和Node才知道它們到底要加載哪些代碼。你給瀏覽器或者Node一個依賴樹的入口文件,從這個入口文件開始,瀏覽器或者Node就沿着每一條"import"語句找到下面的代碼。
可是,瀏覽器卻使用不了這些文件。全部的文件都必需要轉變爲一系列被叫作「Module Records(模塊記錄)的數據結構,這樣瀏覽器才能明白這些文件的內容。
在這以後,module record須要被轉化爲「module instance(模快實例)」。一個module instance包含2種東西:code和state。
code就是一系列的操做指令,就像菜單同樣。可是,光有菜單,並不能做出菜,你還須要原材料。而state就是原材料。State就是變量在每個特意時間點的值。固然,這些變量只是內存裏面一個個保存着值的小盒子的小名而已。
而咱們真正須要的就是每個模塊都有一個module instance。模塊的加載就是從這個入口文件開始,最後獲得包含全部module instance的完整圖像。
四:Module Instances的產生步驟
對於,ES Module來講,這須要經歷三個步驟:
1: Construction(構造)- 找到,下載全部的文件而且解析爲module records。 2: Instantiation(實例化)- 在內存裏找到全部的「盒子」,把全部導出的變量放進去(可是暫時還不求值)。而後,讓導出和導入都指向內存裏面的這些盒子。這叫作「linking(連接)」。 3: Evaluation(求值)- 執行代碼,獲得變量的值而後放到這些內存的「盒子」裏。
你們都說ES Module是異步的。你能夠認爲它是異步的,由於這些工做被分紅了三個不一樣的步驟 - loading(下載),instantiating(實例化)和evaluating(求值) - 而且這些步驟能夠單獨完成。
這意味着ES Module規範採用了一種在CommonJS裏面不存在的異步機制。在CommonJS裏面,對於一個模塊和它底下的依賴來講,下載,實例化,和求值都是一次性完成的,步驟相互之間沒有任何停頓。
然而,這並不意味這這些步驟必須是異步的,它們也能夠同步完成。這依賴於「loading(下載)」是由誰去作的。由於,並非全部的東西都由ES module規範控制。事實上,確實有兩部分的工做是由別的規範負責的。
ES module規範 陳述了你應該怎樣把文件解析爲module records,和怎樣初始化模塊以及求值。然而,它卻沒有說在最開始要怎樣獲得這些文件。
是loader(下載器)去獲取到了文件。而loader對於不一樣的規範來講是特定的。對於瀏覽器來講,這個規範是HTML 規範。你能夠根據你所使用的平臺來獲得不一樣的loader。
loader也控制着模塊如何加載。它會調用ES module的方法--ParseModule, Module.Instantiate,和Module.Evaluate。loader就像傀儡師,操縱着JS引擎的線。
如今讓咱們來具體聊一聊每個步驟。
五:Module Instances的產生步驟之Construction
對於每個模塊來講,在這一步會經歷如下幾個步驟
1: 弄清楚去哪裏下載包含模塊的文件(又叫「 module resolution(模塊識別)」) 2: 獲取文件(經過從一個URL下載或者從文件系統加載) 3: 把文件解析爲module record(模塊記錄)
step1: Finding the file and fetching it 找到文件並獲取文件
loader會負責找到文件並下載。首先,須要找到入口文件,在HTML文件裏,咱們經過使用<script>標籤告訴loader哪裏去找到入口文件。
可是,loader如何找到接下來的一系列模塊 - 也就是main.js所直接依賴的哪些模塊呢?這就輪到import語句登場了。import語句的某一部分又被叫作「模塊說明符」。它告訴loader在哪兒能夠找到下一個模塊。
關於「模塊說明符」,有一點須要說明:某些時候,不一樣的瀏覽器和Node之間,須要不一樣的處理方式。每個平臺都有它們本身的方法去詮釋「模塊說明符」字符串。而這經過「模塊識別算法」完成,不一樣的平臺不同。就目前來講,一些在Node環境工做的模塊識別符在瀏覽器裏面並不工做,可是這一狀況正在被處理修復。
而在修復以前,瀏覽器只接受URL做爲模塊標識符。瀏覽器會從那個URL下載模塊文件。可是,對於整個依賴圖來講,在同一時間是不可能的。由於直到解析了這個文件,你才知道這個模塊須要哪些依賴。。。可是,你又不能解析這個文件除非你獲取了它。
這意味着,要解析一個文件,咱們必須一層一層地遍歷這顆依賴樹,理清楚他全部的依賴,而後找到而且下載這些依賴。可是,假如主線程一直在等待這些文件下載,那麼大量的其餘的任務就被卡在隊列裏面。這是由於,在瀏覽器裏面進行下載工做,會耗費大量的時間。
像這樣阻塞主線程,會致使使用了模塊的app太慢了,這也是ES module規範把算法分割成多個步驟的其中一個緣由。把construction(構建)單獨劃分到一個步驟,這就容許瀏覽器能夠在進入到instantiating(實例化)的一系列同步工做以前能夠先獲取文件而且創建模塊之間的依賴樹。
把這個算法分割到不一樣的步驟--正是ES Module和CommonJS module之間的其中一個關鍵區別。
CommonJS能夠作不一樣於ES Module的處理,是由於從文件系統裏面加載文件比從網絡上下載文件要花少得多的時間。這就意味着,Node能夠在加載文件的時候阻塞主線程。又由於文件已經加載好了,那麼實例化和求值(這兩步在CommomJS裏面是沒有分開的)也顯得頗有道理。這意味着,在你返回這個模塊以前,其依賴樹上全部的依賴都完成了loading(加載),instantiating(實例化)和evaluating(求值)。
CommonJS的方法會帶來一些後果,後面會解釋。可是,其中有一點是在Node裏面的CommomJS module, 你能夠在模塊說明符裏面使用變量
。在你尋找下一個模塊以前,你會執行完本模塊的全部代碼。這就意味着當你去作模塊識別的時候,這個變量已經有值了。
可是,在ES Module裏面,你是在任何求值以前先創建了完整的依賴樹。這說明,你不能在模塊說明符裏面使用變量,由於這個變量目前尚未值。
可是動態模塊,在實際生產中又是有用的。因此有一個提議叫作動態導入,能夠用來知足相似這樣的需求:import(
${path}/foo.js).
動態導入的工做原理是,任何使用import()
來導入的文件,都會做爲一個入口文件從而建立一棵單獨的依賴樹,被單獨處理。
但有一點須要注意的是 - 任何同時存在於兩棵依賴樹的模塊都指向同一個模塊實例。這是由於loader把模塊實例緩存起來了。對於每個模塊來講,在一個特定的全局做用域內,只會有一個模版實例。
這對JS引擎來講,就意味着更少的工做量。舉個例子,不管多少模塊依賴着某一個模塊,可是這個模塊文件都只會被獲取一次。loader使用module map來管理這些緩存,每個全局做用域使用獨立的module map來管理各自的緩存。
當loader經過一個URL去獲取文件的時候,它會把這個URL放入module map而且作上「正在獲取」的標誌。而後它發出請求,進而繼續下一個文件的獲取工做。
當別的模塊也依賴同一個文件的時候,會發生什麼呢?Loader會查詢module map裏面的每個URL,若是它看到這個URL有「正在獲取「的標誌,那它就無論了,繼續下一個URL的處理。
module map不僅是看哪一個文件正在被下載,它同時也管理這模塊的緩存,這就是下面的內容。
step2: Parsing
如今咱們已經獲取到了文件,咱們須要把它解析爲一個module record。這有助於瀏覽器理解模塊的不一樣之處是什麼。
一旦module record建立完成,它就會被放到module map裏面去。這意味着不管什麼時候被請求,loader均可以從module map裏面提取它。
在解析的時候,有一個看起來瑣碎可是卻會產生巨大影響的細節:全部的模塊都是在至關於在文件頂部使用了「use strict
」(嚴格模式)下被解析的。除此以外,也還有其餘的一些不一樣,例如:關鍵字await
被保留在模塊的最高層的代碼裏;this
的值是undefined
。
不一樣的解析方法被稱做「解析目標」。假如你用不一樣的解析目標解析同一個文件,你將會獲得不一樣的解析結果。由於,在解析以前,你須要知道將要被解析的文件是不是模塊。
在瀏覽器裏面,這十分簡單。你只須要給<script>標籤加一個type="module"
。這就告訴了瀏覽器這個文件須要被當成是一個模塊來解析。由於只有模塊才能夠被導入,因此瀏覽器知道導入的文件也是模塊。
可是Node不使用HTML相關的標籤,因此沒法使用type來表示。而在Node裏面是經過文件的擴展名".mjs"來代表這是一個ES Module的。
無論是哪一種方式,最終都是loader來決定這個文件是否看成一個模塊來解析。假如它是一個module或者有import
,那就會開始這個進程,直到全部的文件被下載和解析。
這一步驟就結束了。在加載進程結束以後,咱們就從擁有一個入口文件到最後擁有一系列的module record。
下一步就是實例化這些模塊,而且把全部的實例連接起來。
六:Module Instances的產生步驟之Instantiation
如我以前提過的那樣,一個實例結合了code和state。state存在於內存中,所以實例化這一步就是關於怎樣把東西連接到內存裏面的。
首先,JS引擎建立了一個「模塊環境記錄(module environment record)」。它管理着module record的變量,而後它在內存裏面找到全部導出(export)的變量的「盒子」。module environment record會一直監控着內存裏面的哪一個盒子和哪一個export是相關聯的。
這些內存裏面的盒子尚未得到它們的值,只有在求值這一步驟完成以後,真正的值纔會被填充進去。可是這裏有個小小的警告:任何導出的function定義,都是在這一步初始化的,這使得求值變得相對簡單一些。
爲了實例化模塊圖(module graph),JS引擎會作一個所謂的「深度優前後序遍歷」的操做。意思就是說,JS引擎會先走到模塊圖的最底層--找到不依賴任何其餘模塊的那些模塊,而且設置好它們的導出(export)。
當JS引擎完成一個模塊的全部導出的連接,它就會返回上一個層級去設置來自於這個模塊的導入(import)。須要注意的是,導出和導入都是指向同一片內存地址。先連接導出保證了全部的導入都能找到對應的導出。
這和CommonJS的模塊不一樣。在CommonJS,導入的對象是基於導出拷貝的。這就意味着導出的任何的數值(例如數字)都是拷貝。這就意味着,若是導出模塊在以後修改了一些值,導入的模塊並不會被同步到這些修改。
於此相反的是,ES module使用所謂的「實時綁定」,導出的模塊和導入的模塊都指向同一段內存地址。若是,導出模塊修改了一個值,那麼這個修改會在導入模塊裏面也獲得體現。
導出值的模塊能夠在任什麼時候間修改這些值,可是導入模塊卻不能修改它們導入的值。意思就是,若是一個模塊導出了一個對象(object),那它能夠修改這個對象的屬性值。
「實時綁定」的好處是,不須要跑任何的代碼,就能夠連接起全部的模塊。這有助於當存在循環依賴狀況下的求值。
在這一步的最後,咱們使得全部的模塊實例導出/導入的變量的內存地址連接起來了。
接下來,咱們就開始對代碼求值,而且把獲得的值填入對應的內存地址中。
七:Module Instances的產生步驟之Evaluation
最後一步是把值都填入內存地址中。JS引擎經過執行最上層的代碼-也就是function之外的代碼,來實現這一目的。
除了往內存地址裏面填值,對代碼求值有可能也會觸發反作用。舉個例子,一個模塊有可能會向server作請求。由於這個反作用,你只想求模塊求值一次。和在實例化階段的連接不管執行多少次都會獲得同一個結果不一樣,求值會根據你進行了多少次求值操做而獲得不一樣的結果。
這也是爲何須要module map。Module map根據URL來緩存模塊,由於每個模塊都只有一個module record,這也保證了每個模塊只會被執行一次。和實例化同樣,求值也是按照深度優先倒序的規則來的。
在一個循環依賴的狀況下,最終會在依賴樹裏獲得一個環,爲了僅僅是說明問題,這裏就用一個最簡單的例子:
咱們先來看看CommonJS,它是怎麼工做的。首先,main模塊會執行到require語句,而後進入到counter模塊。Counter模塊嘗試去從訪問導出的對象裏面的message變量。可是,由於這個變量尚未在main模塊裏面被求值,因此會返回undefined。JS引擎會在內存裏面爲這個本地變量開闢一段地址並把它的值設置爲undefined。
求值一直繼續到counter模塊的最底部。咱們想知道最終是否能獲得message的值(也就是main模塊求值以後),因而咱們設置了一個timeout。而後,一樣的求值過程在main模塊從新開始。
message變量會被初始化而且放到內存中。可是,由於這二者之間已經沒有任何連接,因此在counter模塊裏,message變量會保持爲undefined。
假如這個導出是用「實時綁定」處理的,counter模塊最終就能獲得正確的值。到timeout執行的時候,main模塊的求值就完成了而且獲得最終的值。
支持循環依賴,是ES module設計的一個重要基礎。正是前面的「三個階段」使得這一切成爲可能。