ESModule 加載與運行機制
ESModule 作爲 JS 的標準模塊機制,在日常開發中被廣泛使用,但在大部分情況下,我們可能只是將其作爲 JS 代碼文件的組織形式來對待。作爲 JS 的模塊規範,ESModule 底層其實有一套非常完善的機制,來確保 ESModule 在不同場景下的性能以及行爲的確定性。本文的主要內容是關於 ESModule 加載運行的相關原理和機制的分享,在理解了相關的原理和機制之後,你將會對平常在使用 ESModule 過程中遇到的一些問題(比如:循環引用在什麼情況下會報錯、TreeShaking 的原理等)有更加深入的理解。
從一個循環依賴例子說起
下面用一個包含循環引用的例子來分享 ESModule 的加載和執行過程
// main.mjs
import { mod1Fn } from './mod1.mjs'
import { mod2Fn } from './mod2.mjs'
mod1Fn('main')
mod2Fn('main')
// mod1.mjs
import './mod2.mjs'
export let mod1Value = 'mod1Value'
export function mod1Fn(from) {
console.log(`${from} call mod1Fn\n`)
}
// mod2.mjs
import { mod1Fn, mod1Value } from './mod1.mjs'
export function mod2Fn(from) {
console.log(`${from} call mod1Fn\n`)
console.log('log mod1Value in mod2Fn')
console.log(mod1Value)
}
mod1Fn('mod2')
mod2Fn('mod2')
以上的代碼內容分別描述了 main.mjs、mod1.mjs、mod2.mjs 3 個文件的內容,下面我們通過 node 來運行以上的代碼,執行 node index.mjs ,輸出結果如下
可以看出循環引用在 ESModule 中是可用的,但如果我們在 mod2.mjs 中增加一行調用 mod2Fn 的代碼,如下所示:
然後再執行代碼,會發現實際執行會報錯:
以上的報錯是否似曾相識,從報錯的信息我們大致可以推斷出這個是一個和變量提升有關的報錯,實際上報錯的根因是在執行 mod2Fn('mod2') 時,mod1Value 還沒有完成初始化,類似是下面這樣的情況:
console.log(mod1Value)
let mod1Value = 'mod1Value'
但是從直觀上看,mod2.mjs 是在 mod1.mjs 之後加載的,爲什麼 mod1Value 會沒有初始化呢,會不會是因爲 import 的位置在 mod1Value 之前的原因,導致沒有完成初始化呢,但實際上,即使將 mod2 的 import 後置,比如以下的代碼
仍然還是會報錯,因此我們可以得出報錯的原因和 import 的位置是無關的。實際上 ESModule 的加載和執行過程並不是簡單的一個按順序執行的流程,下面我們從底層原理的角度,分享一下 ESModule 實際是如何被加載和執行的,以及會出現以上報錯的原因。
ESModule 加載和執行過程解析
ESModule 的加載和解析過程整體上可以拆分爲三個步驟:
-
獲取代碼和解析:建立模塊之間連接
-
實例化模塊:完成變量聲明和環境對象 (enviroment object) 的綁定
-
執行代碼:按照深度優先的順序,逐行執行代碼
下面我們還是以上面的代碼爲例,分享實際的過程
獲取代碼和解析:建立模塊之間連接
首先瀏覽器或者 Node 等應用程序會通過網絡請求或文件讀寫等形式獲取到對應的 ESModule 的代碼,比如上文中的代碼 node main.mjs Node 會逐步執行以下操作:
-
通過文件讀寫的方式,讀取 main.mjs 的文件內容,記錄到 ESModule 中
-
逐行解析 JS 代碼,獲取依賴的 ESModule 的地址
-
然後繼續加載對應依賴的模塊,重複第一步的操作,直到所有的 ESModule 都完成了加載
在完成這一步之後,我們會得到下面這樣一張圖
值得關注的是,以上的這些過程 JS 代碼還沒有被執行,是通過解析代碼文本的方式,完成了模塊的依賴解析和加載,以及建立模塊之間的依賴關係。
實例化模塊:完成聲明和環境對象 (enviroment object) 的綁定
上面一步完成了模塊之間的依賴關係生成,接下來實例化本質上是更進一步,完成每個模塊內部的變量的聲明以及構建模塊間的引用關係。在這個過程中有幾個要注意的點:
-
每個模塊都會有各自環境對象且相互隔離,這也是不同模塊可以有相同的名字的函數、變量而不會衝突的原因
-
function、var 的變量提升的特性在這個場景下也適用,function 會直接完成初始化,var 則會初始化爲 undefined
還是以上面的例子爲例,完成實例化之後我們會得到以下的依賴關係,具體的結果可見下圖:
綁定的過程會有兩種形式:
-
使用 import 的方式引入的,則會在當前模塊生成一個間接綁定,指向對應的來源對象,比如 index.mjs 中的 mod1Fn
-
如果是在當前模塊聲明的,則直接綁定到當前的對象,比如 mod1.mjs 中的 mod1Fn
同樣在這一個步驟,JS 代碼仍然沒有進入執行階段
執行代碼:按照深度優先的順序,逐行執行代碼
接下來進入實際的執行代碼階段,也是 JS 引擎開始執行代碼的時機。整體的執行策略會遵循兩個大的規則:
-
按照深度優先的順序,首先執行最深的依賴的模塊代碼
-
每個模塊的代碼只會被執行一次
由第 1 階段的依賴關係圖,我們可以看出,依賴最深的是 mod2.mjs,所有 JS 引擎會先執行 mod2.mjs 中的代碼,即:
然後根據第 2 階段實例化代碼得的到綁定關係圖,會先執行以下紅框中的部分
從上往下依次執行 mod2 中的代碼,在執行到 12 行 mod2Fn('mod2') 時,**mod2Fn 在第 7 行依賴了 mod1Value,而由上圖我們可以看到,mod1Value 的狀態是還未初始化,因此在執行 console.log(mod1Value) 時,代碼會拋出沒有初始化的錯誤。**如果將 mod1.mjs 中的 let mod1Value 改爲 var mod1Value,由於 var 天然有變量提升的特性,會先初始化爲 undefined,實際運行時不會報錯,會輸出 undefined。
我們可以看出 ESModule import 不是簡單的類似 require 的同步加載機制,下面我們來分析一下相比於同步的加載方式,ESModule 的加載策略上有哪些優勢。
ESModule 加載運行策略相比於同步的方式又哪些優勢
能夠用更快的速度併發加載代碼資源
在 Web 領域,網絡的加載耗時一直是用戶體驗非常重要的影響因子,在 ESModule 的策略下,實際模塊的依賴的解析不需要依賴代碼的執行,而是直接通過靜態分析的方式進行,這使得瀏覽器、Node 等應用可以用盡可能快的速度完成依賴的收集和資源的請求,而不會受具體模塊代碼執行耗時以及前後順序的影響,可以使用盡可能多的併發請求來快速完成加載。
同時從最開始的例子中可以看出,在 ESModule 中 import 在文件的中的位置不會影響具體的行爲表現,這使得瀏覽器可以進行類似 HTML 流式渲染一樣,對 ESModule 進行 “流式加載”,比如一個 JS 文件有 1000 行,如果第一行寫了一個 import,瀏覽器就可以直接進行對應模塊的加載,而無需等待文件加載完成在進行下一個模塊的加載。
支持 TreeShaking 的自動優化
ESModule 在實例化的階段會完成相關變量的聲明和綁定,在這個階段我們可以得到對應的綁定關係圖,比如之前例子中的以下這張圖
通過這張圖我們可以明確的看出 mod2Value 沒有被其他模塊所引用,從而我們只需要判斷在 mod2 內也沒有使用 mod2Value ,則 mod2Value 相關的代碼是無用的,這也是 TreeShaking 的原理。而在這一階段,實際的代碼還沒有被執行,以上的依賴關係完全是按照代碼文本的靜態分析得出,所以這也保證了,我們在構建時也可以模擬瀏覽器或 Node 進行類似的操作,生成對應的依賴關係圖,然後針對單個模塊分析哪些方法或變量時沒有用,對代碼進行自動的刪減。
結語
本文簡要的介紹了 ESModule 加載和執行的整體過程,在研究的過程也深刻感受到了 ESModule 整體規範的嚴謹性和完善性,考慮了諸多不同的方面,並不是簡單的 CommonJS 的升級版本。對於底層原理更加深入的理解,也能夠指導我們在使用一些新的技術能夠更有方向性,比如 TreeShaking ,實際上就是充分利用了 ESModule 的規範,實現了非常優雅的代碼自動剔除,能夠保證幾乎 0 成本的代碼體積最優。實際上 ESModule 還有很多其他內容,比如 dynamic import、top level await 等,可以值得更多的研究和探索。
最後,本文略去了部分加載和執行過程的細節,對細節感興趣的同學,推薦一篇博客,https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
作者 | 川古
編輯 | 橙子君
出品 | 阿里巴巴新零售淘系技術
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/wxUz5E1Xs5dqYFPRPOnAlw