Rush Stack商店部落格活動
跳到主要內容

NPM 分身

本文繼續討論「虛擬相依性」章節。建議先閱讀該章節。

NPM 分身如何產生

NPM doppelganger

有時,node_modules 資料結構會被迫安裝同一個套件的兩個副本,而它們版本相同。真的嗎?這怎麼可能發生?

假設我們有一個主專案 A 如下所示

{
"name": "library-a",
"version": "1.0.0",
"dependencies": {
"library-b": "^1.0.0",
"library-c": "^1.0.0",
"library-d": "^1.0.0",
"library-e": "^1.0.0"
}
}

然後 BC 都相依於 F1

{
"name": "library-b",
"version": "1.0.0",
"dependencies": {
"library-f": "^1.0.0"
}
}
{
"name": "library-c",
"version": "1.0.0",
"dependencies": {
"library-f": "^1.0.0"
}
}

但是 DE 相依於 F2

{
"name": "library-d",
"version": "1.0.0",
"dependencies": {
"library-f": "^2.0.0"
}
}
{
"name": "library-e",
"version": "1.0.0",
"dependencies": {
"library-f": "^2.0.0"
}
}

node_modules 樹狀結構可以透過將 F1 放在樹狀結構的頂端來共享 F1,但是 F2 必須在子資料夾中重複

- library-a/
- package.json
- node_modules/
- library-b/
- package.json
- library-c/
- package.json
- library-d/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@2.0.0
- library-e/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@2.0.0
- library-f/
- package.json <-- library-f@1.0.0

或者,套件管理員可以選擇將 F2 放在頂端,但這樣 F1 就會被重複

- library-a/
- package.json
- node_modules/
- library-b/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@1.0.0
- library-c/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@1.0.0
- library-d/
- package.json
- library-e/
- package.json
- library-f/
- package.json <-- library-f@2.0.0

無論如何,我們都無法在沒有 library-f 的相同版本兩個副本的情況下排列樹狀結構。我們將這些稱為「分身」。其他程式設計語言的傳統套件管理員不會遇到這個問題;這是 NPM node_modules 樹狀結構的一個獨特面向。它是設計中固有的且無法避免的。

分身的後果

小型專案很少遇到分身,但在大型單一程式碼儲存庫中相當常見。以下是一些可能導致的潛在問題

  • 安裝速度較慢:現在硬碟空間不是很貴,但想像一下,您有 20 個相依於 F1 的函式庫,導致 20 個重複的副本。或者,假設有一個安裝後指令碼會下載並解壓縮大型封存檔 (例如 PhantomJS),並且每個分身都會單獨執行此操作。這可能會嚴重影響您的安裝時間。

  • 捆綁大小暴增:Web 專案通常使用捆綁器 (例如 webpack) 來靜態分析 require() 陳述式,並將程式碼收集到單一捆綁檔案中以進行部署。此檔案應盡可能小,因為它會直接影響 Web 應用程式的載入時間。當分身意外出現時 (例如,由於重新平衡 node_modules 樹狀結構的 npm install 操作),這可能會導致函式庫的兩個副本嵌入在捆綁包中,從而大大增加其大小。

  • 非單例的單例:假設 library-f 有一個 API,公開一個快取物件,旨在作為函式庫所有使用者共享的單例實例。當兩個不同的元件呼叫 require("library-f") 時,它們可能會獲得兩個不同的函式庫實例,這表示會突然出現單例的兩個實例 (也就是說,基礎的「全域」變數會在兩個不同的閉包中配置)。這可能會導致難以除錯的非常奇怪的行為。

  • 重複的類型:假設 library-f 是一個 TypeScript 函式庫。編譯器會遇到所有*該函式庫的 .d.ts 檔案的重複副本。例如,每個類別都會有其宣告的兩個副本,由於它們是單獨的實體檔案,因此無法透過遵循符號連結目標來重複資料刪除。一般而言,TypeScript 不會認為相同的類別宣告是可以互換的,並且在混合使用時會導致編譯錯誤。Typescript 2.x 引入了一種啟發法來偵測和等同這些重複項,但它涉及額外的複雜性和處理。其他建置工作可能不那麼複雜。

  • 語意不同的分身:假設 F 有一個相依性 G,該相依性也被樹狀結構中的其他套件使用。在樹狀結構中,第一個 F1 副本開始在 B 下搜尋 G,而第二個 F1 副本則在 C 下開始搜尋。require() 演算法可以從這兩個起點找到不同版本的 G。這表示兩個 F1 實例的執行階段行為可能會有所不同。或者在編譯時,如果 F 匯出一個繼承自 G 中定義的基底類別的 TypeScript 類別,我們最終可能會得到來自相同套件相同版本之相同類別的不同類型簽章。這可能會導致非常混亂的編譯器錯誤。

Rush 如何協助:Rush 的符號連結策略僅針對單一程式碼儲存庫中的本機專案的相依性消除分身。如果您使用 NPM 或 Yarn 作為套件管理員,則任何間接相依性仍然有可能產生分身。但是,如果您將 PNPM 與 Rush 搭配使用,則可以完全解決分身問題 (因為 PNPM 的安裝模型會準確地模擬真正的有向非循環圖)。