認識包管理工具: npm、yarn和pnpm

specialCoder發表於2024-12-02

一、包管理工具的發展

2010 年 1 月,一款名為 npm 的包管理器誕生。它確立了包管理器工作的核心原則。
npm 的釋出誕生了一場革命,在此之前,專案依賴項都是手動下載和管理的。npm 引入了檔案和後設資料欄位,將依賴項列表儲存在 package.json 檔案中,並且將下載的檔案儲存到 node_modules 資料夾中。
後來因為 npm 的缺陷或者舊版本的不足,又出現了一個個替代 npm 來進行包管理的輪子,例如:yarn,yarn2,pnpm等。

1.1 NPM

NPM 是 Node.js 自帶的包管理工具,也是最常用的包管理工具之一。它可以方便地安裝、升級、解除安裝依賴包,還可以釋出自己的包到 NPM 倉庫。

1.1.1 npm v1 & v2

此時期主要是採用簡單的遞迴依賴方法,最後形成高度巢狀的依賴樹。這種模式雖然模組依賴關係比較清晰,但是造成的問題更大。

  • 重複依賴巢狀地獄,空間資源浪費:大量重複的包被安裝,檔案體積超級大
  • 安裝速度過慢檔案路徑過長:尤其在 window 系統下,路徑過長會導致爆錯,最多260多個字元。
  • 模組例項不能共享:雖然安裝的是兩個相同並且版本也相同的依賴包,但在兩個不同包引入的不是同一個模組例項,因此無法共享內部變數和生命週期,導致一些不可預知的 bug。

專案依賴了A@1.0和 B@1.0,而 A@1.0 和 B@1.0依賴了不同版本的 C@1.0 和 C@2.0,node_modules 結構如下:

├── A@1.0
│   └── node_modules
│       └── C@1.0
└── B@1.0
|    └── node_modules
|       └── C@2.0
└── D@1.0
    └── node_modules
        └── C@1.0

在我們真實使用過程中,隨著依賴的增多,重複冗餘的包會越來越多,最終,node_modules會大量的佔用磁碟。而且依賴巢狀的深度也會十分可怕,這個就是我們常說的依賴地獄(Dependency Hell)。

1.1.2 npm v3

npm v3 版本作了較大的更新,開始採取扁平化的依賴結構。為了將巢狀的依賴儘量打平,避免過深的依賴樹和包冗餘。
npm v3 將子依賴「提升」,採用扁平的 node_modules 結構,子依賴會盡量平鋪安裝在主依賴項所在的目錄中。我們繼續以上面的案例為例:node_modules:

├── A@1.0
└── B@1.0
|    └── node_modules
|        └── C@2.0
└── C@1.0
└── D@1.0

