pnpm: 最先進的包管理工具

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

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

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

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

依賴管理

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

介紹 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 有一個比較嚴格的區分,具體可以參考: https://pnpm.io/how-peers-are-resolved 這篇文章。

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

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

這兩者結合在一起工作之後,假如有一個項目依賴了 bar@1.0.0 和 foo@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 的方式:

具體可以參考 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 官網 的圖片可以很貼切的展示着兩個問題:

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

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:https://github.com/nodejs/node/discussions/37509

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

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

未來會做的一些事情

脫離 nodejs

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

目前該特性其實已經到了 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 來做提升性能使用。

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

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

總結

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

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

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