天天看點

【Web技術】1167- pnpm: 最先進的包管理工具

Hi~大家好,今天給大家介紹一個現代的包管理工具,名字叫做 pnpm,英文裡面的意思叫做 ​

​performant npm​

​ ,意味“高性能的 npm”,官網位址可以參考 https://pnpm.io/。

目前 pnpm 在位元組内部已經有很多項目中得到了實踐和落地,例如下圖中的 TikTok FE 團隊,我們團隊自研的 Monorepo 工具目前最新版本同樣在底層預設了以 pnpm 作為依賴管理工具。

【Web技術】1167- pnpm: 最先進的包管理工具

pnpm 相比較于 yarn/npm 這兩個常用的包管理工具在性能上也有了極大的提升,根據目前官方提供的 benchmark 資料可以看出在一些綜合場景下比 npm/yarn 快了大概兩倍:

【Web技術】1167- pnpm: 最先進的包管理工具

在這篇文章中,将會介紹一些關于 pnpm 在依賴管理方面的優化,在 monorepo 中相比較于 yarn workspace 的應用,以及也會介紹一些 pnpm 目前存在的一些缺陷,包括讨論一下未來 pnpm 會做的一些事情。

依賴管理

這節會通過 pnpm 在依賴管理這一塊的一些不同于正常包管理工具的一些優化技巧。

hard link 機制

介紹 pnpm 一定離不開的就是關于 pnpm 在安裝依賴方面做的一些優化,根據前面的 benchmark 圖可以看到其明顯的性能提升。

那麼 pnpm 是怎麼做到如此大的提升的呢?是因為計算機裡面一個叫做 Hard link 的機制,​

​hard link​

​​ 使得使用者可以通過不同的路徑引用方式去找到某個檔案。pnpm 會在全局的 store 目錄裡存儲項目 ​

​node_modules​

​​ 檔案的 ​

​hard links​

​ 。

舉個例子,例如項目裡面有個 1MB 的依賴 a,在 pnpm 中,看上去這個 a 依賴同時占用了 1MB 的 node_modules 目錄以及全局 store 目錄 1MB 的空間(加起來是 2MB),但因為 ​

​hard link​

​ 的機制使得兩個目錄下相同的 1MB 空間能從兩個不同位置進行尋址,是以實際上這個 a 依賴隻用占用 1MB 的空間,而不是 2MB。

Store 目錄

上一節提到 store 目錄用于存儲依賴的 hard links,這一節簡單介紹一下這個 sotre 目錄。

一般 store 目錄預設是設定在 ​

​${os.homedir}/.pnpm-store​

​​ 這個目錄下,具體可以參考 ​

​@pnpm/store-path​

​ 這個 pnpm 子包中的代碼:

const homedir = os.homedir()
if (await canLinkToSubdir(tempFile, homedir)) {
await fs.unlink(tempFile)
// If the project is on the drive on which the OS home directory
// then the store is placed in the home directory
return path.join(homedir, relStore, STORE_VERSION)
}      

當然使用者也可以在 ​

​.npmrc​

​ 設定這個 store 目錄位置,不過一般而言 store 目錄對于使用者來說感覺程度是比較小的。

因為這樣一個機制,導緻每次安裝依賴的時候,如果是個相同的依賴,有好多項目都用到這個依賴,那麼這個依賴實際上最優情況(即版本相同)隻用安裝一次。

如果是 npm 或 yarn,那麼這個依賴在多個項目中使用,在每次安裝的時候都會被重新下載下傳一次。

【Web技術】1167- pnpm: 最先進的包管理工具

如圖可以看到在使用 pnpm 對項目安裝依賴的時候,如果某個依賴在 sotre 目錄中存在了話,那麼就會直接從 store 目錄裡面去 hard-link,避免了二次安裝帶來的時間消耗,如果依賴在 store 目錄裡面不存在的話,就會去下載下傳一次。

當然這裡你可能也會有問題:如果安裝了很多很多不同的依賴,那麼 store 目錄會不會越來越大?

答案是當然會存在,針對這個問題,pnpm 提供了一個指令來解決這個問題: pnpm store | pnpm。

同時該指令提供了一個選項,使用方法為 ​

​pnpm store prune​

