前端包管理器的依賴管理原理

本文主要探究前端包管理器的依賴管理原理,希望對讀者有所幫助。

前言

npm 是 Node.JS 的包管理工具,除此之外,社區有一些類似的包管理工具如 yarn、pnpm 和 cnpm,以及集團內部使用的 tnpm。我們在項目開發過程中通常使用以上主流包管理器生成 node_modules 目錄安裝依賴並進行依賴管理。本文主要探究前端包管理器的依賴管理原理,希望對讀者有所幫助。

npm

當我們執行npm install命令後,npm 會幫我們下載對應依賴包並解壓到本地緩存,然後構造 node_modules 目錄結構,寫入依賴文件。那麼,對應的包在 node_modules 目錄內部是怎樣的結構呢,npm 主要經歷了以下幾次變化。

npm v1/v2 依賴嵌套

npm 最早的版本中使用了很簡單的嵌套模式進行依賴管理。比如我們在項目中依賴了 A 模塊和 C 模塊,而 A 模塊和 C 模塊依賴了不同版本的 B 模塊,此時生成的 node_modules 目錄如下:

可以看到這種是嵌套的 node_modules 結構,每個模塊的依賴下面還會存在一個 node_modules 目錄來存放模塊依賴的依賴。這種方式雖然簡單明瞭,但存在一些比較大的問題。如果我們在項目中增加一個同樣依賴 2.0 版本 B 的模塊 D,此時生成的 node_modules 目錄便會如下所示。雖然模塊 A、D 依賴同一個版本 B,但 B 卻重複下載安裝了兩遍,造成了重複的空間浪費。這便是依賴地獄問題。

node_modules
├── A@1.0.0
│   └── node_modules
│       └── B@1.0.0
├── C@1.0.0
│   └── node_modules
│       └── B@2.0.0
└── D@1.0.0
    └── node_modules
        └── B@1.0.0

一些著名的梗圖:

npm v3 扁平化

npm v3 完成重寫了依賴安裝程序,npm3 通過扁平化的方式將子依賴項安裝在主依賴項所在的目錄中(hoisting 提升),以減少依賴嵌套導致的深層樹和冗餘。此時生成的 node_modules 目錄如下:


爲了確保模塊的正確加載,npm 也實現了額外的依賴查找算法,核心是遞歸向上查找 node_modules。在安裝新的包時,會不停往上級 node_modules 中查找。如果找到相同版本的包就不會重新安裝,在遇到版本衝突時纔會在模塊下的 node_modules 目錄下存放該模塊子依賴,解決了大量包重複安裝的問題,依賴的層級也不會太深。

扁平化的模式解決了依賴地獄的問題,但也帶來了額外的新問題。

幽靈依賴主要發生某個包未在 package.json 中定義,但項目中依然可以引用到的情況下。考慮之前的案例,它的 package.json 如右圖所示。

在 index.js 中我們可以直接 require A,因爲在 package.json 聲明瞭該依賴,但是,我們 require B 也是可以正常工作的。

var A = require('A');
var B = require('B'); // ???

因爲 B 是 A 的依賴項,在安裝過程中,npm 會將依賴 B 平鋪到 node_modules 下,因此 require 函數可以查找到它。但這可能會導致意想不到的問題:

  1. 依賴不兼容:my-library 庫中並沒有聲明依賴 B 的版本,因此 B 的 major 更新對於 SemVer 體系是完全合法的,這就導致其他用戶安裝時可能會下載到與當前依賴不兼容的版本。

  2. 依賴缺失:我們也可以直接引用項目中 devDepdency 的子依賴,但其他用戶安裝時並不會 devDepdency,這就可能導致運行時會立刻報錯。

