本文介紹了 ES Modules (ESM) 在瀏覽器環境中的運行原理,詳細闡述了 ESM 的三大載入步驟:構建、實例化、求值,並討論了其動態載入能力、迴圈依賴處理方式及與 CommonJS 的區別。 ...
ESM 通過 import
語句引入其它依賴,通過 export
語句導出模塊成員。
在瀏覽器環境中,<script>
可以通過聲明 type="module"
將一個 JS 文件標記為模塊,帶有 type="module"
聲明的<script>
類似於啟用了 defer
,腳本文件的下載不會阻塞HTML渲染,代碼內容會被延後執行。
這篇文章僅討論瀏覽器環境下的 ESM。
概括
ES模塊的載入主要分為三個步驟:
- 構建 Construction:
- 找到入口文件;
- 根據
import
語句遞歸構建依賴圖; - 下載模塊腳本文件,並文件轉換為 Module Record。
- 實例化 Instantiation:
- 為模塊導出的成員申請記憶體空間;
- 建立
import
和export
之間的鏈接;
- 求值 Evaluation:
- 運行模塊代碼;
- 向記憶體中的成員填充實際的值。
模塊載入過程
步驟1 構建
構建過程的作用在於:構建依賴圖,以及瞭解各個模塊之間import/export
的成員(靜態)。
路徑解析與文件下載
在代碼中我們使用的模塊通常是相對路徑,path resolver
負責將相對路徑轉換為文件的絕對路徑,從而可以讓瀏覽器去下載模塊文件。
轉換為模塊記錄
當模塊文件下載到瀏覽器本地之後,瀏覽器會對模塊文件進行靜態解析,從模塊代碼文件總結出一個模塊記錄(Module Record),可以理解為是模塊的元數據。
一個模塊記錄大致包含瞭如下信息:
模塊文件的源代碼,以及根據源代碼構建的 AST;
該模塊依賴的其它模塊;
從其它模塊分別導入了哪些成員。
緩存機制
在瀏覽器中,一個標簽頁會維護一個模塊緩存映射表,它的 key 是模塊解析後的實際路徑,它的 value 是模塊記錄(Module Record)。
當模塊文件的路徑被解析完成之後,它就會被添加到緩存中,而在“完成路徑解析”和“轉換為模塊記錄”這段時間內,它的 value 會被標記為 fetching。
遞歸
場景描述:
- 用戶訪問
https://www.example.com/index.html
,返回的 HTML 文件包含模塊入口腳本文件
<script src="main.js" type="module"/>
- 相對路徑
main.js
被解析為絕對路徑https://www.example.com/main.js
,然後瀏覽器開始下載文件(此時這個模塊路徑已經被記錄到緩存了,標記為 fetching); - 文件下載到瀏覽器本地之後,靜態解析代碼,捕獲
import
語句(import
語句會被預設提升到代碼頂部),解析結果得到模塊記錄(Module Record),模塊記錄會被更新到緩存里; - 模塊記錄包含依賴的其它模塊,此時瀏覽器會遞歸地解析它們的路徑,並下載它們的腳本文件(由上圖紅色箭頭標明)。
在這個過程中,網路請求下載腳本文件占據了大部分的時間開銷。
複雜的依賴關係可能導致初始化構建過程過久,影響首屏時間。
常用的優化手段是使用動態import,在運行時按需引入指定的模塊。
動態載入
語法
import('./dynamic-module.js').then(module => {
console.log(module.default);
console.log(module.xxx);
});
import(`./module-${moduleName}.js`).then(module => {
// ...
});
import
函數的參數是模塊的文件路徑,返回一個 Promise
對象,通過 then
方法可以獲取到模塊對象。
模塊對象包含模塊導出的成員,預設導出使用default
屬性獲取。
應用場景:
- 模塊懶載入,優化首屏時間;
- 根據不同邏輯載入不同的模塊,所需的模塊是在運行時才確定的。
步驟2 實例化
實例化的主要作用是為模塊的state
分配記憶體空間,此時僅作記憶體的分配,state
的值在這一刻還不確定。
瀏覽器會以 深度優先,後序遍歷 的方式遍歷依賴圖,為每一個模塊 export 的成員分配記憶體空間。
當模塊的所有 export 完成記憶體分配之後,會開始將 import 鏈接到相應的記憶體地址。
這意味著 export 導出的成員和 import 引入的成員指向同一處記憶體空間。基礎數據類型也是如此。
特點:
- 模塊內部更新
state
,外部的state
也隨之變化(因為它們指向同一塊記憶體); - 模塊導出的
state
是只讀的。
這種現象和 CommonJS 存在很大區別,CommonJs 在導入模塊成員的時候,是對模塊的導出進行了拷貝。
這意味著在使用模塊導出的
state
時,要註意其數據是否是最新的,因為模塊內部和外部的state
是相互獨立的,內部更新state
並不會影響到外部的state
。不過這種情況一般比較少發生,我們很少直接導出一個基本數據類型,而是導出一個對象,對象內部再記錄這些基本數據類型。由於導出的是對象,只要模塊內部不要直接覆蓋整個對象,而是對對象的屬性進行更新,就不會有太大問題。
步驟3 求值
步驟1和2完成之後,模塊的成員已經完成了記憶體的分配,以及 import/export 之間的鏈接。
最後需要完成的,就是運行模塊代碼,並將成員的值填入先前分配的記憶體中。
模塊代碼中可能存在一些帶有副作用的代碼,為了避免每一次執行都會導致模塊的 exports 發生變化,模塊代碼只會被執行一次。
迴圈依賴
迴圈依賴是所有模塊化方案都要討論的問題。
案例
實際項目中,依賴圖是很複雜的,導致迴圈依賴的環可能包含了許多模塊。這裡僅討論最簡單的情況,即兩個模塊相互依賴對方。
CommonJS
假設main.js
是入口文件。
main.js
const num = require('./a.js');
console.log(num);
exports.message = 'main';
a.js
const { message } = require('./main.js');
module.exports = 123;
setTimeout(()=>console.log(message), 0);
我們期待在main.js
中輸出的num
為123,而在a.js
中輸出的message
為 main;而實際運行結果是:
123
undefined
CommonJS 的 require 函數是同步地載入模塊,並且一次性完成,不像ESM分為三個步驟。
如上圖,當代碼執行到 ① 時,執行require
函數,解析路徑、記錄到緩存中、讀取模塊文件、執行模塊代碼(步驟②)。
由於 CommonJS 的同步特性,它不能直接運行於瀏覽器環境,這裡討論的 Node.js 環境下的模塊載入。
在執行步驟②的過程中,main.js
導出的成員還沒有賦值,此時的module.exports
是一個空對象。
但是由於 CommonJS 是在模塊的路徑解析階段就記錄了緩存,因此步驟②的require
函數可以得到模塊main.js
的module.exports
,只不過此時的module.exports
還是空對象。
由於它此時還是空對象,因此解構賦值出來的message
是undefined
。
我們期待等步驟③這些同步代碼執行完成之後,message
應該就會更新為main
了,於是我們在a.js
中,使用setTimeout
來將任務推入巨集任務隊列中,延後執行。
但結果是,儘管main.js
中的message
被賦值了,a.js
中的message
也不會被更新。這是因為在導入的時候進行了拷貝,所以兩個message
是相互獨立的。
ESM
main.js
import num from './a.mjs';
console.log(num);
export const message = 'main';
a.js
import { message } from "./main.mjs";
export default 123;
setTimeout(()=>console.log(message), 0);
由於 ESM 的 import/export 是被鏈接到同一塊記憶體區域的,因此當 main.js
賦值message
之後,a.js
中的message
也會更新為 main
。
輸出結果:
123
main
在瀏覽器環境下,為了使用 ESM 語法,入口腳本文件需要標明
type="module"
。在 Node.js 環境下,為了表明文件是使用 ES 模塊化語法,需要將文件尾碼改為
.mjs
,或者在package.json
中配置type
為module
。
總結
ES Modules (ESM) 是一種現代模塊化方案,具備以下特點和優勢:
-
模塊化聲明:
- 使用
import
和export
語句實現模塊的引入與導出。 - 在瀏覽器中通過
<script type="module">
標簽載入,不阻塞 HTML 渲染。
- 使用
-
載入過程:
- 構建:遞歸構建依賴圖並下載模塊。
- 實例化:為導出的成員分配記憶體空間,建立
import
和export
的鏈接。 - 求值:運行模塊代碼,填充記憶體中的成員值。
-
與 CommonJS 對比:
特性 ESM CommonJS 載入方式 非同步載入,不阻塞渲染 同步載入 導入成員機制 共用同一記憶體空間,實時更新 拷貝機制,數據獨立 瀏覽器支持 原生支持 <script type="module">
僅支持 Node.js 環境 -
優勢:
- 原生支持 動態載入。
- 解決 迴圈依賴 問題,確保模塊成員實時更新。
引用
[1] ES modules: A cartoon deep-dive - Mozilla Hacks - the Web developer blog