背景
大力教育的在線教室中臺提供封裝了核心能力的教室 SDK,業務方基于教室 SDK 開發面向用戶的在線教室 App。最近對教室 SDK 做一次比較大的改動時,我遇到了一個懵逼的問題。這個問題耗費了我 3 天左右時間,讓我壓力一度大到全身發熱。當時雖然解決了問題,但并沒有很理解原因。直到一個多月后,才有時間做一些更深入的分析,并寫下這篇文章。
當時的情況是,業務方 App 工程能通過 TypeScript 編譯,但在運行時會報錯。就不同的使用教室 SDK 的方式,報錯有兩種。圖 1 為在業務方 App 工程里正常安裝教室 SDK 后進行調試時的報錯;圖 2 為在業務方 App 工程里 yarn link 教室 SDK 后進行調試時的報錯。




在分析這個問題前,需要先分析一下 JS(JavaScript)的模塊機制。
CommonJS vs ES6 模塊
CommonJS 與 ES6(ECMAScript 6)模塊有什么區別呢?《ECMAScript 6 入門教程 》[1]一書在“Module 的加載實現”章節指出兩個模塊體系有三個重大差異。個人覺得這三個差異基本是錯誤的,給大家造成了不少誤解。后面再講質疑的理由,這里先拋出我總結的幾點差異:
- CommonJS 模塊由 JS 運行時實現,ES6 模塊借助 JS 引擎實現;ES6 模塊是語言層面的底層的實現,CommonJS 模塊是之前缺失底層模塊機制時在上層做的彌補。從報錯信息可以察覺這個差異。
- CommonJS 模塊同步加載并執行模塊文件,ES6 模塊提前加載并執行模塊文件。CommonJS 模塊在執行階段分析模塊依賴,采用深度優先遍歷(depth-first traversal),執行順序是父 -> 子 -> 父;ES6 模塊在預處理階段分析模塊依賴,在執行階段執行模塊,兩個階段都采用深度優先遍歷,執行順序是子 -> 父。
- CommonJS 模塊循環引用使用不當一般不會導致 JS 錯誤;ES6 模塊循環引用使用不當一般會導致 JS 錯誤。
- CommonJS 模塊的導入導出語句的位置會影響模塊代碼執行結果;ES6 模塊的導入導出語句位置不影響模塊代碼語句執行結果。
為了方便說明,本文把 JS 代碼的運行大致分為預處理和執行兩個階段,注意,官方并沒有這種說法。下面進行更細致的分析。
CommonJS 模塊
在 Node.js 中,CommonJS 模塊[2]由 cjs/loader.js[3] 實現加載邏輯。其中,模塊包裝器是一個比較巧妙的設計。
在瀏覽器中,CommonJS 模塊一般由包管理器提供的運行時實現,整體邏輯和 Node.js 的模塊運行時類似,也使用了模塊包裝器。以下分析都以 Node.js 為例。
模塊使用報錯
CommonJS 模塊使用不當時,由 cjs/loader.js 拋出錯誤。比如:
// Node.js
internal/modules/cjs/loader.js:905
throw err;
^
Error: Cannot find module './none_existed.js'
Require stack:
- /Users/wuliang/Documents/code/demo_module/index.js
可以看到,錯誤是通過 throw 語句拋出的。
模塊執行順序
CommonJS 模塊是順序執行的,遇到 require 時,加載并執行對應模塊的代碼,然后再回來執行當前模塊的代碼。
如圖 3 所示,模塊 A 依賴模塊 B 和 C,模塊 A 被 2 個 require 語句從上往下分為 3 段,記為 A1、A2、A3。


如圖 4 所示,代碼塊執行順序為:A1 -> B -> A2 -> C -> A3。


模塊循環引用
從 cjs/loader.js 的 L765、L772 和 L784 行代碼可以看到,在模塊執行前就會創建好對應的模塊對象,并進行緩存。模塊執行的過程實際是在給該模塊對象計算需要導出的變量屬性。因此,CommonJS 模塊在啟動執行時,就已經處于可以被獲取的狀態,這個特點可以很好地解決模塊循環引用的問題。
如圖 5 所示,模塊 A 依賴模塊 B,模塊 B 又依賴模塊 A,模塊 A 和 B 分別被 require 語句從上往下分為 2 段,記為 A1、A2、B1、B2。