​​ ,它提供了一種用于删除一些不被全局項目所引用到的 packages 的功能,例如有個包 ​

[email protected]

​​ 被一個項目所引用了,但是某次修改使得項目裡這個包被更新到了 ​

​1.0.1​

​​ ,那麼 store 裡面的 1.0.0 的 axios 就就成了個不被引用的包,執行 ​

​pnpm store prune​

​ 就可以在 store 裡面删掉它了。

該指令推薦偶爾進行使用,但不要頻繁使用,因為可能某天這個不被引用的包又突然被哪個項目引用了,這樣就可以不用再去重新下載下傳這個包了。

node_modules 結構

在 pnpm 官網有一篇很經典的文章,關于介紹 pnpm 項目的 node_modules 結構: Flat node_modules is not the only way | pnpm。

在這篇文章中介紹了 pnpm 目前的 node_modules 的一些檔案結構,例如在項目中使用 pnpm 安裝了一個叫做 ​

​express​

​ 的依賴,那麼最後會在 node_modules 中形成這樣兩個目錄結構:

node_modules/express/...
node_modules/.pnpm/[email protected]/node_modules/xxx      

其中第一個路徑是 nodejs 正常尋找路徑會去找的一個目錄,如果去檢視這個目錄下的内容,會發現裡面連個 ​

​node_modules​

​ 檔案都沒有:

▾ express
    ▸ lib
      History.md
      index.js
      LICENSE
      package.json
      Readme.md      

實際上這個檔案隻是個軟連接配接,它會形成一個到第二個目錄的一個軟連接配接(類似于軟體的快捷方式),這樣 node 在找路徑的時候,最終會找到 .pnpm 這個目錄下的内容。

其中這個 ​

​.pnpm​

​​ 是個虛拟磁盤目錄,然後 express 這個依賴的一些依賴會被平鋪到 ​

​.pnpm/[email protected]/node_modules/​

​ 這個目錄下面,這樣保證了依賴能夠 require 到,同時也不會形成很深的依賴層級。

在保證了 nodejs 能找到依賴路徑的基礎上,同時也很大程度上保證了依賴能很好的被放在一起。

​pnpm​

​​ 對于不同版本的依賴有着極其嚴格的區分要求,如果項目中某個依賴實際上依賴的 ​

​peerDeps​

​​ 出現了具體版本上的不同,對于這樣的依賴會在虛拟磁盤目錄 ​

​.pnpm​

​ 有一個比較嚴格的區分,具體可以參考: https://pnpm.io/how-peers-are-resolved 這篇文章。

綜合而言,本質上 pnpm 的 ​

​node_modules​

​ 結構是個網狀 + 平鋪的目錄結構。這種依賴結構主要基于軟連接配接(即 symlink)的方式來完成。

symlink 和 hard link 機制

在前面知道了 pnpm 是通過 hardlink 在全局裡面搞個 store 目錄來存儲 node_modules 依賴裡面的 hard link 位址,然後在引用依賴的時候則是通過 symlink 去找到對應虛拟磁盤目錄下(.pnpm 目錄)的依賴位址。

這兩者結合在一起工作之後,假如有一個項目依賴了 ​

[email protected]

​​ 和 ​

[email protected]

​ ,那麼最後的 node_modules 結構呈現出來的依賴結構可能會是這樣的:

node_modules
└── bar // symlink to .pnpm/[email protected]/node_modules/bar
└── foo // symlink to .pnpm/[email protected]/node_modules/foo
└── .pnpm
    ├── [email protected]
    │   └── node_modules
    │       └── bar -> <store>/bar
    │           ├── index.js
    │           └── package.json
    └── [email protected]
        └── node_modules
            └── foo -> <store>/foo
                ├── index.js
                └── package.json      

​node_modules​

​ 中的 bar 和 foo 兩個目錄會軟連接配接到 .pnpm 這個目錄下的真實依賴中,而這些真實依賴則是通過 hard link 存儲到全局的 store 目錄中。

相容問題

讀到這裡,可能有使用者會好奇: 像 hard link 和 symlink 這種方式在所有的系統上都是相容的嗎?

