【W(wǎng)eb技術(shù)】1167- pnpm: 最先進(jìn)的包管理工具
Hi~大家好,今天給大家介紹一個(gè)現(xiàn)代的包管理工具,名字叫做 pnpm,英文里面的意思叫做?performant npm?,意味“高性能的 npm”,官網(wǎng)地址可以參考 https://pnpm.io/。
目前 pnpm 在字節(jié)內(nèi)部已經(jīng)有很多項(xiàng)目中得到了實(shí)踐和落地,例如下圖中的 TikTok FE 團(tuán)隊(duì),我們團(tuán)隊(duì)自研的 Monorepo 工具目前最新版本同樣在底層默認(rèn)了以 pnpm 作為依賴管理工具。

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

在這篇文章中,將會(huì)介紹一些關(guān)于 pnpm 在依賴管理方面的優(yōu)化,在 monorepo 中相比較于 yarn workspace 的應(yīng)用,以及也會(huì)介紹一些 pnpm 目前存在的一些缺陷,包括討論一下未來(lái) pnpm 會(huì)做的一些事情。
依賴管理
這節(jié)會(huì)通過(guò) pnpm 在依賴管理這一塊的一些不同于正常包管理工具的一些優(yōu)化技巧。
hard link 機(jī)制
介紹 pnpm 一定離不開(kāi)的就是關(guān)于 pnpm 在安裝依賴方面做的一些優(yōu)化,根據(jù)前面的 benchmark 圖可以看到其明顯的性能提升。
那么 pnpm 是怎么做到如此大的提升的呢?是因?yàn)橛?jì)算機(jī)里面一個(gè)叫做?Hard link?的機(jī)制,hard link?使得用戶可以通過(guò)不同的路徑引用方式去找到某個(gè)文件。pnpm 會(huì)在全局的 store 目錄里存儲(chǔ)項(xiàng)目?node_modules?文件的?hard links?。
舉個(gè)例子,例如項(xiàng)目里面有個(gè) 1MB 的依賴 a,在 pnpm 中,看上去這個(gè) a 依賴同時(shí)占用了 1MB 的 node_modules 目錄以及全局 store 目錄 1MB 的空間(加起來(lái)是 2MB),但因?yàn)?hard link?的機(jī)制使得兩個(gè)目錄下相同的 1MB 空間能從兩個(gè)不同位置進(jìn)行尋址,因此實(shí)際上這個(gè) a 依賴只用占用 1MB 的空間,而不是 2MB。
Store 目錄
上一節(jié)提到 store 目錄用于存儲(chǔ)依賴的 hard links,這一節(jié)簡(jiǎn)單介紹一下這個(gè) sotre 目錄。
一般 store 目錄默認(rèn)是設(shè)置在?${os.homedir}/.pnpm-store?這個(gè)目錄下,具體可以參考?@pnpm/store-path?這個(gè) 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)
}
當(dāng)然用戶也可以在?.npmrc?設(shè)置這個(gè) store 目錄位置,不過(guò)一般而言 store 目錄對(duì)于用戶來(lái)說(shuō)感知程度是比較小的。
因?yàn)檫@樣一個(gè)機(jī)制,導(dǎo)致每次安裝依賴的時(shí)候,如果是個(gè)相同的依賴,有好多項(xiàng)目都用到這個(gè)依賴,那么這個(gè)依賴實(shí)際上最優(yōu)情況(即版本相同)只用安裝一次。
如果是 npm 或 yarn,那么這個(gè)依賴在多個(gè)項(xiàng)目中使用,在每次安裝的時(shí)候都會(huì)被重新下載一次。