考慮在項目中繼續引入的依賴 2.0 版本 B 的模塊 D 與而 1.0 版本 B 的模塊 E,此時無論是把 B 2.0 還是 1.0 提升放在頂層,都會導致另一個版本存在重複的問題,比如這裏重複的 2.0。此時就會存在以下問題:

  1. 破壞單例模式:模塊 C、D 中引入了模塊 B 中導出的一個單例對象,即使代碼裏看起來加載的是同一模塊的同一版本,但實際解析加載的是不同的 module,引入的也是不同的對象。如果同時對該對象進行副作用操作,就會產生問題。

  2. types 衝突:雖然各個 package 的代碼不會相互污染,但是他們的 types 仍然可以相互影響,因此版本重複可能會導致全局的 types 命名衝突。

在前端包管理的背景下,確定性指在給定 package.json 下,無論在何種環境下執行 npm install 命令都能得到相同的 node_modules 目錄結構。然而 npm v3 是不確定性的,它 node_modules 目錄以及依賴樹結構取決於用戶安裝的順序。

考慮項目擁有以下依賴樹結構,其 npm install 產生的 node_modules 目錄結構如右圖所示。


假設當用戶使用 npm 手動升級了模塊 A 到 2.0 版本,導致其依賴的模塊 B 升級到了 2.0 版本,此時的依賴樹結構如下。


此時完成開發,將項目部署至服務器,重新執行 npm install,此時提升的子依賴 B 版本發生了變化,產生的 node_modules 目錄結構將會與用戶本地開發產生的結構不同,如下圖所示。如果需要 node_modules 目錄結構一致,就需要在 package.json 修改時刪除 node_modules 結構並重新執行 npm install。

npm v5 扁平化 + lock

在 npm v5 中新增了 package-lock.json。當項目有 package.json 文件並首次執行 npm install 安裝後,會自動生成一個 package-lock.json 文件,該文件裏面記錄了 package.json 依賴的模塊,以及模塊的子依賴。並且給每個依賴標明瞭版本、獲取地址和驗證模塊完整性哈希值。通過 package-lock.json,保障了依賴包安裝的確定性與兼容性,使得每次安裝都會出現相同的結果。

考慮上文案例,初始時安裝生成 package-lock.json 如左圖所示,depedencies 對象中列出的依賴都是提升的,每個依賴項中的 requires 對象中爲子依賴項。此時更新 A 依賴到 2.0 版本,如右圖所示,並不會改變提升的子依賴版本。因此重新生成的 node_modules 目錄結構將不會發生變化。

語義化版本(Semantic Versioning)

依賴版本兼容性就不得不提到 npm 使用的 SemVer 版本規範,版本格式如下:

  1. 主版本號:不兼容的 API 修改

  2. 次版本號:向下兼容的功能性新增

  3. 修訂號:向下兼容的問題修正


在使用第三方依賴時,我們通常會在 package.json 中指定依賴的版本範圍,語義化版本範圍規定:

  1. ~:只升級修訂號

  2. ^:升級次版本號和修訂號

  3. *:升級到最新版本

語義化版本規則定義了一種理想的版本號更新規則,希望所有的依賴更新都能遵循這個規則,但是往往會有許多依賴不是嚴格遵循這些規定的。因此一些依賴模塊子依賴不經意的升級,可能就會導致不兼容的問題產生。因此 package-lock.json 給每個模塊子依賴標明瞭確定的版本,避免不兼容問題的產生。

Yarn

Yarn 是在 2016 年開源的,yarn 的出現是爲了解決 npm v3 中的存在的一些問題,那時 npm v5 還沒發佈。Yarn 被定義爲快速、安全、可靠的依賴管理。

Yarn v1 lockfile

Yarn 生成的 node_modules 目錄結構和 npm v5 是相同的,同時默認生成一個 yarn.lock 文件。對於上文例子,生成的 yarn.lock 文件如下:

A@^1.0.0:
  version "1.0.0"
  resolved "uri"
 dependencies:
    B "^1.0.0"
B@^1.0.0:
  version "1.0.0"
  resolved "uri"
B@^2.0.0:
  version "2.0.0"
  resolved "uri"
C@^2.0.0:
  version "2.0.0"
  resolved "uri"
 dependencies:
    B "^2.0.0"