可以看到 v3 的版本中, A@1.0 和 D@1.0 的子依賴的 C@1.0 不再放在各自的 node_modules 下了,而是與 A、D 同層級。而 B@1.0 依賴的 C@2.0 因為版本號原因還是巢狀在 B@1.0 的node_modules 下。
優勢:這樣的依賴結構可以很好的解決重複依賴的依賴地獄問題,層級也不會太深。
但也形成了新的問題:

  • 扁平化依賴演算法耗時長:npm@3 wants to be faster
  • 幽靈依賴 問題:在 package.json 中未定義的依賴,但專案中依然可以正確地被引用到。

    • 比如上方的示例其實我們專案只安裝了 A@1.0 和 B@1.0,C@1.0其實是A@1.0的依賴,由於 C@1.0 在安裝時被提升到了和 A 1.0同樣的層級,所以在專案中引用 C@1.0 還是能正常工作的。
    • 幽靈依賴是由依賴的宣告丟失造成的,如果某天某個版本的 A、D 依賴不再依賴 C@1.0 或者 C@1.0的版本發生了變化,那麼就會造成依賴缺失或相容性問題。
    // package.json dependencies
    {
    "dependencies": {
      "A": "^1.0",
      "B": "^1.0"
    }
  • 不確定性:同樣的 package.json 檔案,install 依賴後可能不會得到同樣的 node_modules 目錄結構。

    • 還是之前的例子,A@1.0 依賴 C@1.0,B@1.0依賴 C@2.0,依賴安裝後究竟應該提升 C 的 1.0 還是 2.0 ?這取決於使用者的安裝順序。
    • 如果有 package.json 變更,本地需要刪除 node_modules 重新 install,否則可能會導致生產環境與開發環境 node_modules 結構不同,程式碼無法正常執行。
  • 依賴分身:假設繼續再安裝依賴 C@1.0 的 D 模組和依賴 C@2.0 的 E 模組,此時:A 和 D 依賴 C@1.0,B 和 E 依賴 C@2.0。可以看到 C@2.0 會被安裝兩次,實際上無論提升 C@1.0 還是 C@2.0,都會存在重複版本的 C 被安裝,這兩個重複安裝的 C 就叫 依賴分身。以下是提升 C@1.0 的 node_modules 結構:node_modules,這會帶來一些問題:

      1. 破壞單例模式:假如模組B、E中引入了模組 C2.0 中匯出的一個單例物件,但其實引用的不是同一個 C2.0,即使程式碼裡看起來載入的是同一模組的同一版本,但實際解析載入的是不同的module,引入的也是不同的物件。如果同時對該物件進行快取或副作用操作,就會產生問題。
      1. types衝突:雖然各個 package 的程式碼不會相互汙染,但是他們的 types 仍然可以相互影響,因此版本重複可能會導致全域性的 types 命名衝突。
    ├── A@1.0
    ├── B@1.0
    │   └── node_modules
    │       └── C@2.0
    ├── C@1.0
    ├── D@1.0
    └── E@1.0
      └── node_modules
          └── C@2.0

    1.1.3 npm v5

    為了解決上面出現的扁平化依賴演算法耗時長問題,npm 引入 package-lock.json 機制,package-lock.json 的作用是鎖定專案的依賴結構,保證依賴的穩定性。
    當專案有package.json檔案並首次執行npm install安裝後,會自動生成一個package-lock.json檔案,該檔案裡面記錄了package.json依賴的模組,以及模組的子依賴。並且給每個依賴標明瞭版本、獲取地址和驗證模組完整性雜湊值。透過package-lock.json,保障了依賴包安裝的確定性與相容性,使得每次安裝都會出現相同的結果。
    注:其實在 package-lock.json 機制出現之前,可以透過 npm-shrinkwrap 實現鎖定依賴結構,但是 npm-shrinkwrap 預設關閉,需要主動執行。

1.2 Yarn

1.2.1 yarn

2016 年,yarn 釋出 0.x 版本,隨後迭代正式版本 1.x,yarn 也採用扁平化 node_modules 結構
它的出現是為了解決 npm v3 幾個最為迫在眉睫的問題:依賴安裝速度慢,不確定性
yarn的一些特性是走在npm的前邊的。yarn 出現時,此時 npm 處於 v3 時期,其實當時 yarn 解決的問題基本就是 npm v5 解決的問題,包括使用 yarn.lock 等機制,鎖定版本依賴,實現併發網路請求,最大化網路資源利用率。其次還有利用快取機制,實現了離線模式。
與 npm v5之後推出的package-lock.json不同,yarn並沒有採用JSON格式的檔案,而是使用了自定義的格式,名字就叫做yarn.lock,與前者不同,後者的lockfile目錄結構並不能複製出完完全全一樣的node_modules拓撲結構,他只是把依賴到的所有庫 flat 成根目錄級別,這樣更方便做diff
安裝速度

  • 並行:在 npm 中安裝依賴時,安裝任務是序列的,會按包順序逐個執行安裝,這意味著它會等待一個包完全安裝,然後再繼續下一個。為了加快包安裝速度,yarn 採用了並行操作,在效能上有顯著的提高。
  • 離線快取:像npm一樣,yarn使用本地快取。與npm不一樣的是,yarn的快取機制是將每個包快取在磁碟上,在下一次安裝這個包時,無需網際網路連結就能安裝本地快取的依賴項,它提供了離線模式。這個功能在2012年的npm專案中就被提出來過,但一直沒有實現。

lockfile
yarn 更大的貢獻是發明了 yarn.lock。在依賴安裝時,會根據 package.josn 生成一份 yarn.lock 檔案。lockfile 裡記錄了依賴,以及依賴的子依賴,依賴的版本,獲取地址與驗證模組完整性的 hash。即使是不同的安裝順序,相同的依賴關係在任何的環境和容器中,都能得到穩定的 node_modules 目錄結構,保證了依賴安裝的確定性。所以 yarn 在出現時被定義為快速、安全、可靠的依賴管理。
而 npm 在一年後的 v5 才釋出了 package-lock.json。其實後面npm v5上能看到 yarn 的機制的影子,上面的機制目前 npm 基本也都實現了,就目前而言 npm 和 yarn 其實並沒有差異很大,具體使用 npm 還是 yarn 可以看個人需求。
弊端
yarn 依然和 npm 一樣是扁平化的 node_modules 結構,並沒有解決幽靈依賴和依賴分身問題。

1.2.2 yarn v2

(yarn berry)在 pnpm 之後, yarn 感受到了對手的挑戰,於是在 2020 年, yarn 2誕生了yarn 2(也叫 yarn berry,yarn 1 也叫 yarn classic)。它是對 yarn 的一次重大升級,其中一項重要更新就是 Plug’n’Play(Plug'n'Play = Plug and Play = PnP,即插即用)。儘管yarn1 看似並沒有對 node_modules作出太大改動,但是他們的團隊並不是沒有意識到 node_modules 的缺憾,他們做出了Plug’n’Play的嘗試。npm 與 yarn 的依賴安裝與依賴解析都涉及大量的檔案 I/O,效率不高。開發 Plug’n’Play 最直接的原因就是依賴引用慢,依賴安裝慢。
首先 node_modules 本身的侷限性在於解析、安裝依賴時產生的大量 IO 操作

  • 解析:當require 某個第三方檔案時,首先在當前目錄尋找 node_modules,找不到再去父級,找到之後,再去這個 node_modules 的子目錄去尋找,直到找到該檔案。因為node不認識包,只認識檔案,而 node_moduls 的設計也就註定了他不允許包管理工具正確的刪除重複的包資料。
  • 安裝:解析出某個具體的版本號,下載 tar 包到離線映象,從映象解壓到本地快取;從快取複製到node_modules,即使是 pnpm 的 hard link,也只是最佳化了最後一步。

因此,berry 做出了修改,與其讓 node 去查詢軟體包,不如直接簡明扼要的告訴 node 應該在哪裡找到這個包。Plug’n’Play 特性應運而生,他其實是省略了node_modules 的複製,轉而生成了一個 .pnp.js 的檔案去記錄包的版本,以及對映到的磁碟位置,即把每個包看作整體,壓縮成一個 zip;一個 .yarn 資料夾,裡面又有 cach e和 unplugged 目錄,前者存放壓縮過的依賴包,後者可以透過unplugin 指令解壓某個想要手動修改的包。
berry一定程度解決了一些問題

  • 之前介紹的 npm 存在的兩個問題,berry 因為不會生成 node_modules 目錄,因此不存在幽靈依賴的問題,同時他採用的 .pnp.js 的靜態對映而不是 copy 的方式也避免了重複安裝依賴的問題。
  • 基於 .pnp.js 和 zip loading 實現的零安裝,即將.pnp.js及.yarn資料夾全部上傳至gitlab,在有些情況下是可行的,但是這裡使用 create-react-app 進行實測,yarn 為144Mb,berry為62Mb,只是正常的壓縮體積的最佳化;隨後拿React,Vue等包做了下實驗,也基本都是這個比例(7.9Mb VS 5.1Mb)(17Mb VS 8.5Mb)。
  • 最後一點說一下一些新的特性,如外掛機制,方便我們在對berry的核心程式碼並不熟悉的情況下開發基於 berry 的擴充套件功能,官方實現的官方實現的 typescript 外掛,在 yarn add 時自動新增@types等。

當然也存在一些問題,最明顯的就是首次安裝依賴的時間並沒有感覺到縮短,其次還有上面所說的 .yarn/cache 到底要不要放到遠端倉庫中也是有待商榷的事。
berry 的改變

  1. 拋棄 node_modules
    無論是 npm 還是 yarn,都具備快取的功能,大多數情況下安裝依賴時,其實是將快取中的相關包複製到專案目錄中 node_modules 裡。而 yarn PnP 則不會進行複製這一步,而是在專案裡維護一張靜態對映表 pnp.cjs。pnp.cjs 會記錄依賴在快取中的具體位置,所有依賴都存在全域性快取中。同時自建了一個解析器,在依賴引用時,幫助 node 從全域性快取目錄中發現依賴,而不是查 node_modules。這樣就避免了大量的 I/O 操作同時專案目錄也不會有 node_modules 目錄生成,同版本的依賴在全域性也只會有一份,依賴的安裝速度和解析速度都有較大提升。
    注:pnpm 在 2020 年底的 v5.9 也支援了 PnP
  2. 脫離 node 生態
    pnp 比較明顯的缺點是脫離了 node 生態。因為使用 PnP 不會再有 node_modules 了,但是 Webpack,Babel 等各種前端工具都依賴 node_modules。雖然很多工具比如 pnp-webpack-plugin 已經在解決了,但難免會有相容性風險。PnP 自建了依賴解析器,所有的依賴引用都必須由解析器執行,因此只能透過 yarn 命令來執行 node 指令碼。

    1.3 pnpm

    pnpm - performant npm,在 2017 年正式釋出,定義為快速的,節省磁碟空間的包管理工具,開創了一套新的依賴管理機制,成為了包管理的後起之秀。
    與依賴提升和扁平化的 node_modules 不同,pnpm 引入了另一套依賴管理策略:內容定址儲存。該策略會將包安裝在系統的全域性 store 中,依賴的每個版本只會在系統中安裝一次。
    內容定址儲存pnpm 內部使用基於內容定址的檔案系統來儲存磁碟上所有的檔案,這樣可以做到不會出現重複安裝,在專案中需要使用到依賴的時候,pnpm 只會安裝一次,之後再次使用都會直接硬連結指向該依賴,極大節省磁碟空間,並且加快安裝速度。

    注:硬連結是多個檔名指向同一個檔案的實際內容,而軟連結(符號連結)是一個獨立的檔案,指向另一個檔案或目錄的路徑

在引用專案 node_modules 的依賴時,會透過硬連結與符號連結在全域性 store 中找到這個檔案。為了實現此過程,node_modules 下會多出 .pnpm 目錄,而且是非扁平化結構:

  • 硬連結 Hard link:硬連結可以理解為原始檔的副本,專案裡安裝的其實是副本,它使得使用者可以透過路徑引用查詢到全域性 store 中的原始檔,而且這個副本根本不佔任何空間。同時,pnpm 會在全域性 store 裡儲存硬連結,不同的專案可以從全域性 store 尋找到同一個依賴,大大地節省了磁碟空間。
  • 符號連結 Symbolic link:也叫軟連線,可以理解為快捷方式,pnpm 可以透過它找到對應磁碟目錄下的依賴地址。還是使用上面 A,B,C 模組的示例,使用 pnpm 安裝依賴後 node_modules 結構如下:

    node_modules
    ├── .pnpm
    │   ├── A@1.0
    │   │   └── node_modules
    │   │       ├── A => <store>/A@1.0
    │   │       └── B => ../../B@1.0
    │   ├── B@1.0
    │   │   └── node_modules
    │   │       └── B => <store>/B@1.0
    │   ├── B@2.0
    │   │   └── node_modules
    │   │       └── B => <store>/B@2.0
    │   └── C@1.0
    │       └── node_modules
    │           ├── C => <store>/C@1.0
    │           └── B => ../../B@2.0
    │
    ├── A => .pnpm/A@1.0.0/node_modules/A
    └── C => .pnpm/C@1.0.0/node_modules/C

    <store>/xxx 開頭的路徑是硬連結,指向全域性 store 中安裝的依賴。
    其餘的是符號連結,指向依賴的快捷方式。

    未來可期

    這套全新的機制設計地十分巧妙,不僅相容 node 的依賴解析,同時也解決了:

  • 幽靈依賴問題:只有直接依賴會平鋪在 node_modules 下,子依賴不會被提升,不會產生幽靈依賴。
  • 依賴分身問題:相同的依賴只會在全域性 store 中安裝一次。專案中的都是原始檔的副本,幾乎不佔用任何空間,沒有了依賴分身。同時,由於連結的優勢,pnpm 的安裝速度在大多數場景都比 npm 和 yarn 快 2 倍,節省的磁碟空間也更多。

但是,其實這種模式也存在一些弊端:

  • 由於 pnpm 建立的 node_modules 依賴軟連結,因此在不支援軟連結的環境中,無法使用 pnpm,比如 Electron 應用。
  • 因為依賴原始檔是安裝在 store 中,除錯依賴或 patch-package 給依賴打補丁也不太方便,可能會影響其他專案。
    擴充套件
    也許有人說 yarn 預設也是扁平化安裝方式,但是 yarn 有獨特的 PnP 安裝方式,可以直接去掉 node_modules,將依賴包內容寫在磁碟,節省了 node 檔案 I/O 的開銷,這樣也能提升安裝速度,但是 yarn PnP 和 pnpm 機制是不同的,且總體來說安裝速度 pnpm 是要快於 yarn PnP 的,詳情請看下面官方文件。最後就是 pnpm 是預設支援 monorepo 多專案管理的,在日漸複雜的前端多專案開發中尤其適用,也就說我們不再需要 lerna 來管理多包專案,可以使用 pnpm + Turborepo 作為我們的專案管理環境配置工作空間官方文件:工作空間(Workspace) | pnpm
    圖片

還有就是 pnpm 還能管理 nodejs 版本,可以直接替代 nvm,命令如下所示

# 安裝 LTS 版本
pnpm env use --global lts
# 安裝指定版本
pnpm env use --global 16

1.4 總結

pnpm 起初看起來像 npm,因為它們的 CLI 用法相似,但管理依賴項卻大不相同;pnpm 的方法帶來更好的效能和最佳的磁碟空間效率。Yarn Classic 仍然很受歡迎,但它被認為是遺留軟體,並且在不久的將來可能會放棄支援。Yarn Berry PnP 是新貴,但尚未看到它徹底改變包管理器領域的潛力。
目前還沒有完美的依賴管理方案,可以看到在依賴管理的發展過程中,出現了:

  • 不同的 node_modules 結構,有巢狀,扁平,甚至沒有 node_modules,不同的結構也伴隨著相容與安全問題。
  • 不同的依賴儲存方式來節約磁碟空間,提升安裝速度。每種管理器都伴隨新的工具和命令,不同程度的可配置性和擴充套件性,影響開發者體驗。
  • 這些包管理器也對 monorepo 有不同程度的支援,會直接影響專案的可維護性和速度。

庫與開發者能夠在這樣最佳化與創新的發展過程中互相學習,站在巨人的肩膀上繼續前進,不斷推動前端工程領域的發展。
多年來,許多使用者詢問誰使用哪些包管理器,總體而言,人們似乎對 Yarn Berry PnP 的成熟度和採用特別感興趣。但是國內我們能看到,pnpm似乎更受歡迎。

二、時間線梳理

請注意,以上只是列舉了一些比較重要或者具有改革意義的主要版本,每個包管理器的釋出策略可能會因實際情況而有所不同。此外,還有其它版本以及每個主要版本下可能還有許多次要版本和修訂版本。我試圖嚴格的按釋出順序來完整展示幾大包管理工具的歷史,但是失敗了,因為每個管理器對於包的版本定義以及小版本迭代,還有對釋出測試版本還是正式版本為準定義不同,資訊比較混亂,放棄了,也沒太大意義,因為上面列舉的是我們瞭解比較代表性的版本。下面十一張網圖:
圖片
下面是chatGPT給的一種可能的排序方式,但是它也提示可能會因實際釋出策略而有所不同,如果您需要確切的版本釋出日期,請參閱官方文件、儲存庫或相應的釋出歷史記錄,以獲取最準確和最新的資訊。
幾大包管理工具更多版本大體的釋出順序如下:
npm 1.x(2010年)
Yarn 0.x(2016年)
pnpm 1.x(2016年)
npm 2.x(2014年)
npm 3.x(2015年)
Yarn 1.x(2017年)
npm 4.x(2016年)
npm 5.x(2017年)
pnpm 2.x(2018年)
npm 6.x(2018年)
Yarn 2.x(2020年)
npm 7.x(2020年)
pnpm 3.x(2020年)

...我們其實可以看到版本已經迭代了很多,但是以上列舉的是比較能代表包管理工具從誕生,到改進,互相學習又改革的大體流程。

三、pnpm遷移

遷移過程中主要有如下問題:因為使用 npm 或 yarn 安裝依賴項時,所有包都被提升到模組目錄的根目錄。因此,原始碼可以訪問未作為依賴項新增到專案的依賴項。但是預設情況下,pnpm 使用連結僅將專案的直接依賴項新增到模組目錄的根目錄中。
這意味著如果 package.json 沒有引用的依賴,那麼它將無法解析。這是遷移中的最大障礙。可以使用 auto-install-peers設定自動執行此操作(預設情況下是false)。
對於多個使用 npm 安裝依賴的專案,單獨刪除依賴包很耗時間,我們可以使用 npkill ,該工具可以列出系統中的任何 node_modules 目錄以及它們佔用的空間。然後可以選擇要刪除的依賴以釋放空間
圖片

遷移流程

首先全域性安裝包npm i -g pnpm
遷移步驟如下

  1. 首先使用 npkill 刪除 node_modules 依賴包
  2. 專案根目錄建立 .npmrc,填寫如下內容 auto-install-peers=true
  3. 匯入依賴鎖定檔案(pnpm-lock.yaml)保證根目錄有如下依賴鎖定檔案(npm-shrinkwrap.json,package-lock.json,yarn.lock)然後執行如下命令 pnpm import pnpm-lock.yaml
  4. 最後執行 pnpm i 安裝依賴

問題
生成依賴檔案警告官方 issue 解釋: Unmet peer dependencies and The command -- pnpm/pnpm (github.com) 生成 pnpm-lock.yaml 檔案時出現如下警告 

WARN  Issues with peer dependencies found
.
└─┬ vuepress 1.9.9
  └─┬ @vuepress/core 1.9.9
    └─┬ vue-loader 15.10.1
      └─┬ @vue/component-compiler-utils 3.3.0
        └─┬ consolidate 0.15.1
          ├── ✕ unmet peer react-dom@^16.13.1: found 15.7.0
          └── ✕ unmet peer react@^16.13.1: found 15.7.0

這是因為在 npm 3 中,不會再強制安裝 peerDependencies (對等依賴)中所指定的包,而是透過警告的方式來提示我們。pnpm 會在全域性快取已經下載過的依賴包,如果全域性快取的依賴版本與專案 package.json 中指定的版本不一致,就會出現這種 hint 警告
我們可以在專案的 package.json 中配置 peerDependencyRules 忽略對應的警告提示

{
  "pnpm": {
    "peerDependencyRules": {
      "ignoreMissing": [
        "react"
      ]
    }
  }
}

或者說直接在 .npmrc 配置檔案中直接關閉嚴格的對等依賴模式,可以新增 strict-peer-dependencies=false 到配置檔案中,或者執行如下命令

npm config set strict-peer-dependencies=false

然後也可能會出現警告 deprecated subdependencies found,暫時可以忽略
幽靈依賴問題
在最後安裝依賴的時候可能會出現幽靈依賴問題,幽靈依賴就是沒有在 package.json 中,但是專案中,或者引用的包中使用到的依賴。
舉個例子,比如我們現在使用 npm 安裝了 v-viewer 依賴,同時 viewerjs 是 v-viewer 的依賴項,由於扁平化依賴機制,我們可以在 node_modules/v-viewer/package.json 中看到宣告的 viewerjs 依賴,即使專案根目錄下的 package.json 沒有宣告 viewerjs 依賴,我們仍舊可以使用,這就是幽靈依賴。
而現在我們切換為 pnpm 後,在預設情況下不允許訪問未宣告的依賴,有以下兩種解決方案

  1. 自行安裝未宣告依賴項

    幽靈依賴自動掃描工具:@sugarat/ghost - npm (npmjs.com)
    pnpm i -S viewerjs

    或者說某些版本 pnpm 會自動爆出幽靈依賴錯誤 missing peer ...,也可以直接不使用上面的掃描工具,直接自行安裝後面的 ...依賴

  2. 找到 .npmrc 檔案,在其中配置 public-hoist-pattern 或者 shamefully-hoist 欄位,將依賴提升到根 node_modules 目錄下解決,也就是所謂的依賴提升依賴提升

四、參考文章

  • pnpm、npm、yarn 包管理工具『優劣對比』及『環境遷移』 - 知乎 (zhihu.com)
  • 深入淺出 npm & yarn & pnpm 包管理機制-CSDN部落格
  • yarn yarn2 and pnpm的一些總結 | 碼農家園 (codenong.com)
  • 包管理工具之從NPM到PNPM

相關文章