深入分析 JavaScript 模塊循環(huán)引用
大廠技術(shù) 堅(jiān)持周更 精選好文
背景
大力教育的在線教室中臺(tái)提供封裝了核心能力的教室 SDK,業(yè)務(wù)方基于教室 SDK 開發(fā)面向用戶的在線教室 App。最近對(duì)教室 SDK 做一次比較大的改動(dòng)時(shí),我遇到了一個(gè)懵逼的問題。這個(gè)問題耗費(fèi)了我 3 天左右時(shí)間,讓我壓力一度大到全身發(fā)熱。當(dāng)時(shí)雖然解決了問題,但并沒有很理解原因。直到一個(gè)多月后,才有時(shí)間做一些更深入的分析,并寫下這篇文章。
當(dāng)時(shí)的情況是,業(yè)務(wù)方 App 工程能通過 TypeScript 編譯,但在運(yùn)行時(shí)會(huì)報(bào)錯(cuò)。就不同的使用教室 SDK 的方式,報(bào)錯(cuò)有兩種。圖 1 為在業(yè)務(wù)方 App 工程里正常安裝教室 SDK 后進(jìn)行調(diào)試時(shí)的報(bào)錯(cuò);圖 2 為在業(yè)務(wù)方 App 工程里 yarn link 教室 SDK 后進(jìn)行調(diào)試時(shí)的報(bào)錯(cuò)。

圖 1

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

圖 3
如圖 4 所示,代碼塊執(zhí)行順序?yàn)椋篈1 -> B -> A2 -> C -> A3。

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

圖 5
如圖 6 所示,代碼塊的執(zhí)行順序?yàn)椋篈1 -> B1 -> B2 -> A2。

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

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

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