D@^2.0.0:
  version "2.0.0"
  resolved "uri"
  dependencies:
    B "^2.0.0"
E@^1.0.0:
  version "1.0.0"
  resolved "uri"
  dependencies:
    B "^1.0.0"

可以看到 yarn.lock 使用自定義格式而不是 JSON,並將所有依賴都放在頂層,給出的理由是便於閱讀和審查,減少合併衝突。

  1. 文件格式不同,npm v5 使用的是 json 格式,yarn 使用的是自定義格式

  2. package-lock.json 文件裏記錄的依賴的版本都是確定的,不會出現語義化版本範圍符號 (~ ^ *),而 yarn.lock 文件裏仍然會出現語義化版本範圍符號

  3. package-lock.json 文件內容更豐富,實現了更密集的鎖文件,包括子依賴的提升信息

npm v5 只需要 package.lock 文件就可以確定 node_modules 目錄結構 yarn.lock 無法確定頂層依賴,需要 package.json 和 yarn.lock 兩個文件才能確定 node_modules 目錄結構。node_modules 目錄中 package 的位置是在 yarn 的內部計算出來的,在使用不同版本的 yarn 時可能會引起不確定性。

Yarn v2 Plug'n'Play

在 Yarn 的 2.x 版本重點推出了 Plug'n'Play(PnP)零安裝模式,放棄了 node_modules,更加保證依賴的可靠性,構建速度也得到更大的提升。

因爲 Node 依賴於 node_modules 查找依賴,node_modules 的生成會涉及到下載依賴包、解壓到緩存、拷貝到本地文件目錄等一系列重 IO 的操作,包括依賴查找以及處理重複依賴都是非常耗時操作,基於 node_modules 的包管理器並沒有很多優化的空間。因此 yarn 反其道而行之,既然包管理器已經擁有了項目依賴樹的結構,那也可以直接由包管理器通知解釋器包在磁盤上的位置並管理依賴包版本與子依賴關係。

執行yarn --pnp模式即可開啓 PnP 模式。在 PnP 模式,yarn 會生成 .pnp.cjs 文件代替 node_modules。該文件維護了依賴包到磁盤位置與子依賴項列表的映射。同時 .pnp.js 還實現了 resolveRequest 方法處理 require 請求,該方法會直接根據映射表確定依賴在文件系統中的位置,從而避免了在 node_modules 查找依賴的 I/O 操作。

pnp 模式優缺點也非常明顯:

  1. 優:擺脫 node_modules,安裝、模塊速度加載快;所有 npm 模塊都會存放在全局的緩存目錄下,避免多重依賴;嚴格模式下子依賴不會提升,也避免了幽靈依賴(但這可能會導致某些包出現問題,因此也支持了依賴提升的寬鬆模式:<)。

  2. 缺:自建 resolver 處理 Node require 方法,執行 Node 文件需要通過 yarn node 解釋器執行,脫離 Node 現存生態,兼容性不太好

pnpm

pnpm1.0 於 2017 年正式發佈,pnpm 具有安裝速度快、節約磁盤空間、安全性好等優點,它的出現也是爲了解決 npm 和 yarn 存在的問題。

因爲在基於 npm 或 yarn 的扁平化 node_modules 的結構下,雖然解決了依賴地獄、一致性與兼容性的問題,但多重依賴和幽靈依賴並沒有好的解決方式。因爲在不考慮循環依賴的情況下,實際的依賴結構圖爲有向無環圖 (DAG),但是 npm 和 yarn 通過文件目錄和 node resolve 算法模擬的實際上是有向無環圖的一個超集(多出了很多錯誤祖先節點和兄弟節點之間的鏈接),這導致了很多的問題。pnpm 也是通過硬鏈接與符號鏈接結合的方式,更加精確的模擬 DAG 來解決 yarn 和 npm 的問題。

非扁平化的 node_modules

硬鏈接可以理解爲源文件的副本,使得用戶可以通過不同的路徑引用方式去找到某個文件,他和源文件一樣的大小但是事實上卻不佔任何空間。pnpm 會在全局 store 目錄裏存儲項目 node_modules 文件的硬鏈接。硬鏈接可以使得不同的項目可以從全局 store 尋找到同一個依賴,大大節省了磁盤空間。

