Pnpm: 最先進的包管理工具

zoomdong發表於2021-08-29

Hi~大家好,今天給大家介紹一個現代的包管理工具,名字叫做 pnpm,英文裡面的意思叫做 performant npm ,意味“高效能的 npm”,官網地址可以參考 pnpm.io/。

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

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

在這篇文章中,將會介紹一些關於 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,那麼這個依賴在多個專案中使用,在每次安裝的時候都會被重新下載一次。

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

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

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

同時該命令提供了一個選項,使用方法為 pnpm store prune ,它提供了一種用於刪除一些不被全域性專案所引用到的 packages 的功能,例如有個包 axios@1.0.0 被一個專案所引用了,但是某次修改使得專案裡這個包被更新到了 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/express@4.17.1/node_modules/xxx
複製程式碼

其中第一個路徑是 nodejs 正常尋找路徑會去找的一個目錄,如果去檢視這個目錄下的內容,會發現裡面連個 node_modules 檔案都沒有:

▾ express
    ▸ lib
      History.md
      index.js
      LICENSE
      package.json
      Readme.md
複製程式碼

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

其中這個 .pnpm 是個虛擬磁碟目錄,然後 express 這個依賴的一些依賴會被平鋪到 .pnpm/express@4.17.1/node_modules/ 這個目錄下面,這樣保證了依賴能夠 require 到,同時也不會形成很深的依賴層級。

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

pnpm 對於不同版本的依賴有著極其嚴格的區分要求,如果專案中某個依賴實際上依賴的 peerDeps 出現了具體版本上的不同,對於這樣的依賴會在虛擬磁碟目錄 .pnpm 有一個比較嚴格的區分,具體可以參考: pnpm.io/how-peers-a… 這篇文章。

綜合而言,本質上 pnpm 的 node_modules 結構是個網狀 + 平鋪的目錄結構。這種依賴結構主要基於軟連線(即 symlink)的方式來完成。

symlink 和 hard link 機制

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

這兩者結合在一起工作之後,假如有一個專案依賴了 bar@1.0.0foo@1.0.0 ,那麼最後的 node_modules 結構呈現出來的依賴結構可能會是這樣的:

node_modules
└── bar // symlink to .pnpm/bar@1.0.0/node_modules/bar
└── foo // symlink to .pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
    ├── bar@1.0.0
    │   └── node_modules
    │       └── bar -> <store>/bar
    │           ├── index.js
    │           └── package.json
    └── foo@1.0.0
        └── 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 的方式:

具體可以參考 github.com/nodejs/node… 該issue 討論。

Monorepo 支援

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

workspace 支援

對於 monorepo 型別的專案,pnpm 提供了 workspace 來支援,具體可以參考官網文件: pnpm.io/workspaces/…

痛點解決

Monorepo 下被人詬病較多的問題,一般是依賴結構問題。常見的兩個問題就是 Phantom dependenciesNPM doppelgangers,用 rush 官網 的圖片可以很貼切的展示著兩個問題:

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

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 依賴 util_e@1.0.0,而 c 和 d 依賴 util_e@2.0.0

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

- package
- package.json
- node_modules
- lib_a
  - node_modules <- util_e@1.0.0
- lib_b
  - node_modules <- util_e@1.0.0
_ lib_c
  - node_modules <- util_e@2.0.0
- lib_d
  - node_modules <- util_e@2.0.0
複製程式碼

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

- package
- package.json
- node_modules
- util_e@1.0.0
- lib_a
- lib_b
_ lib_c
  - node_modules <- util_e@2.0.0
- lib_d
  - node_modules <- util_e@2.0.0
複製程式碼

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

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

目前不適用的場景

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

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

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

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

未來會做的一些事情

脫離 nodejs

具體可以參考 github.com/pnpm/pnpm/d…

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

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

使用 rust 寫一些模組

具體可以看 github.com/pnpm/pnpm/d… 這個 discussion 討論的內容,大概就是作者希望給 pnpm 的一些子命令提供一些 rust 的 cli wrapper 來做提升效能使用。

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

目前作者本人也還在學習 rust 的過程中,具體的 cli rust wrapper 的倉庫地址可以參考: github.com/pnpm/pn,目前還…

總結

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

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

相關文章