圖 9
使用不當(dāng)?shù)膯栴}
由于子模塊先于父模塊被執(zhí)行,子模塊直接執(zhí)行從父模塊導(dǎo)入的變量會(huì)導(dǎo)致 JS 錯(cuò)誤。
// 文件 parent.js
import {} from './child.js';
export const parent = 'parent';
// 文件 child.js
import { parent } from './parent.js';
console.log(parent); // 報(bào)錯(cuò)
代碼 2
如代碼 2 所示,child.js 中的導(dǎo)入變量 parent 被綁定為 parent.js 的導(dǎo)出變量 parent,當(dāng)執(zhí)行 child.js 的最后一行代碼時(shí),parent.js 還沒有被執(zhí)行,parent.js 的導(dǎo)出變量 parent 未被初始化,所以 child.js 中的導(dǎo)入變量 parent 也就沒有被初始化,會(huì)導(dǎo)致 JS 錯(cuò)誤。注意,本文說的變量是統(tǒng)稱,包含 var、let、const、function 等關(guān)鍵字聲明的變量。
console.log(parent)
^
ReferenceError: Cannot access 'parent' before initialization
如果是異步執(zhí)行,則沒問題,因?yàn)楫惒綀?zhí)行的時(shí)候父模塊已經(jīng)被執(zhí)行了。例如,代碼 3 是能正常運(yùn)行的。
// parent.js
import {} from './child.js';
export const parent = 'parent';
// child.js
import { parent } from './parent.js';
setTimeout(() => {
console.log(parent) // 輸出 'parent'
}, 0);
代碼 3
糾正教程觀點(diǎn)
《ECMAScript 6 入門教程》一書說的三個(gè)重大差異如下:
CommonJS 模塊輸出的是一個(gè)值的拷貝,ES6 模塊輸出的是值的引用。 CommonJS 模塊是運(yùn)行時(shí)加載,ES6 模塊是編譯時(shí)輸出接口。 CommonJS 模塊的 require() 是同步加載模塊,ES6 模塊的 import 命令是異步加載,有一個(gè)獨(dú)立的模塊依賴的解析階段。
對(duì)于第 1 點(diǎn),CommonJS 和 ES6 模塊輸出的都是變量,變量都是值的引用。該章節(jié)的評(píng)論中也有人質(zhì)疑這個(gè)點(diǎn)。對(duì)于第 2 點(diǎn),前半句基本正確,后半句基本錯(cuò)誤。CommonJS 模塊在執(zhí)行階段加載子模塊文件,ES6 模塊在預(yù)處理階段加載子模塊文件,當(dāng)然 ES6 模塊在執(zhí)行階段也會(huì)加載子模塊文件,不過會(huì)使用預(yù)處理階段的緩存。從形式上看,CommonJS 模塊整體導(dǎo)出一個(gè)包含若干個(gè)變量的對(duì)象,ES6 模塊分開導(dǎo)出單個(gè)變量,如果只看父模塊,ES6 模塊的父模塊確實(shí)在預(yù)處理階段就綁定了子模塊的導(dǎo)出變量,但是預(yù)處理階段的子模塊的導(dǎo)出變量是還沒有被賦最終值的,所以并不能算真正輸出。對(duì)于第 3 點(diǎn),CommonJS 模塊同步加載并執(zhí)行模塊文件,ES6 模塊提前加載并執(zhí)行模塊文件。異步通常被理解為延后一個(gè)時(shí)間節(jié)點(diǎn)執(zhí)行,所以說成異步加載是錯(cuò)誤的。
分析問題
對(duì) JS 模塊機(jī)制有了更深刻的理解后,我們回來分析我遇到的問題。
問題一
首先分析圖 1 的報(bào)錯(cuò)。業(yè)務(wù)方 App 的工程代碼用 webpack 打包,所以實(shí)際運(yùn)行的是 CommonJS 模塊。上面講過 CommonJS 模塊循環(huán)引用使用不當(dāng)一般不會(huì)導(dǎo)致 JS 錯(cuò)誤,為啥這里會(huì)出現(xiàn) JS 報(bào)錯(cuò)呢?這是因?yàn)椋h(huán)引用使用不當(dāng)導(dǎo)致變量的值為 undefined,我們的代碼使用了 extends[21],而 extends 不支持 undefined。由于使用了 Bable[22] 進(jìn)行轉(zhuǎn)碼,所以由墊片 _inherits[23] 報(bào)錯(cuò)。另外一個(gè)典型的不支持的 undefined 的 case 是 Object.create(undefined)。
問題二
然后分析圖 2 的報(bào)錯(cuò)。在業(yè)務(wù)方 App 工程里 yarn link 教室 SDK,使用 webpack 打包后,運(yùn)行的仍然是 CommonJS 模塊,為什么會(huì)出現(xiàn) JS 引擎級(jí)別的錯(cuò)誤呢?這不是 ES6 模塊才會(huì)出現(xiàn)的報(bào)錯(cuò)么?這里有兩個(gè)原因。
教室 SDK 使用 Rollup[24] 進(jìn)行打包。Rollup 會(huì)把多個(gè)文件打包成一個(gè)文件,子模塊的代碼會(huì)被放到父模塊前面。比如,代碼 2 經(jīng)過 Rollup 打包后變成了代碼 4。
console.log(parent); // 報(bào)錯(cuò)
const parent = 'parent';
export { parent };
代碼 4
本地 yarn link 教室 SDK 后,引用的教室 SDK 包路徑為軟連接,而軟連接在 Babel 轉(zhuǎn)碼時(shí)會(huì)被忽略。因此,業(yè)務(wù) App 直接引用了 Rollup 打包的 ES6+ 語法的教室 SDK。如果在子模塊中直接執(zhí)行了父模塊導(dǎo)出的變量,就會(huì)報(bào)錯(cuò)。如代碼 4 所示,執(zhí)行第一行代碼時(shí),變量 parent 有被創(chuàng)建綁定但沒有被初始化。
解決問題
明確了問題由模塊循環(huán)引用導(dǎo)致,并分析了具體原因。那怎么在復(fù)雜的代碼工程中找到出現(xiàn)循環(huán)引用的模塊呢?
webpack plugin
circular-dependency-plugin[25] 是一個(gè)分析模塊循環(huán)引用的 webpack 插件。它的源碼只有 100 行左右,原理也比較簡(jiǎn)單。在 optimizeModules[26] 鉤子中,從本模塊開始遞歸尋找依賴模塊,并比較依賴模塊與本模塊的 debugId,如果相同,就判定為循環(huán)引用,并返回循環(huán)引用鏈。
定位并解決循環(huán)引用
在業(yè)務(wù) App 工程中引入 circular-dependency-plugin 后做一些配置,就可以看到教室 SDK 相關(guān)的循環(huán)引用模塊。輸出的模塊循環(huán)引用鏈比較多,有 112 個(gè)。如何進(jìn)一步定位到幾個(gè)導(dǎo)致問題的循環(huán)引用呢?根據(jù)報(bào)錯(cuò)的堆棧找到報(bào)錯(cuò)的文件,然后找出和這個(gè)文件相關(guān)的循環(huán)引用,用 hack 的方式逐個(gè)切斷這些循環(huán)引用后驗(yàn)證報(bào)錯(cuò)是否解決。最后,我在切斷兩個(gè)循環(huán)引用后解決了問題。其中一個(gè)循環(huán)引用鏈如下:
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 工程的循環(huán)引用問題是比較普遍的,常常會(huì)因?yàn)樾枰褂靡粋€(gè)類型而增加一個(gè)文件依賴。建議在工程中引入模塊循環(huán)引用檢測(cè)機(jī)制,比如 webpack 插件 circular-dependency-plugin 和 eslint 規(guī)則 import/no-cycle,以便及時(shí)調(diào)整文件或代碼結(jié)構(gòu)來切斷循環(huán)引用。
總結(jié)
本文從開發(fā)時(shí)遇到的一個(gè)報(bào)錯(cuò)出發(fā),對(duì) JS 模塊機(jī)制和循環(huán)引用進(jìn)行了深度分析,并提供了定位和解決模塊循環(huán)引用問題的方法。根據(jù)對(duì) ES 規(guī)范的解讀,本文糾正了《ECMAScript 6 入門教程》一書中的幾個(gè)錯(cuò)誤觀點(diǎn)。
參考文獻(xiàn)
ES6 入門教程,https://es6.ruanyifeng.com/ Modules: CommonJS modules,https://nodejs.org/dist/latest-v14.x/docs/api/modules.html#modules_modules_commonjs_modules cjs/loader.js,https://github.com/nodejs/node/blob/v14.x-staging/lib/internal/modules/cjs/loader.js ECMAScript Language: Scripts and Modules,https://tc39.es/ecma262/#sec-ecmascript-language-scripts-and-modules What features of V8 engine dose Node.js use when implementing ECMAScript modules,https://app.slack.com/client/T0K2RM7F0/C0K2NFFV1/thread/C0K2NFFV1-1626762465.203500 Cyclic Module Records,https://tc39.es/ecma262/#sec-cyclic-module-records InnerModuleLinking,https://tc39.es/ecma262/#sec-InnerModuleLinking InitializeEnvironment,https://tc39.es/ecma262/#sec-source-text-module-record-initialize-environment Environment Records,https://tc39.es/ecma262/#sec-environment-records Execution Contexts,https://tc39.es/ecma262/#sec-execution-contexts CreateMutableBinding,https://tc39.es/ecma262/#sec-declarative-environment-records-createmutablebinding-n-d InitializeBinding,https://tc39.es/ecma262/#sec-declarative-environment-records-initializebinding-n-v Runtime Semantics: InstantiateFunctionObject,https://tc39.es/ecma262/#sec-runtime-semantics-instantiatefunctionobject HostResolveImportedModule,https://tc39.es/ecma262/#sec-hostresolveimportedmodule InnerModuleEvaluation,https://tc39.es/ecma262/#sec-innermoduleevaluation ExecuteModule,https://tc39.es/ecma262/#sec-source-text-module-record-execute-module ECMAScript Language: Expressions,https://tc39.es/ecma262/#sec-ecmascript-language-expressions ECMAScript Language: Statements and Declarations,https://tc39.es/ecma262/#sec-ecmascript-language-statements-and-declarations ECMAScript Language: Functions and Classes,https://tc39.es/ecma262/#sec-ecmascript-language-functions-and-classes ScriptEvaluation,https://tc39.es/ecma262/#sec-runtime-semantics-scriptevaluation extends,https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/extends babeljs,https://babeljs.io/ _inherits,https://github.com/babel/babel/blob/main/packages/babel-helpers/src/helpers.js#L344-L346 rollup.js,https://rollupjs.org/guide/en/ circular-dependency-plugin,https://github.com/aackerman/circular-dependency-plugin optimizeModules,https://v4.webpack.js.org/api/compilation-hooks/#optimizemodules
