關於現代包管理器的深度思考——爲何如今我更推薦 pnpm 而不是 npm/yarn?

這篇文章給你們分享一個業內一款出色的包管理器——pnpm。目前 GitHub 已經有 star 9.8k,如今已經相對成熟且穩定了。它由 npm/yarn 衍生而來,但卻解決了 npm/yarn 內部潛在的 bug,而且極大了地優化了性能,擴展了使用場景。下面是本文的思惟導圖: javascript

1、什麼是 pnpm ?

pnpm 的官方文檔是這樣說的:html

Fast, disk space efficient package manager前端

所以,pnpm 本質上就是一個包管理器,這一點跟 npm/yarn 沒有區別,但它做爲殺手鐗的兩個優點在於:java

  • 包安裝速度極快;
  • 磁盤空間利用很是高效。

它的安裝也很是簡單。能夠有多簡單?node

npm i -g pnpm
複製代碼

2、特性概覽

1. 速度快

pnpm 安裝包的速度究竟有多快?先以 React 包爲例來對比一下:ios

能夠看到,做爲黃色部分的 pnpm,在絕多大數場景下,包安裝的速度都是明顯優於 npm/yarn,速度會比 npm/yarn 快 2-3 倍。git

對 yarn 比較熟悉的同窗可能會說,yarn 不是有 PnP 安裝模式嗎?直接去掉 node_modules,將依賴包內容寫在磁盤,節省了 node 文件 I/O 的開銷,這樣也能提高安裝速度。(具體原理見這篇文章github

接下來,咱們以這樣一個倉庫爲例,咱們來看一看 benchmark 數據,主要對比一下 pnpmyarn PnP:算法

從中能夠看到,整體而言,pnpm 的包安裝速度仍是明顯優於 yarn PnP的。express

2. 高效利用磁盤空間

pnpm 內部使用基於內容尋址的文件系統來存儲磁盤上全部的文件,這個文件系統出色的地方在於:

  • 不會重複安裝同一個包。用 npm/yarn 的時候,若是 100 個項目都依賴 lodash,那麼 lodash 極可能就被安裝了 100 次,磁盤中就有 100 個地方寫入了這部分代碼。但在使用 pnpm 只會安裝一次,磁盤中只有一個地方寫入,後面再次使用都會直接使用 hardlink(硬連接,不清楚的同窗詳見這篇文章)。

  • 即便一個包的不一樣版本,pnpm 也會極大程度地複用以前版本的代碼。舉個例子,好比 lodash 有 100 個文件,更新版本以後多了一個文件,那麼磁盤當中並不會從新寫入 101 個文件,而是保留原來的 100 個文件的 hardlink,僅僅寫入那一個新增的文件

3. 支持 monorepo

隨着前端工程的日益複雜,愈來愈多的項目開始使用 monorepo。以前對於多個項目的管理,咱們通常都是使用多個 git 倉庫,但 monorepo 的宗旨就是用一個 git 倉庫來管理多個子項目,全部的子項目都存放在根目錄的packages目錄下,那麼一個子項目就表明一個package。若是你以前沒接觸過 monorepo 的概念,建議仔細看看這篇文章以及開源的 monorepo 管理工具lerna,項目目錄結構能夠參考一下 babel 倉庫

pnpm 與 npm/yarn 另一個很大的不一樣就是支持了 monorepo,體如今各個子命令的功能上,好比在根目錄下 pnpm add A -r, 那麼全部的 package 中都會被添加 A 這個依賴,固然也支持 --filter字段來對 package 進行過濾。

4. 安全性高

以前在使用 npm/yarn 的時候,因爲 node_module 的扁平結構,若是 A 依賴 B, B 依賴 C,那麼 A 當中是能夠直接使用 C 的,但問題是 A 當中並無聲明 C 這個依賴。所以會出現這種非法訪問的狀況。但 pnpm 腦洞特別大,自創了一套依賴管理方式,很好地解決了這個問題,保證了安全性,具體怎麼體現安全、規避非法訪問依賴的風險的,後面再來詳細說說。

3、依賴管理

npm/yarn install 原理

主要分爲兩個部分, 首先,執行 npm/yarn install以後,包如何到達項目 node_modules 當中。其次,node_modules 內部如何管理依賴

執行命令後,首先會構建依賴樹,而後針對每一個節點下的包,會經歷下面四個步驟:

    1. 將依賴包的版本區間解析爲某個具體的版本號
    1. 下載對應版本依賴的 tar 包到本地離線鏡像
    1. 將依賴從離線鏡像解壓到本地緩存
    1. 將依賴從緩存拷貝到當前目錄的 node_modules 目錄

而後,對應的包就會到達項目的node_modules當中。

那麼,這些依賴在node_modules內部是什麼樣的目錄結構呢,換句話說,項目的依賴樹是什麼樣的呢?

npm1npm2 中呈現出的是嵌套結構,好比下面這樣:

node_modules
└─ foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ bar
         ├─ index.js
         └─ package.json
複製代碼

若是 bar 當中又有依賴,那麼又會繼續嵌套下去。試想一下這樣的設計存在什麼問題:

  1. 依賴層級太深,會致使文件路徑過長的問題,尤爲在 window 系統下。
  2. 大量重複的包被安裝,文件體積超級大。好比跟 foo 同級目錄下有一個baz,二者都依賴於同一個版本的lodash,那麼 lodash 會分別在二者的 node_modules 中被安裝,也就是重複安裝。
  3. 模塊實例不能共享。好比 React 有一些內部變量,在兩個不一樣包引入的 React 不是同一個模塊實例,所以沒法共享內部變量,致使一些不可預知的 bug。

接着,從 npm3 開始,包括 yarn,都着手來經過扁平化依賴的方式來解決這個問題。相信你們都有這樣的體驗,我明明就裝個 express,爲何 node_modules裏面多了這麼多東西?

沒錯,這就是扁平化依賴管理的結果。相比以前的嵌套結構,如今的目錄結構相似下面這樣:

node_modules
├─ foo
|  ├─ index.js
|  └─ package.json
└─ bar
   ├─ index.js
   └─ package.json
複製代碼

全部的依賴都被拍平到node_modules目錄下,再也不有很深層次的嵌套關係。這樣在安裝新的包時,根據 node require 機制,會不停往上級的node_modules當中去找,若是找到相同版本的包就不會從新安裝,解決了大量包重複安裝的問題,並且依賴層級也不會太深。

以前的問題是解決了,但仔細想一想這種扁平化的處理方式,它真的就是無懈可擊嗎?並非。它照樣存在諸多問題,梳理一下:

    1. 依賴結構的不肯定性
    1. 扁平化算法自己的複雜性很高,耗時較長。
    1. 項目中仍然能夠非法訪問沒有聲明過依賴的包

後面兩個都好理解,那第一點中的不肯定性是什麼意思?這裏來詳細解釋一下。

假如如今項目依賴兩個包 foo 和 bar,這兩個包的依賴又是這樣的:

那麼 npm/yarn install 的時候,經過扁平化處理以後,到底是這樣

仍是這樣?

答案是: 都有可能。取決於 foo 和 bar 在 package.json中的位置,若是 foo 聲明在前面,那麼就是前面的結構,不然是後面的結構。

這就是爲何會產生依賴結構的不肯定問題,也是 lock 文件誕生的緣由,不管是package-lock.json(npm 5.x纔出現)仍是yarn.lock,都是爲了保證 install 以後都產生肯定的node_modules結構。

儘管如此,npm/yarn 自己仍是存在扁平化算法複雜package 非法訪問的問題,影響性能和安全。

pnpm 依賴管理

pnpm 的做者Zoltan Kochan發現 yarn 並無打算去解決上述的這些問題,因而另起爐竈,寫了全新的包管理器,開創了一套新的依賴管理機制,如今就讓咱們去一探究竟。

仍是以安裝 express 爲例,咱們新建一個目錄,執行:

pnpm init -y
複製代碼

而後執行:

pnpm install express
複製代碼

咱們再去看看node_modules:

.pnpm
.modules.yaml
express
複製代碼

咱們直接就看到了express,但值得注意的是,這裏僅僅只是一個軟連接,不信你打開看看,裏面並無 node_modules 目錄,若是是真正的文件位置,那麼根據 node 的包加載機制,它是找不到依賴的。那麼它真正的位置在哪呢?

咱們繼續在 .pnpm 當中尋找:

▾ node_modules
  ▾ .pnpm
    ▸ accepts@1.3.7
    ▸ array-flatten@1.1.1
    ...
    ▾ express@4.17.1
      ▾ node_modules
        ▸ accepts
        ▸ array-flatten
        ▸ body-parser
        ▸ content-disposition
        ...
        ▸ etag
        ▾ express
          ▸ lib
            History.md
            index.js
            LICENSE
            package.json
            Readme.md
複製代碼

好傢伙!居然在 .pnpm/express@4.17.1/node_modules/express下面找到了!

隨便打開一個別的包:

好像也都是同樣的規律,都是<package-name>@version/node_modules/<package-name>這種目錄結構。而且 express 的依賴都在.pnpm/express@4.17.1/node_modules下面,這些依賴也全都是軟連接

再看看.pnpm.pnpm目錄下雖然呈現的是扁平的目錄結構,但仔細想一想,順着軟連接慢慢展開,其實就是嵌套的結構!

▾ node_modules
  ▾ .pnpm
    ▸ accepts@1.3.7
    ▸ array-flatten@1.1.1
    ...
    ▾ express@4.17.1
      ▾ node_modules
        ▸ accepts  -> ../accepts@1.3.7/node_modules/accepts
        ▸ array-flatten -> ../array-flatten@1.1.1/node_modules/array-flatten
        ...
        ▾ express
          ▸ lib
            History.md
            index.js
            LICENSE
            package.json
            Readme.md
複製代碼

包自己依賴放在同一個node_module下面,與原生 Node 徹底兼容,又能將 package 與相關的依賴很好地組織到一塊兒,設計十分精妙。

如今咱們回過頭來看,根目錄下的 node_modules 下面再也不是眼花繚亂的依賴,而是跟 package.json 聲明的依賴基本保持一致。即便 pnpm 內部會有一些包會設置依賴提高,會被提高到根目錄 node_modules 當中,但總體上,根目錄的node_modules比之前仍是清晰和規範了許多。

4、再談安全

不知道你發現沒有,pnpm 這種依賴管理的方式也很巧妙地規避了非法訪問依賴的問題,也就是隻要一個包未在 package.json 中聲明依賴,那麼在項目中是沒法訪問的。

但在 npm/yarn 當中是作不到的,那你可能會問了,若是 A 依賴 B, B 依賴 C,那麼 A 就算沒有聲明 C 的依賴,因爲有依賴提高的存在,C 被裝到了 A 的node_modules裏面,那我在 A 裏面用 C,跑起來沒有問題呀,我上線了以後,也能正常運行啊。不是挺安全的嗎?

還真不是。

第一,你要知道 B 的版本是可能隨時變化的,假如以前依賴的是C@1.0.1,如今發了新版,新版本的 B 依賴 C@2.0.1,那麼在項目 A 當中 npm/yarn install 以後,裝上的是 2.0.1 版本的 C,而 A 當中用的仍是 C 當中舊版的 API,可能就直接報錯了。

第二,若是 B 更新以後,可能不須要 C 了,那麼安裝依賴的時候,C 都不會裝到node_modules裏面,A 當中引用 C 的代碼直接報錯。

還有一種狀況,在 monorepo 項目中,若是 A 依賴 X,B 依賴 X,還有一個 C,它不依賴 X,但它代碼裏面用到了 X。因爲依賴提高的存在,npm/yarn 會把 X 放到根目錄的 node_modules 中,這樣 C 在本地是可以跑起來的,由於根據 node 的包加載機制,它可以加載到 monorepo 項目根目錄下的 node_modules 中的 X。但試想一下,一旦 C 單獨發包出去,用戶單獨安裝 C,那麼就找不到 X 了,執行到引用 X 的代碼時就直接報錯了。

這些,都是依賴提高潛在的 bug。若是是本身的業務代碼還好,試想一下若是是給不少開發者用的工具包,那危害就很是嚴重了。

npm 也有想過去解決這個問題,指定--global-style參數便可禁止變量提高,但這樣作至關於回到了當年嵌套依賴的時代,一晚上回到解放前,前面提到的嵌套依賴的缺點仍然暴露無遺。

npm/yarn 自己去解決依賴提高的問題貌似很難完成,不過社區針對這個問題也已經有特定的解決方案: dependency-check,地址: github.com/dependency-…

但不能否認的是,pnpm 作的更加完全,首創的一套依賴管理方式不只解決了依賴提高的安全問題,還大大優化了時間和空間上的性能。

5、平常使用

說了這麼多,估計你會以爲 pnpm 挺複雜的,是否是用起來成本很高呢?

剛好相反,pnpm 使用起來十分簡單,若是你以前有 npm/yarn 的使用經驗,甚至能夠無縫遷移到 pnpm 上來。不信咱們來舉幾個平常使用的例子。

pnpm install

跟 npm install 相似,安裝項目下全部的依賴。但對於 monorepo 項目,會安裝 workspace 下面全部 packages 的全部依賴。不過能夠經過 --filter 參數來指定 package,只對知足條件的 package 進行依賴安裝。

固然,也能夠這樣使用,來進行單個包的安裝:

// 安裝 axios
pnpm install axios
// 安裝 axios 並將 axios 添加至 devDependencies
pnpm install axios -D
// 安裝 axios 並將 axios 添加至 dependencies
pnpm install axios -S
複製代碼

固然,也能夠經過 --filter 來指定 package。

pnpm update

根據指定的範圍將包更新到最新版本,monorepo 項目中能夠經過 --filter 來指定 package。

pnpm uninstall

在 node_modules 和 package.json 中移除指定的依賴。monorepo 項目同上。舉例以下:

// 移除 axios
pnpm uninstall axios --filter package-a
複製代碼

pnpm link

將本地項目鏈接到另外一個項目。注意,使用的是硬連接,而不是軟連接。如:

pnpm link ../../axios
複製代碼

另外,對於咱們常常用到npm run/start/test/publish,這些直接換成 pnpm 也是同樣的,再也不贅述。更多的使用姿式可參考官方文檔: pnpm.js.org/en/

能夠看到,雖然 pnpm 內部作了很是多複雜的設計,但實際上對於用戶來講是無感知的,使用起來很是友好。而且,如今做者如今還一直在維護,目前 npm 上週下載量已經有 10w +,經歷了大規模用戶的考驗,穩定性也能有所保障。

所以,綜合來看,pnpm 是一個相比 npm/yarn 更優的方案,期待將來 pnpm 能有更多的落地。

參考資料:

[1] pnpm 官方文檔: pnpm.js.org/en/

[2] benchmark 倉庫: github.com/dependency-…

[3] Zoltan Kochan 《Why should we use pnpm?》:www.kochan.io/nodejs/why-…

[4] Zoltan Kochan 《pnpm's strictness helps to avoid silly bugs》: www.kochan.io/nodejs/pnpm…

[5] Conarli《npm install 原理分析》: cloud.tencent.com/developer/a…

[6] yarn 官方文檔: classic.yarnpkg.com/en/docs

[7] 《Yarn 的 Plug'n'Play 特性》: loveky.github.io/2019/02/11/…

[8] 《Guide to Monorepos for Front-end Code》: www.toptal.com/front-end/g…

相關文章
相關標籤/搜索