精讀《pnpm》
pnpm 全稱是 “Performant NPM”,即高性能的 npm。它結合軟硬鏈接與新的依賴組織方式,大大提升了包管理的效率,也同時解決了 “幻影依賴” 的問題,讓包管理更加規範,減少潛在風險發生的可能性。
使用 pnpm
很容易,可以使用 npm
安裝:
npm i pnpm -g
之後便可用 pnpm
代替 npm
命令了,比如最重要的安裝包步驟,可以使用 pnpm i
代替 npm i
,這樣就算把 pnpm
使用起來了。
pnpm 的優勢
用一個比較好記的詞描述 pnpm
的優勢那就是 “快、準、狠”:
-
快:安裝速度快。
-
準:安裝過的依賴會準確複用緩存,甚至包版本升級帶來的變化都只 diff,絕不浪費一點空間,邏輯上也嚴絲合縫。
-
狠:直接廢掉了幻影依賴,在邏輯合理性與含糊的便捷性上,毫不留情的選擇了邏輯合理性。
而帶來這些優勢的點子,全在官網上的這張圖上:
-
所有 npm 包都安裝在全局目錄
~/.pnpm-store/v3/files
下,同一版本的包僅存儲一份內容,甚至不同版本的包也僅存儲 diff 內容。 -
每個項目的
node_modules
下有.pnpm
目錄以打平結構管理每個版本包的源碼內容,以硬鏈接方式指向 pnpm-store 中的文件地址。 -
每個項目
node_modules
下安裝的包結構爲樹狀,符合 node 就近查找規則,以軟鏈接方式將內容指向node_modules/.pnpm
中的包。
所以每個包的尋找都要經過三層結構:node_modules/package-a
> 軟鏈接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a
> 硬鏈接 ~/.pnpm-store/v3/files/00/xxxxxx
。
經過這三層尋址帶來了什麼好處呢?爲什麼是三層,而不是兩層或者四層呢?
依賴文件三層尋址的目的
第一層
接着上面的例子思考,第一層尋找依賴是 nodejs
或 webpack
等運行環境 / 打包工具進行的,他們的在 node_modules
文件夾尋找依賴,並遵循就近原則,所以第一層依賴文件勢必要寫在 node_modules/package-a
下,一方面遵循依賴尋找路徑,一方面沒有將依賴都拎到上級目錄,也沒有將依賴打平,目的就是還原最語義化的 package.json
定義:即定義了什麼包就能依賴什麼包,反之則不行,同時每個包的子依賴也從該包內尋找,解決了多版本管理的問題,同時也使 node_modules
擁有一個穩定的結構,即該目錄組織算法僅與 package.json
定義有關,而與包安裝順序無關。
如果止步於此,這就是 npm@2.x
的包管理方案,但正因爲 npm@2.x
包管理方案最沒有歧義,所以第一層沿用了該方案的設計。
第二層
從第二層開始,就要解決 npm@2.x
設計帶來的問題了,主要是包複用的問題。所以第二層的 node_modules/package-a
> 軟鏈接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a
尋址利用軟鏈接解決了代碼重複引用的問題。相比 npm@3
將包打平的設計,軟鏈接可以保持包結構的穩定,同時用文件指針解決重複佔用硬盤空間的問題。
若止步於此,也已經解決了一個項目內的包管理問題,但項目不止一個,多個項目對於同一個包的多份拷貝還是太浪費,因此要進行第三步映射。
第三層
第三層映射 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a
> 硬鏈接 ~/.pnpm-store/v3/files/00/xxxxxx
已經脫離當前項目路徑,指向一個全局統一管理路徑了,這正是跨項目複用的必然選擇,然而 pnpm
更進一步,沒有將包的源碼直接存儲在 pnpm-store,而是將其拆分爲一個個文件塊,這在後面詳細講解。
幻影依賴
幻影依賴是指,項目代碼引用的某個包沒有直接定義在 package.json
中,而是作爲子依賴被某個包順帶安裝了。代碼裏依賴幻影依賴的最大隱患是,對包的語義化控制不能穿透到其子包,也就是包 a@patch
的改動可能意味着其子依賴包 b@major
級別的 Break Change。
正因爲這三層尋址的設計,使得第一層可以僅包含 package.json
定義的包,使 node_modules 不可能尋址到未定義在 package.json
中的包,自然就解決了幻影依賴的問題。
但還有一種更難以解決的幻影依賴問題,即用戶在 Monorepo 項目根目錄安裝了某個包,這個包可能被某個子 Package 內的代碼尋址到,要徹底解決這個問題,需要配合使用 Rush,在工程上通過依賴問題檢測來徹底解決。
peer-dependences 安裝規則
pnpm
對 peer-dependences
有一套嚴格的安裝規則。對於定義了 peer-dependences
的包來說,意味着爲 peer-dependences
內容是敏感的,潛臺詞是說,對於不同的 peer-dependences
,這個包可能擁有不同的表現,因此 pnpm
針對不同的 peer-dependences
環境,可能對同一個包創建多份拷貝。
比如包 bar
peer-dependences
依賴了 baz^1.0.0
與 foo^1.0.0
,那我們在 Monorepo 環境兩個 Packages 下分別安裝不同版本的包會如何呢?
- foo-parent-1
- bar@1.0.0
- baz@1.0.0
- foo@1.0.0
- foo-parent-2
- bar@1.0.0
- baz@1.1.0
- foo@1.0.0
結果是這樣(引用官網文檔例子):
node_modules
└── .pnpm
├── foo@1.0.0_bar@1.0.0+baz@1.0.0
│ └── node_modules
│ ├── foo
│ ├── bar -> ../../bar@1.0.0/node_modules/bar
│ ├── baz -> ../../baz@1.0.0/node_modules/baz
│ ├── qux -> ../../qux@1.0.0/node_modules/qux
│ └── plugh -> ../../plugh@1.0.0/node_modules/plugh
├── foo@1.0.0_bar@1.0.0+baz@1.1.0
│ └── node_modules
│ ├── foo
│ ├── bar -> ../../bar@1.0.0/node_modules/bar
│ ├── baz -> ../../baz@1.1.0/node_modules/baz
│ ├── qux -> ../../qux@1.0.0/node_modules/qux
│ └── plugh -> ../../plugh@1.0.0/node_modules/plugh
├── bar@1.0.0
├── baz@1.0.0
├── baz@1.1.0
├── qux@1.0.0
├── plugh@1.0.0
可以看到,安裝了兩個相同版本的 foo
,雖然內容完全一樣,但卻分別擁有不同的名稱:foo@1.0.0_bar@1.0.0+baz@1.0.0
、foo@1.0.0_bar@1.0.0+baz@1.1.0
。這也是 pnpm
規則嚴格的體現,任何包都不應該有全局副作用,或者考慮好單例實現,否則可能會被 pnpm
裝多次。
硬連接與軟鏈接的原理
要理解 pnpm
軟硬鏈接的設計,首先要複習一下操作系統文件子系統對軟硬鏈接的實現。
硬鏈接通過 ln originFilePath newFilePath
創建,如 ln ./my.txt ./hard.txt
,這樣創建出來的 hard.txt
文件與 my.txt
都指向同一個文件存儲地址,因此無論修改哪個文件,都因爲直接修改了原始地址的內容,導致這兩個文件內容同時變化。進一步說,通過硬鏈接創建的 N 個文件都是等效的,通過 ls -li ./
查看文件屬性時,可以看到通過硬鏈接創建的兩個文件擁有相同的 inode 索引:
ls -li ./
84976912 -rw-r--r-- 2 author staff 489 Jun 9 15:41 my.txt
84976912 -rw-r--r-- 2 author staff 489 Jun 9 15:41 hard.txt
其中第三個參數 2 表示該文件指向的存儲地址有兩個硬鏈接引用。硬鏈接如果要指向目錄就麻煩多了,第一個問題是這樣會導致文件的父目錄有歧義,同時還要將所有子文件都創建硬鏈接,實現複雜度較高,因此 Linux 並沒有提供這種能力。
軟鏈接通過 ln -s originFilePath newFilePath
創建,可以認爲是指向文件地址指針的指針,即它本身擁有一個新的 inode 索引,但文件內容僅包含指向的文件路徑,如:
84976913 -rw-r--r-- 2 author staff 489 Jun 9 15:41 soft.txt -> my.txt
源文件被刪除時,軟鏈接也會失效,但硬鏈接不會,軟鏈接可以對文件夾生效。因此 pnpm
雖然採用了軟硬結合的方式實現代碼複用,但軟鏈接本身也幾乎不會佔用多少額外的存儲空間,硬鏈接模式更是零額外內存空間佔用,所以對於相同的包,pnpm
額外佔用的存儲空間可以約等於零。
全局安裝目錄 pnpm-store 的組織方式
pnpm
在第三層尋址時採用了硬鏈接方式,但同時還留下了一個問題沒有講,即這個硬鏈接目標文件並不是普通的 NPM 包源碼,而是一個哈希文件,這種文件組織方式叫做 content-addressable(基於內容的尋址)。
簡單來說,基於內容的尋址比基於文件名尋址的好處是,即便包版本升級了,也僅需存儲改動 Diff,而不需要存儲新版本的完整文件內容,在版本管理上進一步節約了存儲空間。
pnpm-store 的組織方式大概是這樣的:
~/.pnpm-store
- v3
- files
- 00
- e4e13870602ad2922bfc7..
- e99f6ffa679b846dfcbb1..
..
- 01
..
- ..
..
- ff
..
也就是採用文件內容尋址,而非文件位置尋址的存儲方式。之所以能採用這種存儲方式,是因爲 NPM 包一經發布內容就不會再改變,因此適合內容尋址這種內容固定的場景,同時內容尋址也忽略了包的結構關係,當一個新包下載下來解壓後,遇到相同文件 Hash 值時就可以拋棄,僅存儲 Hash 值不存在的文件,這樣就自然實現了開頭說的,pnpm
對於同一個包不同的版本也僅存儲其增量改動的能力。
總結
pnpm
通過三層尋址,既貼合了 node_modules
默認尋址方式,又解決了重複文件安裝的問題,順便解決了幻影依賴問題,可以說是包管理的目前最好的創新,沒有之一。
但其苛刻的包管理邏輯,使我們單獨使用 pnpm
管理大型 Monorepo 時容易遇到一些符合邏輯但又覺得彆扭的地方,比如如果每個 Package 對於同一個包的引用版本產生了分化,可能會導致 Peer Deps 了這些包的包產生多份實例,而這些包版本的分化可能是不小心導致的,我們可能需要使用 Rush 等 Monorepo 管理工具來保證版本的一致性。
討論地址是:精讀《pnpm》· Issue #435 · dt-fe/weekly
關注 前端精讀微信公衆號
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/xxNrtwGgjXLihkUmZ1sGzw