實際上 hard link 在主流系統上(​

​Unix/Win​

​)使用都是沒有問題的,但是 symlink 即軟連接配接的方式可能會在 windows 存在一些相容的問題,但是針對這個問題,pnpm 也提供了對應的解決方案:

在 win 系統上使用一個叫做 junctions 的特性來替代軟連接配接,這個方案在 win 上的相容性要好于 symlink。

或許你也會好奇為啥 pnpm 要使用 hard links 而不是全都用 symlink 來去實作。

實際上存在 store 目錄裡面的依賴也是可以通過軟連接配接去找到的,nodejs 本身有提供一個叫做 ​

​--preserve-symlinks​

​ 的參數來支援 symlink,但實際上這個參數實際上對于 symlink 的支援并不好導緻作者放棄了該方案進而采用 hard links 的方式:

【Web技術】1167- pnpm: 最先進的包管理工具

具體可以參考 https://github.com/nodejs/node-eps/issues/46 該issue 讨論。

Monorepo 支援

​pnpm​

​ 在 monorepo 場景可以說算得上是個完美的解決方案了,因為其本身的設計機制,導緻很多關鍵或者說緻命的問題都得到了相當有效的解決。

workspace 支援

對于 monorepo 類型的項目,pnpm 提供了 workspace 來支援,具體可以參考官網文檔: https://pnpm.io/workspaces/。

痛點解決

Monorepo 下被人诟病較多的問題,一般是依賴結構問題。常見的兩個問題就是 ​

​Phantom dependencies​

​​ 和 ​

​NPM doppelgangers​

​,用 rush 官網 的圖檔可以很貼切的展示着兩個問題:

【Web技術】1167- pnpm: 最先進的包管理工具

下面會針對兩個問題一一介紹。

Phantom dependencies

Phantom dependencies 被稱之為幽靈依賴,解釋起來很簡單,即某個包沒有被安裝(​

​package.json​

​ 中并沒有,但是使用者卻能夠引用到這個包)。

引發這個現象的原因一般是因為 node_modules 結構所導緻的,例如使用 yarn 對項目安裝依賴,依賴裡面有個依賴叫做 foo,foo 這個依賴同時依賴了 bar,yarn 會對安裝的 node_modules 做一個扁平化結構的處理(npm v3 之後也是這麼做的),會把依賴在 node_modules 下打平,這樣相當于 foo 和 bar 出現在同一層級下面。那麼根據 nodejs 的尋徑原理,使用者能 require 到 foo,同樣也能 require 到 bar。

package.json -> foo(bar 為 foo 依賴)
node_modules
  /foo
  /bar -> 幽靈依賴      

那麼這裡這個 bar 就成了一個幽靈依賴,如果某天某個版本的 foo 依賴不再依賴 bar 或者 foo 的版本發生了變化,那麼 require bar 的子產品部分就會抛錯。

以上其實隻是一個簡單的例子,但是根據筆者在位元組内部見到的一些 monorepo(主要為 ​

​lerna + yarn​

​ )項目中,這其實是個比較常見的現象,甚至有些包會直接去利用這種殘缺的引入方式去減輕包體積。

還有一種場景就是在 lerna + yarn workspace 的項目裡面,因為 yarn 中提供了 hoist 機制(即一些底層子項目的依賴會被提升到頂層的 ​

​node_modules​

​ 中),這種 phantom dependencies 會更多,一些底層的子項目經常會去 require 一些在自己裡面沒有引入的依賴,而直接去找頂層 node_modules 的依賴(nodejs 這裡的尋徑是個遞歸上下的過程)并使用。

而根據前面提到的 pnpm 的 ​

​node_modules​

​​ 依賴結構,這種現象是顯然不會發生的,因為被打平的依賴會被放到 ​

​.pnpm​

​ 這個虛拟磁盤目錄下面去,使用者通過 require 是根本找不到的。

值得一提的是,pnpm 本身其實也提供了将依賴提升并且按照 yarn 那種形式組織的 node_modules 結構的 Option,作者将其命名為 ​

​--shamefully-hoist​

​ ,即 "羞恥的 hoist".....

NPM doppelgangers

這個問題其實也可以說是 hoist 導緻的,這個問題可能會導緻有大量的依賴的被重複安裝,舉個例子:

