精讀《pnpm》

pnpm 全稱是 “Performant NPM”,即高性能的 npm。它結合軟硬鏈接與新的依賴組織方式,大大提升了包管理的效率,也同時解決了 “幻影依賴” 的問題,讓包管理更加規範,減少潛在風險發生的可能性。

使用 pnpm 很容易,可以使用 npm 安裝:

npm i pnpm -g

之後便可用 pnpm 代替 npm 命令了,比如最重要的安裝包步驟,可以使用 pnpm i 代替 npm i,這樣就算把 pnpm 使用起來了。

pnpm 的優勢

用一個比較好記的詞描述 pnpm 的優勢那就是 “快、準、狠”:

而帶來這些優勢的點子,全在官網上的這張圖上:

所以每個包的尋找都要經過三層結構:node_modules/package-a > 軟鏈接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a > 硬鏈接 ~/.pnpm-store/v3/files/00/xxxxxx

經過這三層尋址帶來了什麼好處呢?爲什麼是三層,而不是兩層或者四層呢?

依賴文件三層尋址的目的

第一層

接着上面的例子思考,第一層尋找依賴是 nodejswebpack 等運行環境 / 打包工具進行的,他們的在 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 安裝規則

pnpmpeer-dependences 有一套嚴格的安裝規則。對於定義了 peer-dependences 的包來說,意味着爲 peer-dependences 內容是敏感的,潛臺詞是說,對於不同的 peer-dependences,這個包可能擁有不同的表現,因此 pnpm 針對不同的 peer-dependences 環境,可能對同一個包創建多份拷貝。

比如包 bar peer-dependences 依賴了 baz^1.0.0foo^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.0foo@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