如圖可以看到在使用 pnpm 對(duì)項(xiàng)目安裝依賴的時(shí)候,如果某個(gè)依賴在 sotre 目錄中存在了話,那么就會(huì)直接從 store 目錄里面去 hard-link,避免了二次安裝帶來(lái)的時(shí)間消耗,如果依賴在 store 目錄里面不存在的話,就會(huì)去下載一次。
當(dāng)然這里你可能也會(huì)有問(wèn)題:如果安裝了很多很多不同的依賴,那么 store 目錄會(huì)不會(huì)越來(lái)越大?
答案是當(dāng)然會(huì)存在,針對(duì)這個(gè)問(wèn)題,pnpm 提供了一個(gè)命令來(lái)解決這個(gè)問(wèn)題: pnpm store | pnpm。
同時(shí)該命令提供了一個(gè)選項(xiàng),使用方法為?pnpm store prune?,它提供了一種用于刪除一些不被全局項(xiàng)目所引用到的 packages 的功能,例如有個(gè)包?[email protected]?被一個(gè)項(xiàng)目所引用了,但是某次修改使得項(xiàng)目里這個(gè)包被更新到了?1.0.1?,那么 store 里面的 1.0.0 的 axios 就就成了個(gè)不被引用的包,執(zhí)行?pnpm store prune?就可以在 store 里面刪掉它了。
該命令推薦偶爾進(jìn)行使用,但不要頻繁使用,因?yàn)榭赡苣程爝@個(gè)不被引用的包又突然被哪個(gè)項(xiàng)目引用了,這樣就可以不用再去重新下載這個(gè)包了。
node_modules 結(jié)構(gòu)
在 pnpm 官網(wǎng)有一篇很經(jīng)典的文章,關(guān)于介紹 pnpm 項(xiàng)目的 node_modules 結(jié)構(gòu): Flat node_modules is not the only way | pnpm。
在這篇文章中介紹了 pnpm 目前的 node_modules 的一些文件結(jié)構(gòu),例如在項(xiàng)目中使用 pnpm 安裝了一個(gè)叫做?express?的依賴,那么最后會(huì)在 node_modules 中形成這樣兩個(gè)目錄結(jié)構(gòu):
node_modules/express/...
node_modules/.pnpm/[email protected]/node_modules/xxx
其中第一個(gè)路徑是 nodejs 正常尋找路徑會(huì)去找的一個(gè)目錄,如果去查看這個(gè)目錄下的內(nèi)容,會(huì)發(fā)現(xiàn)里面連個(gè)?node_modules?文件都沒(méi)有:
? express
? lib
History.md
index.js
LICENSE
package.json
Readme.md
實(shí)際上這個(gè)文件只是個(gè)軟連接,它會(huì)形成一個(gè)到第二個(gè)目錄的一個(gè)軟連接(類似于軟件的快捷方式),這樣 node 在找路徑的時(shí)候,最終會(huì)找到 .pnpm 這個(gè)目錄下的內(nèi)容。
其中這個(gè)?.pnpm?是個(gè)虛擬磁盤(pán)目錄,然后 express 這個(gè)依賴的一些依賴會(huì)被平鋪到?.pnpm/[email protected]/node_modules/?這個(gè)目錄下面,這樣保證了依賴能夠 require 到,同時(shí)也不會(huì)形成很深的依賴層級(jí)。
在保證了 nodejs 能找到依賴路徑的基礎(chǔ)上,同時(shí)也很大程度上保證了依賴能很好的被放在一起。
pnpm?對(duì)于不同版本的依賴有著極其嚴(yán)格的區(qū)分要求,如果項(xiàng)目中某個(gè)依賴實(shí)際上依賴的?peerDeps?出現(xiàn)了具體版本上的不同,對(duì)于這樣的依賴會(huì)在虛擬磁盤(pán)目錄?.pnpm?有一個(gè)比較嚴(yán)格的區(qū)分,具體可以參考: https://pnpm.io/how-peers-are-resolved 這篇文章。
綜合而言,本質(zhì)上 pnpm 的?node_modules?結(jié)構(gòu)是個(gè)網(wǎng)狀 + 平鋪的目錄結(jié)構(gòu)。這種依賴結(jié)構(gòu)主要基于軟連接(即 symlink)的方式來(lái)完成。
symlink 和 hard link 機(jī)制
在前面知道了 pnpm 是通過(guò) hardlink 在全局里面搞個(gè) store 目錄來(lái)存儲(chǔ) node_modules 依賴?yán)锩娴?hard link 地址,然后在引用依賴的時(shí)候則是通過(guò) symlink 去找到對(duì)應(yīng)虛擬磁盤(pán)目錄下(.pnpm 目錄)的依賴地址。
這兩者結(jié)合在一起工作之后,假如有一個(gè)項(xiàng)目依賴了?[email protected]?和?[email protected]?,那么最后的 node_modules 結(jié)構(gòu)呈現(xiàn)出來(lái)的依賴結(jié)構(gòu)可能會(huì)是這樣的:
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 -> /bar
│ ├── index.js
│ └── package.json
└── [email protected]
└── node_modules
└── foo -> /foo
├── index.js
└── package.json
node_modules?中的 bar 和 foo 兩個(gè)目錄會(huì)軟連接到 .pnpm 這個(gè)目錄下的真實(shí)依賴中,而這些真實(shí)依賴則是通過(guò) hard link 存儲(chǔ)到全局的 store 目錄中。
兼容問(wèn)題
讀到這里,可能有用戶會(huì)好奇: 像 hard link 和 symlink 這種方式在所有的系統(tǒng)上都是兼容的嗎?
實(shí)際上 hard link 在主流系統(tǒng)上(Unix/Win)使用都是沒(méi)有問(wèn)題的,但是 symlink 即軟連接的方式可能會(huì)在 windows 存在一些兼容的問(wèn)題,但是針對(duì)這個(gè)問(wèn)題,pnpm 也提供了對(duì)應(yīng)的解決方案:
在 win 系統(tǒng)上使用一個(gè)叫做 junctions 的特性來(lái)替代軟連接,這個(gè)方案在 win 上的兼容性要好于 symlink。
或許你也會(huì)好奇為啥 pnpm 要使用 hard links 而不是全都用 symlink 來(lái)去實(shí)現(xiàn)。
實(shí)際上存在 store 目錄里面的依賴也是可以通過(guò)軟連接去找到的,nodejs 本身有提供一個(gè)叫做?--preserve-symlinks?的參數(shù)來(lái)支持 symlink,但實(shí)際上這個(gè)參數(shù)實(shí)際上對(duì)于 symlink 的支持并不好導(dǎo)致作者放棄了該方案從而采用 hard links 的方式:

