深入淺出 npm - yarn - pnpm 包管理機制

一起進步🚀)" data-from="0" has-insert-preloading="1" data-darkmode-color-16565923456148="rgb(163, 163, 163)" data-darkmode-original-color-16565923456148="#fff|rgb(63, 63, 63)">

前端開發者們每天都在接觸 xxx install,包管理器是必不可少的工具。

本文會以儘量簡潔的語言來描述當下主流包管理工具 npm、yarn、pnpm 的管理策略以及進化史,不涉及任何晦澀的代碼。

npm - 先鋒

2010 年 1 月,一款名爲 npm 的包管理器誕生。

很多人認爲 npm 是 node package manager 的縮寫,其實不是,而且 npm 根本也不是任何短語的縮寫。

npm 官方闢謠:

它的前身其實是名爲 pm(pkgmakeinst) 的 bash 工具,它可以在各種平臺上安裝各種東西。

硬要說縮寫的話,也應該是 node pm 或者 new pm。

嵌套的 node_modules 結構

npm 在早期採用的是嵌套的 node_modules 結構,直接依賴會平鋪在 node_modules 下,子依賴嵌套在直接依賴的 node_modules 中。

比如項目依賴了 A 和 C,而 A 和 C 依賴了不同版本的 B@1.0 和 B@2.0,node_modules 結構如下:

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

如果 D 也依賴 B@1.0,會生成如下的嵌套結構:

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

可以看到同版本的 B 分別被 A 和 D 安裝了兩次。

依賴地獄 Dependency Hell

在真實場景下,依賴增多,冗餘的包也變多,node_modules 最終會堪比黑洞,很快就能把磁盤佔滿。而且依賴嵌套的深度也會十分可怕,這個就是依賴地獄

扁平的 node_modules 結構

爲了將嵌套的依賴儘量打平,避免過深的依賴樹和包冗餘,npm v3 將子依賴「提升」(hoist),採用扁平的 node_modules 結構,子依賴會盡量平鋪安裝在主依賴項所在的目錄中。

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

可以看到 A 的子依賴的 B@1.0 不再放在 A 的 node_modules 下了,而是與 A 同層級。

而 C 依賴的 B@2.0 因爲版本號原因還是嵌套在 C 的 node_modules 下。

這樣不會造成大量包的重複安裝,依賴的層級也不會太深,解決了依賴地獄問題,但也形成了新的問題。

幽靈依賴 Phantom dependencies

幽靈依賴是指在 package.json 中未定義的依賴,但項目中依然可以正確地被引用到。

比如上方的示例其實我們只安裝了 A 和 C:

{
  "dependencies"{
    "A""^1.0.0",
    "C""^1.0.0"
  }
}

由於 B 在安裝時被提升到了和 A 同樣的層級,所以在項目中引用 B 還是能正常工作的。

幽靈依賴是由依賴的聲明丟失造成的,如果某天某個版本的 A 依賴不再依賴 B 或者 B 的版本發生了變化,那麼就會造成依賴缺失或兼容性問題。

不確定性 Non-Determinism

不確定性是指:同樣的 package.json 文件,install 依賴後可能不會得到同樣的 node_modules 目錄結構。

還是之前的例子,A 依賴 B@1.0,C 依賴 B@2.0,依賴安裝後究竟應該提升 B 的 1.0 還是 2.0。

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

取決於用戶的安裝順序。

如果有 package.json 變更,本地需要刪除 node_modules 重新 install,否則可能會導致生產環境與開發環境 node_modules 結構不同,代碼無法正常運行。

依賴分身 Doppelgangers

假設繼續再安裝依賴 B@1.0 的 D 模塊和依賴 @B2.0 的 E 模塊,此時:

以下是提升 B@1.0 的 node_modules 結構:

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

可以看到 B@2.0 會被安裝兩次,實際上無論提升 B@1.0 還是 B@2.0,都會存在重複版本的 B 被安裝,這兩個重複安裝的 B 就叫 doppelgangers。

而且雖然看起來模塊 C 和 E 都依賴 B@2.0,但其實引用的不是同一個 B,假設 B 在導出之前做了一些緩存或者副作用,那麼使用者的項目就會因此而出錯。

yarn - 創新

2016 年,yarn 發佈,yarn 也採用扁平化 node_modules 結構。它的出現是爲了解決 npm v3 幾個最爲迫在眉睫的問題:依賴安裝速度慢,不確定性。

提升安裝速度

在 npm 中安裝依賴時,安裝任務是串行的,會按包順序逐個執行安裝,這意味着它會等待一個包完全安裝,然後再繼續下一個。

爲了加快包安裝速度,yarn 採用了並行操作,在性能上有顯著的提高。而且在緩存機制上,yarn 會將每個包緩存在磁盤上,在下一次安裝這個包時,可以脫離網絡實現從磁盤離線安裝。

lockfile 解決不確定性

yarn 更大的貢獻是發明了 yarn.lock。

在依賴安裝時,會根據 package.josn 生成一份 yarn.lock 文件。

lockfile 裏記錄了依賴,以及依賴的子依賴,依賴的版本,獲取地址與驗證模塊完整性的 hash。