軟鏈接可以理解爲快捷方式,pnpm 在引用依賴時通過符號鏈接去找到對應磁盤目錄(.pnpm)下的依賴地址。考慮在項目中安裝依賴於 foo 模塊的 bar 模塊,生成的 node_modules 目錄如下所示。

可以看到 node_modules 下的 bar 目錄下並沒有 node_modules,這是一個符號鏈接,實際真正的文件位於. pnpm 目錄中對應的 <package-name>@version/node_modules/<package-name>目錄並硬鏈接到全局 store 中。而 bar 的依賴存在於. pnpm 目錄下<package-name>@version/node_modules目錄下,而這也是軟鏈接到<package-name>@version/node_modules/<package-name>目錄並硬鏈接到全局 store 中。

而這種嵌套 node_modules 結構的好處在於只有真正在依賴項中的包才能訪問,避免了使用扁平化結構時所有被提升的包都可以訪問,很好地解決了幽靈依賴的問題。此外,因爲依賴始終都是存在 store 目錄下的硬鏈接,相同的依賴始終只會被安裝一次,多重依賴的問題也得到了解決。

官網上的這張圖清晰地解釋了 pnpm 的依賴管理機制

侷限性

看起來 pnpm 似乎很好地解決了問題,但也存在一些侷限。

  1. 忽略了 package-lock.json。npm 的鎖文件旨在反映平鋪的 node_modules 佈局,但是 pnpm 默認創建隔離佈局,無法由 npm 的鎖文件格式反映出來,而是使用自身的鎖文件 pnpm-lock.yaml。

  2. 符號鏈接兼容性。存在符號鏈接不能適用的一些場景,比如 Electron 應用、部署在 lambda 上的應用無法使用 pnpm。

  3. 子依賴提升到同級的目錄結構,雖然由於 Node.js 的父目錄上溯尋址邏輯,可以實現兼容。但對於類似 Egg、Webpack 的插件加載邏輯,在用到相對路徑的地方,需要去適配。

  4. 不同應用的依賴是硬鏈接到同一份文件,如果在調試時修改了文件,有可能會無意中影響到其他項目。

cnpm 和 tnpm

cnpm 是由阿里維護並開源的 npm 國內鏡像源,支持官方 npm registry 的鏡像同步。tnpm 是在 cnpm 基礎之上,專爲阿里巴巴經濟體的同學服務,提供了私有的 npm 倉庫,並沉澱了很多 Node.js 工程實踐方案。

cnpm/tnpm 的依賴管理是借鑑了 pnpm ,通過符號鏈接方式創建非扁平化的 node_modules 結構,最大限度提高了安裝速度。安裝的依賴包都是在 node_modules 文件夾以包名命名,然後再做符號鏈接到 版本號 @包名的目錄下。與 pnpm 不同的是,cnpm 沒有使用硬鏈接,也未把子依賴符號鏈接到單獨目錄進行隔離。

此外,tnpm 新推出的 rapid 模式使用用戶態文件系統(FUSE)對依賴管理做了一些新的優化。FUST 類似於文件系統版的 ServiceWorker,通過 FUSE 可以接管一個目錄的文件系統操作邏輯。基於此實現非扁平化的 node_modules 結構可以解決軟鏈接的兼容性問題。限於篇幅原因這裏不再詳述,感興趣可以移步真 · 深入淺出 tnpm rapid 模式 - 如何比 pnpm 快 10 秒。

其他

Deno

通過上文探究的主流包管理器依賴管理機制,我們發現無論扁平化或非扁平化 node_modules 結構似乎都不完美,拋棄 node_modules 的 PnP 模式又不兼容當前 Node 的生態,無解。看起來似乎是 Node 與 node_modules 自身有點問題 (?)。Node.JS 作者 Ryan 也在 JSConf 上承認 node_modules 是他對 Node 的十大遺憾之一,但已經無法挽回了,隨後他推薦了自己的新作 Deno。那讓我們看看 JS 的另一大運行時環境 Deno 是如何進行依賴管理的。