具體可以參考 https://github.com/nodejs/node-eps/issues/46 該issue 討論。
Monorepo 支持
pnpm?在 monorepo 場(chǎng)景可以說(shuō)算得上是個(gè)完美的解決方案了,因?yàn)槠浔旧淼脑O(shè)計(jì)機(jī)制,導(dǎo)致很多關(guān)鍵或者說(shuō)致命的問(wèn)題都得到了相當(dāng)有效的解決。
workspace 支持
對(duì)于 monorepo 類型的項(xiàng)目,pnpm 提供了 workspace 來(lái)支持,具體可以參考官網(wǎng)文檔: https://pnpm.io/workspaces/。
痛點(diǎn)解決
Monorepo 下被人詬病較多的問(wèn)題,一般是依賴結(jié)構(gòu)問(wèn)題。常見(jiàn)的兩個(gè)問(wèn)題就是?Phantom dependencies?和?NPM doppelgangers,用?rush 官網(wǎng)?的圖片可以很貼切的展示著兩個(gè)問(wèn)題:

下面會(huì)針對(duì)兩個(gè)問(wèn)題一一介紹。
Phantom dependencies
Phantom dependencies 被稱之為幽靈依賴,解釋起來(lái)很簡(jiǎn)單,即某個(gè)包沒(méi)有被安裝(package.json?中并沒(méi)有,但是用戶卻能夠引用到這個(gè)包)。
引發(fā)這個(gè)現(xiàn)象的原因一般是因?yàn)?node_modules 結(jié)構(gòu)所導(dǎo)致的,例如使用 yarn 對(duì)項(xiàng)目安裝依賴,依賴?yán)锩嬗袀€(gè)依賴叫做 foo,foo 這個(gè)依賴同時(shí)依賴了 bar,yarn 會(huì)對(duì)安裝的 node_modules 做一個(gè)扁平化結(jié)構(gòu)的處理(npm v3 之后也是這么做的),會(huì)把依賴在 node_modules 下打平,這樣相當(dāng)于 foo 和 bar 出現(xiàn)在同一層級(jí)下面。那么根據(jù) nodejs 的尋徑原理,用戶能 require 到 foo,同樣也能 require 到 bar。
package.json -> foo(bar 為 foo 依賴)
node_modules
/foo
/bar -> 幽靈依賴
那么這里這個(gè) bar 就成了一個(gè)幽靈依賴,如果某天某個(gè)版本的 foo 依賴不再依賴 bar 或者 foo 的版本發(fā)生了變化,那么 require bar 的模塊部分就會(huì)拋錯(cuò)。
以上其實(shí)只是一個(gè)簡(jiǎn)單的例子,但是根據(jù)筆者在字節(jié)內(nèi)部見(jiàn)到的一些 monorepo(主要為?lerna + yarn?)項(xiàng)目中,這其實(shí)是個(gè)比較常見(jiàn)的現(xiàn)象,甚至有些包會(huì)直接去利用這種殘缺的引入方式去減輕包體積。
還有一種場(chǎng)景就是在 lerna + yarn workspace 的項(xiàng)目里面,因?yàn)?yarn 中提供了 hoist 機(jī)制(即一些底層子項(xiàng)目的依賴會(huì)被提升到頂層的?node_modules?中),這種 phantom dependencies 會(huì)更多,一些底層的子項(xiàng)目經(jīng)常會(huì)去 require 一些在自己里面沒(méi)有引入的依賴,而直接去找頂層 node_modules 的依賴(nodejs 這里的尋徑是個(gè)遞歸上下的過(guò)程)并使用。
而根據(jù)前面提到的 pnpm 的?node_modules?依賴結(jié)構(gòu),這種現(xiàn)象是顯然不會(huì)發(fā)生的,因?yàn)楸淮蚱降囊蕾嚂?huì)被放到?.pnpm?這個(gè)虛擬磁盤(pán)目錄下面去,用戶通過(guò) require 是根本找不到的。
值得一提的是,pnpm 本身其實(shí)也提供了將依賴提升并且按照 yarn 那種形式組織的 node_modules 結(jié)構(gòu)的 Option,作者將其命名為?
--shamefully-hoist?,即 "羞恥的 hoist".....
NPM doppelgangers
這個(gè)問(wèn)題其實(shí)也可以說(shuō)是 hoist 導(dǎo)致的,這個(gè)問(wèn)題可能會(huì)導(dǎo)致有大量的依賴的被重復(fù)安裝,舉個(gè)例子:
例如有個(gè) package,下面依賴有 lib_a、lib_b、lib_c、lib_d,其中 a 和 b 依賴 [email protected],而 c 和 d 依賴 [email protected]。
那么早期 npm 的依賴結(jié)構(gòu)應(yīng)該是這樣的:
- 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]
這樣必然會(huì)導(dǎo)致很多依賴被重復(fù)安裝,于是就有了 hoist 和打平依賴的操作:
- package
- package.json
- node_modules
- [email protected]
- lib_a
- lib_b
_ lib_c
- node_modules <- [email protected]
- lib_d
- node_modules <- [email protected]
但是這樣也只能提升一個(gè)依賴,如果兩個(gè)依賴都提升了會(huì)導(dǎo)致沖突,這樣同樣會(huì)導(dǎo)致一些不同版本的依賴被重復(fù)安裝多次,這里就會(huì)導(dǎo)致使用 npm 和 yarn 的性能損失。
如果是 pnpm 的話,這里因?yàn)橐蕾囀冀K都是存在 store 目錄下的 hard links ,一份不同的依賴始終都只會(huì)被安裝一次,因此這個(gè)是能夠被徹徹底底的消除的。
目前不適用的場(chǎng)景
前面有提到關(guān)于 pnpm 的主要問(wèn)題在于 symlink(軟鏈接)在一些場(chǎng)景下會(huì)存在兼容的問(wèn)題,可以參考作者在 nodejs 那邊開(kāi)的一個(gè) discussion:https://github.com/nodejs/node/discussions/37509