例如有個 package,下面依賴有 lib_a、lib_b、lib_c、lib_d,其中 a 和 b 依賴 [email protected],而 c 和 d 依賴 [email protected]

那麼早期 npm 的依賴結構應該是這樣的:

- package
- package.json
- node_modules
- lib_a
  - node_modules <- [email protected]
- lib_b
  - node_modules <- [email protected]
_ lib_c
  - node_modules <- [email protected]
- lib_d
  - node_modules <- [email protected]      

這樣必然會導緻很多依賴被重複安裝,于是就有了 hoist 和打平依賴的操作:

- package
- package.json
- node_modules
- [email protected]
- lib_a
- lib_b
_ lib_c
  - node_modules <- [email protected]
- lib_d
  - node_modules <- [email protected]      

但是這樣也隻能提升一個依賴,如果兩個依賴都提升了會導緻沖突,這樣同樣會導緻一些不同版本的依賴被重複安裝多次,這裡就會導緻使用 npm 和 yarn 的性能損失。

如果是 pnpm 的話,這裡因為依賴始終都是存在 store 目錄下的 hard links ,一份不同的依賴始終都隻會被安裝一次,是以這個是能夠被徹徹底底的消除的。

目前不适用的場景

前面有提到關于 pnpm 的主要問題在于 symlink(軟連結)在一些場景下會存在相容的問題,可以參考作者在 nodejs 那邊開的一個 discussion:https://github.com/nodejs/node/discussions/37509

【Web技術】1167- pnpm: 最先進的包管理工具

在裡面作者提到了目前 nodejs 軟連接配接不能适用的一些場景,希望 nodejs 能提供一種 link 方式而不是使用軟連接配接,同時也提到了 pnpm 目前因為軟連接配接而不能使用的場景:

  • Electron 應用無法使用 pnpm
  • 部署在 lambda 上的應用無法使用 pnpm

筆者在位元組内部使用 pnpm 時也遇到過一些 nodejs 基礎庫不支援 symlink 的情況導緻使用 pnpm 無法正常工作,不過這些庫在疊代更新之後也會支援這一特性。

【Web技術】1167- pnpm: 最先進的包管理工具

未來會做的一些事情

脫離 nodejs

具體可以參考 https://github.com/pnpm/pnpm/discussions/3434

  • 安裝 pnpm 的, 可以基本上脫離掉 nodejs 這個 runtime 去進行安裝使用。
  • 可以通過 pnpm 來使用不同版本的 nodejs 來去做依賴安裝,類似于 nvm 提供的功能。

目前該特性其實已經到了 beta 版本,可以參考 https://www.npmjs.com/package/@pnpm/beta 這個包。管理不同版本的 nodejs 功能可以參考 env 這個子指令: https://pnpm.io/cli/env

使用 rust 寫一些子產品

具體可以看 https://github.com/pnpm/pnpm/discussions/3419 這個 discussion 讨論的内容,大概就是作者希望給 pnpm 的一些子指令提供一些 rust 的 cli wrapper 來做提升性能使用。

【Web技術】1167- pnpm: 最先進的包管理工具

目前這個目前還沒有特别大的進展,但還是為作者的想法點贊,作者本人對于這個的回應是“如果這個 pnpm 不去做,那麼會有其他工具去做,最後 pnpm 就會被淘汰”。

【Web技術】1167- pnpm: 最先進的包管理工具

目前作者本人也還在學習 rust 的過程中,具體的 cli rust wrapper 的倉庫位址可以參考: https://github.com/pnpm/pn,目前還隻是處于一個起步的階段。

總結

目前基于 pnpm 為依賴管理的 monorepo 工具例如 rush 在開源社群得到了廣泛的實踐,在位元組内部的我們組自研的 Monorepo 工具中同樣基于 pnpm 作為依賴管理工具,目前已經落地了大量的項目。

pnpm 作為包管理器裡面的“後起之秀”,通過作者别出心裁的設計方案,完美解決了許多了現有的包管理工具 npm、yarn 以及 node_modules 本身設計原因留下的痛點。同時作者本人也十分有進取心,努力的在完善 pnpm 的 feature 以及規劃未來的發展方向,期待未來能越來越好吧~