[JS] ES Modules的運作原理
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


浙公網安備 33010602011771號