在里面作者提到了目前 nodejs 軟連接不能適用的一些場(chǎng)景,希望 nodejs 能提供一種 link 方式而不是使用軟連接,同時(shí)也提到了 pnpm 目前因?yàn)檐涍B接而不能使用的場(chǎng)景:
Electron 應(yīng)用無(wú)法使用 pnpm
部署在?lambda?上的應(yīng)用無(wú)法使用 pnpm
筆者在字節(jié)內(nèi)部使用 pnpm 時(shí)也遇到過(guò)一些 nodejs 基礎(chǔ)庫(kù)不支持 symlink 的情況導(dǎo)致使用 pnpm 無(wú)法正常工作,不過(guò)這些庫(kù)在迭代更新之后也會(huì)支持這一特性。

未來(lái)會(huì)做的一些事情
脫離 nodejs
具體可以參考?https://github.com/pnpm/pnpm/discussions/3434
安裝 pnpm 的, 可以基本上脫離掉 nodejs 這個(gè) runtime 去進(jìn)行安裝使用。
可以通過(guò) pnpm 來(lái)使用不同版本的 nodejs 來(lái)去做依賴安裝,類似于 nvm 提供的功能。
目前該特性其實(shí)已經(jīng)到了 beta 版本,可以參考 https://www.npmjs.com/package/@pnpm/beta 這個(gè)包。管理不同版本的 nodejs 功能可以參考 env 這個(gè)子命令: https://pnpm.io/cli/env
使用 rust 寫(xiě)一些模塊
具體可以看 https://github.com/pnpm/pnpm/discussions/3419 這個(gè) discussion 討論的內(nèi)容,大概就是作者希望給 pnpm 的一些子命令提供一些 rust 的 cli wrapper 來(lái)做提升性能使用。