如圖 6 所示,代碼塊的執行順序為:A1 -> B1 -> B2 -> A2。


使用不當的問題
如果 B2 使用了 A2 導出的變量會怎么樣呢?模塊 A 的模塊對象上不存在該變量對應的屬性,獲取的值為 undefined。獲得 undefined 雖然不符合預期,但一般不會造成 JS 錯誤。
可以看到,由于 require 語句直接分割了執行的代碼塊,CommonJS 模塊的導入導出語句的位置會影響模塊代碼語句的執行結果。
ES6 模塊
ES6 模塊[4]借助 JS 引擎實現。JS 引擎實現了 ES6 模塊的底層核心邏輯,JS 運行時需要在上層做適配。適配工作量還不小,比如實現文件的加載,具體可以看一下我發起的一個討論[5]。
模塊使用報錯
ES6 模塊使用不當時,由 JS 引擎或 JS 運行時的適配層拋出錯誤。比如:
// Node.js 中報錯
internal/process/esm_loader.js:74
internalBinding('errors').triggerUncaughtException
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
// 瀏覽器中報錯
Uncaught SyntaxError: The requested module './child.js' does not provide an export named 'b'
第一個是 Node.js 適配層觸發的內部錯誤(不是通過 throw 拋出的),第二個是瀏覽器拋出的 JS 引擎級別的語法錯誤。
模塊執行順序
ES6 模塊有 5 種狀態,分別為 unlinked、linking、linked、evaluating 和 evaluated,用循環模塊記錄(Module Environment Records)[6]的 Status 字段表示。ES6 模塊的處理包括連接(link)和評估(evaluate)兩步。連接成功之后才能進行評估。
連接主要由函數 InnerModuleLinking[7] 實現。函數 InnerModuleLinking 會調用函數 InitializeEnvironment[8],該函數會初始化模塊的環境記錄(Environment Records)[9],主要包括創建模塊的執行上下文(Execution Contexts)[10]、給導入模塊變量創建綁定[11]并初始化[12]為子模塊的對應變量,給 var 變量創建綁定并初始化為 undefined、給函數聲明變量創建綁定并初始化為函數體的實例化[13]值、給其他變量創建綁定但不進行初始化。
對于圖 3 的模塊關系,連接過程如圖 7 所示。連接階段采用深度優先遍歷,通過函數 HostResolveImportedModule[14] 獲取子模塊。完成核心操作的函數 InitializeEnvironment 是后置執行的,所以從效果上看,子模塊先于父模塊被初始化。


評估主要由函數 InnerModuleEvaluation[15] 實現。函數 InnerModuleEvaluation 會調用函數 ExecuteModule[16],該函數會評估模塊代碼(evaluating module.[[ECMAScriptCode]])。ES6 規范并沒有明確說明這里的評估模塊代碼具體指什么。我把 ES6 規范的相關部分反復看了至少十余遍,才得出一個比較合理的解釋。這里的評估模塊代碼應該指根據代碼語句順序執行條款 13[17]、條款 14[18] 和 條款 15[19] 內的對應小節的“運行時語義:評估(Runtime Semantics: Evaluation)”。ScriptEvaluation[20] 中的評估腳本(evaluating scriptBody)應該也是這個意思??梢钥吹?,ES6 規范雖然做了很多設計并且邏輯清晰和自洽,但仍有一些模棱兩可的地方,沒有達到一種絕對完善和無懈可擊的狀態。
對于圖 3 的模塊關系,評估過程如圖 8 所示。和連接階段類似,評估階段也采用深度優先遍歷,通過函數 HostResolveImportedModule 獲取子模塊。完成核心操作的函數 ExecuteModule 是后置執行的,所以從效果上看,子模塊先于父模塊被執行。