即使是不同的安裝順序,相同的依賴關係在任何的環境和容器中,都能得到穩定的 node_modules 目錄結構,保證了依賴安裝的確定性。

所以 yarn 在出現時被定義爲快速、安全、可靠的依賴管理。而 npm 在一年後的 v5 才發佈了 package-lock.json。

與 npm 一樣的弊端

yarn 依然和 npm 一樣是扁平化的 node_modules 結構,沒有解決幽靈依賴依賴分身問題。

pnpm - 後浪

pnpm - performant npm,在 2017 年正式發佈,定義爲快速的,節省磁盤空間的包管理工具,開創了一套新的依賴管理機制,成爲了包管理的後起之秀。

內容尋址存儲 CAS

與依賴提升和扁平化的 node_modules 不同,pnpm 引入了另一套依賴管理策略:內容尋址存儲。

該策略會將包安裝在系統的全局 store 中,依賴的每個版本只會在系統中安裝一次。

在引用項目 node_modules 的依賴時,會通過硬鏈接與符號鏈接在全局 store 中找到這個文件。爲了實現此過程,node_modules 下會多出 .pnpm 目錄,而且是非扁平化結構。

還是使用上面 A,B,C 模塊的示例,使用 pnpm 安裝依賴後 node_modules 結構如下:

node_modules
├── .pnpm
│   ├── A@1.0.0
│   │   └── node_modules
│   │       ├── A => <store>/A@1.0.0
│   │       └── B => ../../B@1.0.0
│   ├── B@1.0.0
│   │   └── node_modules
│   │       └── B => <store>/B@1.0.0
│   ├── B@2.0.0
│   │   └── node_modules
│   │       └── B => <store>/B@2.0.0
│   └── C@1.0.0
│       └── node_modules
│           ├── C => <store>/C@1.0.0
│           └── B => ../../B@2.0.0
│
├── A => .pnpm/A@1.0.0/node_modules/A
└── C => .pnpm/C@1.0.0/node_modules/C

<store>/xxx 開頭的路徑是硬鏈接,指向全局 store 中安裝的依賴。

其餘的是符號鏈接,指向依賴的快捷方式。

pnpm 官方圖片也清晰地解釋了這套機制:

未來可期

這套全新的機制設計地十分巧妙,不僅兼容 node 的依賴解析,同時也解決了:

  1. 幽靈依賴問題:只有直接依賴會平鋪在 node_modules 下,子依賴不會被提升,不會產生幽靈依賴。

  2. 依賴分身問題:相同的依賴只會在全局 store 中安裝一次。項目中的都是源文件的副本,幾乎不佔用任何空間,沒有了依賴分身。

同時,由於鏈接的優勢,pnpm 的安裝速度在大多數場景都比 npm 和 yarn 快 2 倍,節省的磁盤空間也更多。

但也存在一些弊端:

  1. 由於 pnpm 創建的 node_modules 依賴軟鏈接,因此在不支持軟鏈接的環境中,無法使用 pnpm,比如 Electron 應用。

  2. 因爲依賴源文件是安裝在 store 中,調試依賴或 patch-package 給依賴打補丁也不太方便,可能會影響其他項目。

yarn Plug’n’Play - 探索

2020 年 1 月,yarn v2 發佈,也叫 yarn berry(v1 叫 yarn classic)。它是對 yarn 的一次重大升級,其中一項重要更新就是 Plug’n’Play(Plug'n'Play = Plug and Play = PnP,即插即用)。

npm 與 yarn 的依賴安裝與依賴解析都涉及大量的文件 I/O,效率不高。開發 Plug’n’Play 最直接的原因就是依賴引用慢,依賴安裝慢。

拋棄 node_modules

無論是 npm 還是 yarn,都具備緩存的功能,大多數情況下安裝依賴時,其實是將緩存中的相關包複製到項目目錄中 node_modules 裏。

而 yarn PnP 則不會進行拷貝這一步,而是在項目裏維護一張靜態映射表 pnp.cjs。

pnp.cjs 會記錄依賴在緩存中的具體位置,所有依賴都存在全局緩存中。同時自建了一個解析器,在依賴引用時,幫助 node 從全局緩存目錄中發現依賴,而不是查找 node_modules。

這樣就避免了大量的 I/O 操作同時項目目錄也不會有 node_modules 目錄生成,同版本的依賴在全局也只會有一份,依賴的安裝速度和解析速度都有較大提升。

pnpm 在 2020 年底的 v5.9 也支持了 PnP。

脫離 node 生態

pnp 比較明顯的缺點是脫離了 node 生態。

總結

目前還沒有完美的依賴管理方案,可以看到在依賴管理的發展過程中,出現了:

庫與開發者能夠在這樣優化與創新的發展過程中互相學習,站在巨人的肩膀上繼續前進,不斷推動前端工程領域的發展。

參考

一起進步🚀)" data-from="0" has-insert-preloading="1" data-darkmode-color-16565923456148="rgb(163, 163, 163)" data-darkmode-original-color-16565923456148="#fff|rgb(63, 63, 63)"> 小李的前端小屋 關注我 ().then(() => 一起進步🚀)

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