目前這個(gè)目前還沒(méi)有特別大的進(jìn)展,但還是為作者的想法點(diǎn)贊,作者本人對(duì)于這個(gè)的回應(yīng)是“如果這個(gè) pnpm 不去做,那么會(huì)有其他工具去做,最后 pnpm 就會(huì)被淘汰”。

目前作者本人也還在學(xué)習(xí) rust 的過(guò)程中,具體的 cli rust wrapper 的倉(cāng)庫(kù)地址可以參考: https://github.com/pnpm/pn,目前還只是處于一個(gè)起步的階段。
總結(jié)
目前基于 pnpm 為依賴管理的 monorepo 工具例如 rush 在開(kāi)源社區(qū)得到了廣泛的實(shí)踐,在字節(jié)內(nèi)部的我們組自研的 Monorepo 工具中同樣基于 pnpm 作為依賴管理工具,目前已經(jīng)落地了大量的項(xiàng)目。
pnpm 作為包管理器里面的“后起之秀”,通過(guò)作者別出心裁的設(shè)計(jì)方案,完美解決了許多了現(xiàn)有的包管理工具 npm、yarn 以及 node_modules 本身設(shè)計(jì)原因留下的痛點(diǎn)。同時(shí)作者本人也十分有進(jìn)取心,努力的在完善 pnpm 的 feature 以及規(guī)劃未來(lái)的發(fā)展方向,期待未來(lái)能越來(lái)越好吧~

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點(diǎn)擊“閱讀原文”查看 130+ 篇原創(chuàng)文章