由于連接階段會給導入模塊變量創建綁定并初始化為子模塊的對應變量,子模塊的對應變量在評估階段會先被賦值,所以導入模塊變量獲得了和函數聲明變量一樣的提升效果。例如,代碼 1 是能正常運行的。因此,ES6 模塊的導入導出語句的位置不影響模塊代碼語句的執行結果。
console.log(a) // 正常打印 a 的值
import { a } from './child.js'
代碼 1
模塊循環引用
對于循環引用的場景,會先對子模塊進行預處理和執行。連接階段除了分析模塊依賴關系,還會創建執行上下文和初始化變量,所以連接階段主要包括分析模塊依賴關系和對模塊進行預處理。如圖 9 所示,對于圖 5 的模塊關系,處理順序為:預處理 B -> 預處理 A -> 執行 B -> 執行 A。


使用不當的問題
由于子模塊先于父模塊被執行,子模塊直接執行從父模塊導入的變量會導致 JS 錯誤。
// 文件 parent.js
import {} from './child.js';
export const parent = 'parent';
// 文件 child.js
import { parent } from './parent.js';
console.log(parent); // 報錯
代碼 2
如代碼 2 所示,child.js 中的導入變量 parent 被綁定為 parent.js 的導出變量 parent,當執行 child.js 的最后一行代碼時,parent.js 還沒有被執行,parent.js 的導出變量 parent 未被初始化,所以 child.js 中的導入變量 parent 也就沒有被初始化,會導致 JS 錯誤。注意,本文說的變量是統稱,包含 var、let、const、function 等關鍵字聲明的變量。
console.log(parent)
^
ReferenceError: Cannot access 'parent' before initialization
如果是異步執行,則沒問題,因為異步執行的時候父模塊已經被執行了。例如,代碼 3 是能正常運行的。
// parent.js
import {} from './child.js';
export const parent = 'parent';
// child.js
import { parent } from './parent.js';
setTimeout(() => {
console.log(parent) // 輸出 'parent'
}, 0);
代碼 3
糾正教程觀點
《ECMAScript 6 入門教程》一書說的三個重大差異如下:
- CommonJS 模塊輸出的是一個值的拷貝,ES6 模塊輸出的是值的引用。
- CommonJS 模塊是運行時加載,ES6 模塊是編譯時輸出接口。
- CommonJS 模塊的 require() 是同步加載模塊,ES6 模塊的 import 命令是異步加載,有一個獨立的模塊依賴的解析階段。
對于第 1 點,CommonJS 和 ES6 模塊輸出的都是變量,變量都是值的引用。該章節的評論中也有人質疑這個點。對于第 2 點,前半句基本正確,后半句基本錯誤。CommonJS 模塊在執行階段加載子模塊文件,ES6 模塊在預處理階段加載子模塊文件,當然 ES6 模塊在執行階段也會加載子模塊文件,不過會使用預處理階段的緩存。從形式上看,CommonJS 模塊整體導出一個包含若干個變量的對象,ES6 模塊分開導出單個變量,如果只看父模塊,ES6 模塊的父模塊確實在預處理階段就綁定了子模塊的導出變量,但是預處理階段的子模塊的導出變量是還沒有被賦最終值的,所以并不能算真正輸出。對于第 3 點,CommonJS 模塊同步加載并執行模塊文件,ES6 模塊提前加載并執行模塊文件。異步通常被理解為延后一個時間節點執行,所以說成異步加載是錯誤的。
分析問題
對 JS 模塊機制有了更深刻的理解后,我們回來分析我遇到的問題。
問題一
首先分析圖 1 的報錯。業務方 App 的工程代碼用 webpack 打包,所以實際運行的是 CommonJS 模塊。上面講過 CommonJS 模塊循環引用使用不當一般不會導致 JS 錯誤,為啥這里會出現 JS 報錯呢?這是因為,循環引用使用不當導致變量的值為 undefined,我們的代碼使用了 extends[21],而 extends 不支持 undefined。由于使用了 Bable[22] 進行轉碼,所以由墊片 _inherits[23] 報錯。另外一個典型的不支持的 undefined 的 case 是 Object.create(undefined)。
問題二
然后分析圖 2 的報錯。在業務方 App 工程里 yarn link 教室 SDK,使用 webpack 打包后,運行的仍然是 CommonJS 模塊,為什么會出現 JS 引擎級別的錯誤呢?這不是 ES6 模塊才會出現的報錯么?這里有兩個原因。
教室 SDK 使用 Rollup[24] 進行打包。Rollup 會把多個文件打包成一個文件,子模塊的代碼會被放到父模塊前面。比如,代碼 2 經過 Rollup 打包后變成了代碼 4。
console.log(parent); // 報錯
const parent = 'parent';
export { parent };
代碼 4
本地 yarn link 教室 SDK 后,引用的教室 SDK 包路徑為軟連接,而軟連接在 Babel 轉碼時會被忽略。因此,業務 App 直接引用了 Rollup 打包的 ES6+ 語法的教室 SDK。如果在子模塊中直接執行了父模塊導出的變量,就會報錯。如代碼 4 所示,執行第一行代碼時,變量 parent 有被創建綁定但沒有被初始化。
解決問題
明確了問題由模塊循環引用導致,并分析了具體原因。那怎么在復雜的代碼工程中找到出現循環引用的模塊呢?
webpack plugin
circular-dependency-plugin[25] 是一個分析模塊循環引用的 webpack 插件。它的源碼只有 100 行左右,原理也比較簡單。在 optimizeModules[26] 鉤子中,從本模塊開始遞歸尋找依賴模塊,并比較依賴模塊與本模塊的 debugId,如果相同,就判定為循環引用,并返回循環引用鏈。
定位并解決循環引用
在業務 App 工程中引入
circular-dependency-plugin 后做一些配置,就可以看到教室 SDK 相關的循環引用模塊。輸出的模塊循環引用鏈比較多,有 112 個。如何進一步定位到幾個導致問題的循環引用呢?根據報錯的堆棧找到報錯的文件,然后找出和這個文件相關的循環引用,用 hack 的方式逐個切斷這些循環引用后驗證報錯是否解決。最后,我在切斷兩個循環引用后解決了問題。其中一個循環引用鏈如下:
Circular dependency detected:
node_modules/@byted-classroom/room/lib/service/assist/stream-validator.js ->
node_modules/@byted-classroom/room/lib/service/rtc/engine.js ->
node_modules/@byted-classroom/room/lib/service/rtc/definitions.js ->
node_modules/@byted-classroom/room/lib/service/rtc/base.js ->
node_modules/@byted-classroom/room/lib/service/monitor/index.js ->
node_modules/@byted-classroom/room/lib/service/monitor/monitors.js ->
node_modules/@byted-classroom/room/lib/service/monitor/room.js ->
node_modules/@byted-classroom/room/lib/service/npy-courseware/student-courseware.js ->
node_modules/@byted-classroom/room/lib/service/index.js ->
node_modules/@byted-classroom/room/lib/service/audio-mixing/index.js ->
node_modules/@byted-classroom/room/lib/service/audio-mixing/mixing-player.js ->
node_modules/@byted-classroom/room/lib/index.js ->
node_modules/@byted-classroom/room/lib/room/base.js ->
node_modules/@byted-classroom/room/lib/service/rtc/manager.js ->
node_modules/@byted-classroom/room/lib/service/assist/stream-validator.js
建議
TypeScript 工程的循環引用問題是比較普遍的,常常會因為需要使用一個類型而增加一個文件依賴。建議在工程中引入模塊循環引用檢測機制,比如 webpack 插件
circular-dependency-plugin 和 eslint 規則 import/no-cycle,以便及時調整文件或代碼結構來切斷循環引用。
總結
本文從開發時遇到的一個報錯出發,對 JS 模塊機制和循環引用進行了深度分析,并提供了定位和解決模塊循環引用問題的方法。根據對 ES 規范的解讀,本文糾正了《ECMAScript 6 入門教程》一書中的幾個錯誤觀點。
版權聲明:本文內容由互聯網用戶自發貢獻,該文觀點僅代表作者本人。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。如發現本站有涉嫌抄襲侵權/違法違規的內容, 請發送郵件至 舉報,一經查實,本站將立刻刪除。