前端包管理器對比 npm、yarn 和 pnpm
前端包管理器的發(fā)展
沒有包管理器
依賴(dependency)是別人為了解決一些問題而寫好的代碼,即我們常說的第三方包或三方庫。
一個項目或多或少的會有一些依賴,而你安裝的依賴又可能有它自己的依賴。
比如,你需要寫一個base64編解碼的功能,你可以自己寫,但為什么要自己造輪子呢?大多數(shù)情況下,一個可靠的第三方依賴經(jīng)過多方測試,兼容性和健壯性會比你自己寫的更好。
項目中的依賴,可以是一個完整的庫或者框架,比如 react 或 vue;可以是一個很小的功能,比如日期格式化;也可以是一個命令行工具,比如 eslint。
如果沒有現(xiàn)代化的構(gòu)建工具,即包管理器,你需要用 <script> 標簽來引入依賴。
此外,如果你發(fā)現(xiàn)了一個比當前使用的依賴更好的庫,或者你使用的依賴發(fā)布了更新,而你想用最新版本,在一個大的項目中,這些版本管理、依賴升級將是讓人頭疼的問題。
于是包管理器誕生了,用來管理項目的依賴。
它提供方法給你安裝依賴(即安裝一個包),管理包的存儲位置,而且你可以發(fā)布自己寫的包。
npm1/2
初代npm(Node.js Package Manager)隨著Node.js的發(fā)布出現(xiàn)了。
npm install 原理
主要分為兩個部分, 首先,執(zhí)行 npm install 之后,包如何到達項目 node_modules 當中。其次,node_modules 內(nèi)部如何管理依賴。
執(zhí)行命令后,首先會構(gòu)建依賴樹,然后針對每個節(jié)點下的包,會經(jīng)歷下面四個步驟:
1、將依賴包的版本區(qū)間解析為某個具體的版本號;
2、下載對應版本依賴的 tar 包到本地離線鏡像;
3、將依賴從離線鏡像解壓到本地緩存;
4、將依賴從緩存拷貝到當前目錄的 node_modules 目錄;
然后,對應的包就會到達項目的node_modules當中。
在 npm1、npm2 中呈現(xiàn)出的是嵌套結(jié)構(gòu),比如下面這樣:
這會導致3個問題:
1、依賴層級太深,會導致文件路徑過長的問題,尤其在 window 系統(tǒng)下;
2、大量重復的包被安裝,文件體積超級大。比如跟 foo 同級目錄下有一個baz,兩者都依賴于同一個版本的lodash,那么 lodash 會分別在兩者的 node_modules 中被安裝,也就是重復安裝;
3、模塊實例不能共享。比如 React 有一些內(nèi)部變量,在兩個不同包引入的 React 不是同一個模塊實例,因此無法共享內(nèi)部變量,導致一些不可預知的 bug;
npm3/yarn
從 npm3 開始,包括 yarn,都著手來通過扁平化依賴的方式來解決上面的這個問題:
所有的依賴都被拍平到node_modules目錄下,不再有很深層次的嵌套關系。這樣在安裝新的包時,根據(jù) node require 機制,會不停往上級的node_modules當中去找,如果找到相同版本的包就不會重新安裝,解決了大量包重復安裝的問題,而且依賴層級也不會太深。
但是扁平化帶來了新的問題:
1、package.json里并沒有寫入的包竟然也可以在項目中使用了(Phantom - 幻影依賴)。
2、node_modules安裝的不穩(wěn)定性(Doppelgangers - 分身依賴)。
3、平鋪式的node_modules算法復雜,耗費時間。
Phantom
package.json 中我們只聲明了A,B ~ F都是因為扁平化處理才放到和A同級的 node_modules 下,理論上在項目中寫代碼時只可以使用A,但實際上B ~ F也可以使用,由于扁平化將沒有直接依賴的包提升到 node_modules 一級目錄,Node.js沒有校驗是否有直接依賴,所以項目中可以非法訪問沒有聲明過依賴的包。
Doppelgangers
比如B和C都依賴了F,但是依賴的F版本不一樣:
依賴結(jié)構(gòu)的不確定性表現(xiàn)是扁平化的結(jié)果不確定,以下2種情況都有可能,取決于package.json中B和C的位置。
npm5.x/yarn - 帶有l(wèi)ock文件的平鋪式的node_modules
該版本引入了一個lock文件,以解決node_modules安裝中的不確定因素。 這使得無論你安裝多少次,都能有一個一樣結(jié)構(gòu)的node_modules。 這也是為什么lock文件應該始終包含在版本控制中并且不應該手動編輯的原因。
然而,平鋪式的算法的復雜性,以及Phantom、性能和安全問題仍未得到解決。
pnpm - 基于符號鏈接的node_modules結(jié)構(gòu)
pnpm(Performance npm)的作者Zoltan Kochan發(fā)現(xiàn) yarn 并沒有打算去解決上述的這些問題,于是另起爐灶,寫了全新的包管理器。
pnpm復刻了npm所有的命令,所以使用方法和npm一樣,并且在安裝目錄結(jié)構(gòu)上做了優(yōu)化,特點是善用鏈接,且由于鏈接的優(yōu)勢,大多數(shù)情況下pnpm的安裝速度比yarn和npm更快。
pnpm生成node_modules主要分為兩個步驟:
1、基于硬連接的node_modules
.
└── node_modules
└── .pnpm
├── foo@1.0.0
│ └── node_modules
│ └── foo -> <store>/foo
└── bar@1.0.0
└── node_modules
└── bar -> <store>/bar
乍一看,結(jié)構(gòu)與npm/yarn的結(jié)構(gòu)完全不同,第一手node_modules下面的唯一文件夾叫做.pnpm。在.pnpm下面是一個 <PACKAGE_NAME@VERSION> 文件夾,而在其下面 <PACKAGE_NAME> 的文件夾是一個content-addressable store的硬鏈接。 當然僅僅是這樣還無法使用,所以下一步軟鏈接也很關鍵。
2、用于依賴解析的軟鏈接
- 用于在foo內(nèi)引用bar的軟鏈接
- 在項目里引用foo的軟鏈接
.
└── node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
├── foo@1.0.0
│ └── node_modules
│ ├── foo -> <store>/foo
│ └── bar -> ../../bar@1.0.0/node_modules/bar
└── bar@1.0.0
└── node_modules
└── bar -> <store>/bar
當然這只是使用pnpm的node_modules結(jié)構(gòu)最簡單的例子!但可以發(fā)現(xiàn)項目中能使用的代碼只能是package.json中定義過的,并且完全可以做到?jīng)]用無用的安裝。peers dependencies的話會比這個稍微復雜一些,但一旦不考慮peer的話任何復雜的依賴都可以完全符合這種結(jié)構(gòu)。
例如,當foo和bar同時依賴于lodash的時候,就會像下圖這樣的結(jié)構(gòu)。
.
└── node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
├── foo@1.0.0
│ └── node_modules
│ ├── foo -> <store>/foo
│ ├── bar -> ../../bar@1.0.0/node_modules/bar
│ └── lodash -> ../../lodash@1.0.0/node_modules/lodash
├── bar@1.0.0
│ └── node_modules
│ ├── bar -> <store>/bar
│ └── lodash -> ../../lodash@1.0.0/node_modules/lodash
└── lodash@1.0.0
└── node_modules
└── lodash -> <store>/lodash
node_modules 中的 bar 和 foo 兩個目錄會軟連接到 .pnpm 這個目錄下的真實依賴中,而這些真實依賴則是通過 hard link 存儲到全局的 store 目錄中。
綜合而言,本質(zhì)上 pnpm 的 node_modules 結(jié)構(gòu)是個網(wǎng)狀 + 平鋪的目錄結(jié)構(gòu)。這種依賴結(jié)構(gòu)主要基于軟連接(即 symlink)的方式來完成。
pnpm 是通過 hardlink 在全局里面搞個 store 目錄來存儲 node_modules 依賴里面的 hard link 地址,然后在引用依賴的時候則是通過 symlink 去找到對應虛擬磁盤目錄下(.pnpm 目錄)的依賴地址。
比如安裝A,A依賴了B:
1、安裝依賴
A和B一起放到.pnpm中(和上面相比,這里沒有耗時的扁平化算法)。
另外A@1.0.0下面是node_modules,然后才是A,這樣做有兩點好處:
- 允許包引用自身
- 把包和它的依賴攤平,避免循環(huán)結(jié)構(gòu)
2、處理間接依賴
A平級目錄創(chuàng)建B,B指向B@1.0.0下面的B。
3、處理直接依賴
頂層node_modules目錄下創(chuàng)建A,指向A@1.0.0下的A。
對于更深的依賴,比如A和B都依賴了C:
pnpm 詳細介紹
什么是 pnpm
pnpm 本質(zhì)上就是一個包管理器,這一點跟 npm/yarn 沒有區(qū)別,但它作為殺手锏的優(yōu)勢在于:
- 包安裝速度極快;
- 磁盤空間利用非常高效。
- 支持 monorepo
- 安全性高
它的安裝也非常簡單。可以有多簡單?
npm i -g pnpm
特性概覽
速度快
pnpm 安裝包的速度究竟有多快?先以 React 包為例來對比一下:
可以看到,作為黃色部分的 pnpm,在絕多大數(shù)場景下,包安裝的速度都是明顯優(yōu)于 npm/yarn,速度會比 npm/yarn 快 2-3 倍。
高效利用磁盤空間
npm/yarn - 消耗磁盤空間的node_modules
npm/yarn有一個缺點,就是使用了太多的磁盤空間, 如果你安裝同一個包100次,100分的就會被儲存在不同的node_modules文件夾下。 舉一個常有的的例子,如果完成了一個項目,而node_modules沒有刪掉保留了下來,往往會占用大量的磁盤空間。 為了解決這個問題,我經(jīng)常使用npkill。
$ npx npkill
可以掃描當前文件夾下的所有node_modules,并動態(tài)地刪除它們。
pnpm - 高效的使用磁盤空間
pnpm將包存儲在同一文件夾中(content-addressable store),只要當你在同一OS的同一個用戶在下再次安裝時就只需要創(chuàng)建一個硬鏈接。 MacOs的默認位置是~/.pnpm-store,甚至當安裝同一package的不同版本時,只有不同的部分會被重新保存。 也就是說然后當你安裝一個package時,如果它在store里,建立硬連接新使用,如果沒有,就下載保存在store再創(chuàng)建硬連接。
在使用 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 就就成了個不被引用的包,執(zhí)行 pnpm store prune 就可以在 store 里面刪掉它了。
該命令推薦偶爾進行使用,但不要頻繁使用,因為可能某天這個不被引用的包又突然被哪個項目引用了,這樣就可以不用再去重新下載這個包了。
支持 monorepo
隨著前端工程的日益復雜,越來越多的項目開始使用 monorepo。之前對于多個項目的管理,我們一般都是使用多個 git 倉庫,但 monorepo 的宗旨就是用一個 git 倉庫來管理多個子項目,所有的子項目都存放在根目錄的packages目錄下,那么一個子項目就代表一個package。
pnpm 與 npm/yarn 另外一個很大的不同就是支持了 monorepo,體現(xiàn)在各個子命令的功能上,比如在根目錄下 pnpm add A -r, 那么所有的 package 中都會被添加 A 這個依賴,當然也支持 –filter字段來對 package 進行過濾。
安全性高
不知道你發(fā)現(xiàn)沒有,pnpm 這種依賴管理的方式也很巧妙地規(guī)避了非法訪問依賴的問題,也就是只要一個包未在 package.json 中聲明依賴,那么在項目中是無法訪問的。
但在 npm/yarn 當中是做不到的,那你可能會問了,如果 A 依賴 B, B 依賴 C,那么 A 就算沒有聲明 C 的依賴,由于有依賴提升的存在,C 被裝到了 A 的node_modules里面,那我在 A 里面用 C,跑起來沒有問題呀,我上線了之后,也能正常運行啊。不是挺安全的嗎?
還真不是。
1、你要知道 B 的版本是可能隨時變化的,假如之前依賴的是C@1.0.1,現(xiàn)在發(fā)了新版,新版本的 B 依賴 C@2.0.1,那么在項目 A 當中 npm/yarn install 之后,裝上的是 2.0.1 版本的 C,而 A 當中用的還是 C 當中舊版的 API,可能就直接報錯了。
2、如果 B 更新之后,可能不需要 C 了,那么安裝依賴的時候,C 都不會裝到node_modules里面,A 當中引用 C 的代碼直接報錯。
3、在 monorepo 項目中,如果 A 依賴 X,B 依賴 X,還有一個 C,它不依賴 X,但它代碼里面用到了 X。由于依賴提升的存在,npm/yarn 會把 X 放到根目錄的 node_modules 中,這樣 C 在本地是能夠跑起來的,因為根據(jù) node 的包加載機制,它能夠加載到 monorepo 項目根目錄下的 node_modules 中的 X。但試想一下,一旦 C 單獨發(fā)包出去,用戶單獨安裝 C,那么就找不到 X 了,執(zhí)行到引用 X 的代碼時就直接報錯了。
這些,都是依賴提升潛在的 bug。如果是自己的業(yè)務代碼還好,試想一下如果是給很多開發(fā)者用的工具包,那危害就非常嚴重了。
pnpm以外的解決法
Yarn 要不要支持 symlinks(符號鏈接) 的討論
2016 年 11 月 10 日,一個大佬開了一個 issue:Looking for brilliant yarn member who has first-hand knowledge of prior issues with symlinking modules。
問:「聽@thejameskyle 說,在開源之前,Yarn 曾經(jīng)做過 symlinks(符號鏈接)。然而,它破壞了太多的生態(tài)系統(tǒng)而不能被認為是一個有效的選擇?!谷绻?yarn 成員,對實際損壞的內(nèi)容有第一手消息,并且可以從技術上解釋原因,我懇請您回答這個問題。
然后@sebmck 回答了他的問題:說 symlinks(符號鏈接),對現(xiàn)有的生態(tài)系統(tǒng)不能很好地兼容。
總結(jié)了幾點:
符號鏈接的優(yōu)點:
1、稍微快一點的包安裝;
2、更少的磁盤使用;
存在的缺點:
1、操作系統(tǒng)差異;
2、通過添加多種安裝模式來降低 Yarn 的確定性;
3、文件觀察不兼容;
4、現(xiàn)有工具兼容性差;
5、由循環(huán)引起的遞歸錯誤,等等…
這個 issue,討論了很長的篇幅,這里就不繼續(xù)了,感興趣的可以去觀摩一下。最后顯然 yarn 的團隊暫時不打算支持使用符號鏈接。
npm global-style
npm也曾經(jīng)為了解決扁平式node_modules的問題提供過,通過指定global-style來禁止平鋪node_modules,但這無疑又退回了嵌套式的node_modules時代的問題,所以并沒有推廣開來。
dependency-check
光靠npm/yarn的話看似無法解決,所以基礎社區(qū)的解決方案dependency-check也經(jīng)常被用到。
$ dependency-check ./package.json --verbose
Success! All dependencies used in the code are listed in package.json
Success! All dependencies in package.json are used in the code
有了本文的基礎,光是看到README內(nèi)一段命令行的輸出應該也能想象到dependency-check是如何工作的了吧!
果然和其他的解決方案比,pnpm顯得最為優(yōu)雅吧。
hard link 和 symlink 兼容問題
讀到這里,可能有用戶會好奇: 像 hard link 和 symlink 這種方式在所有的系統(tǒng)上都是兼容的嗎?
實際上 hard link 在主流系統(tǒng)上(Unix/Win)使用都是沒有問題的,但是 symlink 即軟連接的方式可能會在 windows 存在一些兼容的問題,但是針對這個問題,pnpm 也提供了對應的解決方案:
在 win 系統(tǒng)上使用一個叫做 junctions 的特性來替代軟連接,這個方案在 win 上的兼容性要好于 symlink。
或許你也會好奇為啥 pnpm 要使用 hard links 而不是全都用 symlink 來去實現(xiàn)。
實際上存在 store 目錄里面的依賴也是可以通過軟連接去找到的,nodejs 本身有提供一個叫做 –preserve-symlinks 的參數(shù)來支持 symlink,但實際上這個參數(shù)實際上對于 symlink 的支持并不好導致作者放棄了該方案從而采用 hard links 的方式。
npm/yarn 與 pnpm 對比小結(jié)
npm/yarn - 缺點
- 扁平的node_modules結(jié)構(gòu)允許訪問沒有引用的package。
- 來自不同項目的package不能共享,這是對磁盤空間的消耗。
- 安裝緩慢,大量重復安裝node_modules。
pnpm - 解決方案
- pnpm使用獨創(chuàng)的基于symlink的node_modules結(jié)構(gòu),只允許訪問package.json中的引入packages(嚴格)。
- 安裝的package存儲在一個任何文件夾都可以訪問的目錄里并用硬連接到各個node_modules,以節(jié)省磁盤空間(高效)。
- 有了上述改變,安裝也會更快(快速)。
從官方網(wǎng)站上看,嚴格、高效、快速和對于monorepo的支持是pnpm的四大特點。
pnpm 日常使用
說了這么多,估計你會覺得 pnpm 挺復雜的,是不是用起來成本很高呢?
恰好相反,pnpm 使用起來十分簡單,如果你之前有 npm/yarn 的使用經(jīng)驗,甚至可以無縫遷移到 pnpm 上來。不信我們來舉幾個日常使用的例子。
pnpm install
跟 npm install 類似,安裝項目下所有的依賴。但對于 monorepo 項目,會安裝 workspace 下面所有 packages 的所有依賴。不過可以通過 –filter 參數(shù)來指定 package,只對滿足條件的 package 進行依賴安裝。
當然,也可以這樣使用,來進行單個包的安裝:
// 安裝 axios
pnpm install axios
// 安裝 axios 并將 axios 添加至 devDependencies
pnpm install axios -D
// 安裝 axios 并將 axios 添加至 dependencies
pnpm install axios -S
當然,也可以通過 –filter 來指定 package。
pnpm update
根據(jù)指定的范圍將包更新到最新版本,monorepo 項目中可以通過 –filter 來指定 package。
pnpm uninstall
在 node_modules 和 package.json 中移除指定的依賴。monorepo 項目同上。舉例如下:
// 移除 axios
pnpm uninstall axios --filter package-a
pnpm link
將本地項目連接到另一個項目。注意,使用的是硬鏈接,而不是軟鏈接。如:
pnpm link ../../axios
另外,對于我們經(jīng)常用到npm run/start/test/publish,這些直接換成 pnpm 也是一樣的,不再贅述。更多的使用姿勢可參考官方文檔。
總結(jié)
可以看到,雖然 pnpm 內(nèi)部做了非常多復雜的設計,但實際上對于用戶來說是無感知的,使用起來非常友好。并且,現(xiàn)在作者現(xiàn)在還一直在維護,目前 npm 上周下載量已經(jīng)有 10w +,經(jīng)歷了大規(guī)模用戶的考驗,穩(wěn)定性也能有所保障。
因此,我覺得無論是從背后的安全和性能角度,還是從使用上的心智成本來考慮,pnpm 都是一個相比 npm/yarn 更優(yōu)的方案。