在 Deno 不使用 npm、package.json 以及 node_modules,而是將引入源、包名、版本號、模塊名全部塞進了 URL 裏,通過 URL 導入依賴並進行全局統一緩存,不僅節省了磁盤空間,也優化了項目結構。

import * as log from "https://deno.land/std@0.125.0/log/mod.ts";

因此 Deno 中沒有包管理器的概念,對於項目中的依賴管理,Deno 提供了這樣一種方案。由開發者創建dep.ts,此文件中引用了所有必需的遠程依賴關係,並且重新導出了所需的方法和類。本地模塊從dep.ts統一導入所需方法和類,避免單獨使用 URL 導入外部依賴可能造成的不一致的問題。

// dep.ts
export {
  assert,
  assertEquals,
  assertStringIncludes,
} from "https://deno.land/std@0.125.0/testing/asserts.ts";
// index.ts
import { assert } from './dep.ts';

Deno 處理依賴的方式雖然解決了 node_modules 帶來的種種問題,但目前體驗也並不是很好。首先 URL 引入依賴的方式寫法比較冗餘繁瑣,直接引用網絡上文件的安全性也值得商榷;而且需要開發者手動維護dep.ts文件,依賴來源不清晰,依賴變更還需要更改引入依賴的本地文件;此外,依賴包的生態也遠遠不及 Node。

但 Deno 確實提供了另外一種思路,Node 的包管理器似乎只是安裝依賴、生成 node_modules 的 “純工具人”,真正查找 resolve 依賴的邏輯還是在 Node 做的,所以包管理器層面也沒有太多優化的空間。Yarn 的 Pnp 模式曾試圖改變包管理器的地位,但也不敵強大的 Node 生態。因此 Deno 重啓爐竈,將 intall 和 resolve 依賴過程合併,多餘的 node_modules 與包管理器也就沒什麼存在的必要了。只是 Deno 當前的方式還不夠成熟,期待後續的演進。

結語

雖然目前還沒有完美的依賴管理方案,但縱觀包管理器的歷史發展,是庫與開發者互相學習和持續優化的過程,並且都在不斷推動着前端工程化領域的發展,我們期待未來會出現更好的解決方案。

參考

  1. node_modules 困境 (https://zhuanlan.zhihu.com/p/137535779)npm:

  2. How Npm Works(https://npm.github.io/how-npm-works-docs/index.html)

  3. Yarn: Plug'n'Play(https://yarnpkg.com/features/pnp)

  4. pnpm: 基於符號鏈接的 node_modules 結構 (https://pnpm.io/zh/symlinked-node-modules-structure)

  5. tnpm: 真 · 深入淺出 tnpm rapid 模式 - 如何比 pnpm 快 10 秒 (https://zhuanlan.zhihu.com/p/455809528)

  6. deno: Linking to third party code(https://deno.land/manual@v1.18.2/linking_to_external_code)

團隊介紹

大淘寶技術—行業工作臺前端團隊,有一羣熱愛技術,期望用技術推動業務的小夥伴。服務於數千運營和千萬商家,打造高效、穩定、好用的下一代數智化操作系統,讓運營 & 商家能更輕鬆、更快捷,給消費者更好的購物體驗。

團隊在建設和探索的核心技術有:

  1. 新一代無代碼研發產品,包括需求結構化、領域物料、無代碼搭建、服務標準化 & 編排等細分技術方向

  2. 安全生產 & 用戶體驗產品,包括監控預警、自動化測試、代碼掃描、灰度發佈、問題診斷、體驗分析等方向

  3. 數據化運營技術,包括數據可視化、數據指標分析、策略驅動運營等技術探索方向。

作者 | 朔宸

編輯 | 橙子君

出品 | 阿里巴巴新零售淘系技術

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/BfijbUe017ZzlINnCIda8g