首頁 > 科技

深入分析JavaScript模組迴圈引用

2021-08-20 03:05:14

背景

大力教育的線上教室中臺提供封裝了核心能力的教室 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.jsinternal/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.jsimport {} from './child.js';export const parent = 'parent';// 檔案 child.jsimport { 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.jsimport {} from './child.js';export const parent = 'parent';// child.jsimport { parent } from './parent.js';setTimeout(() => {  console.log(parent) // 輸出 'parent'}, 0);

程式碼 3

糾正教程觀點

《ECMAScript 6 入門教程》一書說的三個重大差異如下:

  1. CommonJS 模組輸出的是一個值的拷貝,ES6 模組輸出的是值的引用。
  2. CommonJS 模組是運行時載入,ES6 模組是編譯時輸出介面。
  3. 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 入門教程》一書中的幾個錯誤觀點。

作者:ELab
連結:https://juejin.cn/post/6997288355050291213
來源:掘金


IT145.com E-mail:sddin#qq.com