包管理工具的演進

前言

通過 Node.js 官方內置可以看出,目前前端領域最火的包管理工具主要是 npm( Node.js 直接內置)、yarn (corepack 內置) 以及 pnpm (corepack 內置)。

因此,本文主要是圍繞這三者來闡述包管理工具在迭代演進中提出的一些創新性特性以及其遇到困難是如何解決問題的。

npm

嵌套結構的依賴

npm 作爲前端領域最早的包管理工具,其早期版本(v1/v2)的工作模式和現在還是有很大的區別,其中最典型的就是 node_modules 的目錄管理。

拿以下依賴關係爲例:

Application -> A -> B
            -> C -> B

則 node_modules 目錄結構爲:

如上圖所示,對於 Application 的依賴,node_modules 的目錄結構是層層嵌套的。這樣的設計其實很符合直覺,依賴包的安裝和目錄結構都十分清晰且可預測,但是卻帶來了兩個比較嚴重問題:

  1. 依賴包重複安裝

這個問題在上圖中就可以很明顯的看出來,B 包被 A 依賴,同時也被 C 所依賴,因此 B 包就分別在 A 和 C 之下分別被安裝了一次。這都是沒有考慮包版本問題存在的情況下,依賴包都會被重複安裝。此種設計結構直接導致了 node_modules 體積過度膨脹,這也是臭名昭著的 node_modules hell 問題。

  1. 嵌套層級太深

同樣如上圖所示,應用依賴 A 和 C,而 A 的 node_modules 中又安裝了 B,如果 B 也依賴其他包,那麼 B 又會存在一個 node_modules 中來存放其他依賴包,如此層層遞進。

而 Windows 以及一些應用工具無法處理超過 260 個字符的文件和文件夾路徑,嵌套層級過深則會導致相應包的路徑名很容易就超出了能處理的範圍 ,因此會導致一系列問題。比如在想刪除相應的依賴包時,系統就無法處理了。(參見:Node's nested node_modules approach is basically incompatible with Windows[1])

除此之外,npm 還存在着一些問題被人詬病:

  1. SemVer 版本管理使得依賴的安裝不確定

  2. 緩存能力存在問題,且無離線模式

因此面對上述問題,特別是 node_modules 的嵌套結構問題,經過社區的反覆討論,npm v3 幾乎重寫了安裝程序,來試圖給開發者帶來更好的體驗。

扁平化

針對之前 node_moduels 嵌套結構所產生的問題, npm v3 提出的解法就是目錄扁平化。同樣拿之前的依賴結構爲例,npm v2 和 npm v3 的安裝目錄就完全不一致了。

hoist 機制: npm v3 在處理 A 的依賴 B 時,會將其提升到頂級依賴,然後再處理 C 包,然後發現 C 依賴的 B 包已經被安裝了,就不用再重複安裝了。

當然上面的舉例只是一個理想化的簡單 demo,現在考慮一下存在版本不同的情況。

依賴關係變爲:

Application -> A_v1 -> B_v1
            -> C_v1 -> B_v2

則扁平化的目錄結構爲:

如上圖所示,在依賴分析過程中,檢查到 A v1 依賴了 B v1,因此將 B v1 提升到了頂層。再檢查到 C v1 依賴了 B v2 時,發現頂層已經存在了 B v1,因此 B v2 無法提升到頂層,那麼只能接着放在 C v1 之下。可以看出,如果出現了同一依賴的不同版本的話,也無法做到完全的扁平化。但是這樣的設計在很大程度上確實解決了之前嵌套層級過深的問題。

新的問題

上面提到了 npm v3 通過扁平化設計 node_modules 來儘量規避同一版本依賴包重複安裝的問題和減少層級嵌套過深的問題。但是這個設計也不是十全十美的的,在解決舊有問題的同時也產生了新問題。

phantom dependencies

phantom dependencies 也稱幽靈依賴,指的是業務代碼中能夠引用到 package.json 指定依賴以外的包。拿上面提到過的依賴關係爲例:

package.json 中實際只寫明瞭 Application 依賴 A v1 和 C v1,但是由於 hoist 機制,B v1 被提升到了 node_modules 的第一層目錄中,那麼依照 node 依賴查找的方式,在我們的業務代碼中是可以直接引用 B v1 包的。雖然乍一看也沒有比較大的問題,但是 B v1 的版本管理是不在我們的感知之內的。也許某個時期使用了 B v1 的某個方法看起來沒有什麼問題,等到下次 A 有更新,相應的 A 引用的 B 版本也有了 breaking change 的更新,那麼我們在原本代碼中使用 B 的方法可能就出現報錯。

doppelgangers

將上面提到的依賴關係中再加入一個 D v1 包,則依賴關係變爲:

Application -> A_v1 -> B_v1
            -> C_v1 -> B_v2
            -> D_v1 -> B_v2

那麼 node_modules 目錄結構變爲:

結果會發現 B v2 又被安裝了一份在 D v1 下面。C v1 和 D v1 的依賴都是 B v2 版本,不存在任何差別,但是卻依然被重複安裝了兩遍,這個現象就叫做 doppelgangers,中文名被叫做 “雙胞胎陌生人” 問題。

被加重的依賴不冪等

先不考慮 doppelgangers 的現象,可以轉過來思考一下 B v2 明明有兩個卻沒有提升到頂層,仍然還是 B v1 在頂層,是什麼決定的這個關係呢。

安裝順序很重要!

正常來說,如果是 package.json 裏面寫好了依賴包,那麼 npm install 安裝的先後順序則由依賴包的字母順序進行排序,那如果是使用 npm install 對每個包進行單獨安裝,那就看手動的安裝順序了。

如果是先安裝的 C v1 ,然後再安裝的 A v1,那麼提升到頂層的就是 B v2 了。

如果情況再複雜一點,即 Application 又依賴了 E v1 的包:

Application -> A_v1 -> B_v1
            -> C_v1 -> B_v2
            -> D_v1 -> B_v2
            -> E_v1 -> B_v1

那麼目錄結構就會變成

之後的迭代過程中, A v1 包被手動升級成 A v2,其依賴項變成了 B v2,那麼本地的依賴樹結構就變成了:

因爲是直接升級的 A 版本,而不是刪掉 node_modules 進行重新安裝,而由於 E v1 存在,那麼 B v1 不會被從 node_modules 中刪掉,因此 A v2 的依賴包 B v2 仍然得不到提升,而是依然放在 A v2 之下。

但是,當這版代碼上傳到服務器上進行部署時,依賴進行重新安裝,由於 A v2 的依賴會被最先安裝,所以服務器上的依賴樹結構則爲如下:

因此可見,本來就因爲 SemVer 機制導致的依賴不冪等問題被進一步放大了。

鎖文件

上面提到三個典型問題,其中依賴不冪等的問題在 npm v3 中是提出了相應的解決方法的,那就是 npm-shrinkwrap.json 文件

在 npm v3 版本中,需要手動運行 npm shrinkwrap 纔會生成 npm-shrinkwrap.json 文件,之後每次改動版本依賴,都無法自動更新 npm-shrinkwrap.json 文件,仍然需要手動運行更新,因此這個特性對於開發者來說有一定的成本(開發者可能不知道該特性,或者沒有每次及時更新)。

之後受到 yarn.lock 的啓發,npm 在 v5 版本中設計了我們現在比較熟悉的 package-lock.json 文件,此時鎖文件就是自動生成和更新了。

package-lock.json 和 npm-shrinkwrap.json 的作用基本一致,只有一些細微差別:

之前 npm-shrinkwrap.json 允許在發佈包中進行版本控制,這樣使得子依賴包的版本不容易被共享,從而增加依賴包的體積。

在本地存在 package-lock.json 文件的情況下,npm 就不需要再去請求查看依賴包的具體信息和滿足要求的版本,而是直接通過 lock 文件中內容先去查找文件緩存。若發現沒有緩存則直接下載並進行完整性校驗,如若無誤,則安裝。

這邊簡單舉例一下完整性校驗的 demo,拿 mod-a 包爲例,其 integrity 值爲:

sha512-LHSY3BAvHk8CV3O2J2zraDq10+VI1QT1yCTildRW12JSWwFvsnzwLhdOdrJG2gaHHIya7N4GndK+ZFh1bTBjFw==

// 其格式爲:(加密函數)-(摘要)

那麼先下載包,包路徑爲 resolved 值:http://bnpm.byted.org/mod-a/-/mod-a-1.0.0.tgz

然後對包進行 SHA512 加密並進行 base64 編碼:

發現下載下來的依賴包計算出來的 integrity 值和本地的 integrity 值一致,則通過校驗。

yarn

yarn 0.x 版本正式發佈的時候,是在 2016 年,也就是 npm v4 還沒有發佈之前。Yarn 的誕生是由於當時 Facebook 的工程師不滿足 npm 所存在的一系列問題,從而開發出來的一個新的包管理工具。由於後發優勢,yarn 0.x 版本吸取了 npm v3 優點的同時,也作出了自己的創新:

yarn 的扁平化結構和 npm 基本類似,但是對重複安裝包計算上更加智能一些。

在上面鎖文件小節中提到在將 A v1 升級到 A v2 時, npm 安裝的時候會出現以下情況:

而 yarn 則會自動地將 B v1 放在 E v1 下面,而 B v2 則被提升到頂層。

對比於當時 npm 的 npm-shrinkwrap,yarn.lock 不需要手動生成,而是自動生成和更新。這一點在 npm v5 中被借鑑。

workspaces

之後隨着 monorepo 的項目管理方式被逐漸推廣,比如 babel 爲了管理多包問題開發了 lerna。yarn 也順勢在 v1 版本也增加了相應的功能:yarn workspaces 。

對比早期的 lerna,yarn workspaces 有一個最主要的優勢:

lerna bootstrap 是在每個子包內部進行依賴包的單獨安裝,而 yarn 對依賴包會盡量進行 hoist 處理,也就是在工程的最頂層安裝依賴包,這樣可以避免共同依賴被重複下載,同時也加快了安裝的速度。

此外 yarn workspaces 沒有封裝較多的上層 API,基本上還是依賴於整個 yarn 命令體系,因此使用成本較低。

當然,後期 lerna 也支持了 hoist 特性,甚至也支持了配合 workspaces 使用,但是最後還是被 babel 官方給放棄並停止維護了。

可以閱讀 Why babel remove lerna?[3] 來了解 babel 爲什麼放棄使用 lerna 來管理倉庫

我們可以看到,yarn 雖然在 npm 之上做出了一定的創新和相應的改進,但是在依賴包管理方式上還是借鑑的 npm 的扁平化 node_modules 方式,並沒有解決 npm 相應的痛點。也是基於此因素,使得社區部分開發者對其有點失望,pnpm 應運而生。

pnpm

pnpm 在依賴包管理方式上完全捨棄了 npm 的那一套,而是巧妙利用 symbol link 和 hard link 做出了自己的創新。

上面提到過 npm 在扁平化 node_modules 之後帶來了新的問題,而 pnpm 利用符號鏈接的方式重新設計了 node_modules 的結構來處理扁平化帶來的問題。

複用之前提到過的依賴關係:

Application -> A_v1 -> B_v1
            -> C_v1 -> B_v2
            -> D_v1 -> B_v2

那麼目錄結構則爲:

通過上圖可以看出,pnpm 的依賴樹結構和之前 npm 或者 yarn 的扁平化完全不同:

  1. 只有應用直接依賴的 A v1、C v1 以及 D v1 包在 node_modules 頂層中,而依賴的依賴,比如 B v1 和 B v2 都不在。那麼如果在項目中直接引用 B 就無法找到相應的依賴包,直接報錯。因此這點完全避免了 phantom dependencies 的發生。

  2. 頂層 node_module 中的 A v1、C v1 以及 D v1 包都是源文件依賴包的 symbol link,源文件依賴包還有其相應的子依賴包都放在了 .pnpm 目錄中。

  3. 雖然表面上看起來 C v1 的依賴 B v2 以及 D v1 的依賴 B v2 也被重複的安裝了兩次,但是這兩個 B v2 都是源文件 B v2 的 symbol link,因此這個設計也避免了 doppelgangers 的問題。

pnpm 在安裝的過程中,會在全局的 store 目錄中去存儲依賴包,然後在項目對應的 node_modules 中創建相應的硬鏈接。由於不能對目錄進行 hard link,因此不像 npm 一樣緩存的是壓縮包,pnpm 是將依賴包的每個文件都緩存到 store 中,然後創建相應文件的硬鏈。

我們可以簡單看一下 demo 實例,下圖爲依賴關係和相應的 lock 文件:

通過看 pnpm 源碼可以知道, pnpm 是利用 ssri 這個包來將 integrity 進行 base64 轉碼來決定緩存目錄的。

那麼去到 store 目錄下

而 mod-a 文件的轉碼爲 2c7498dc102f1e4f025773b6276ceb683ab5d3e548d504f5c824e295d456d762525b016fb27cf02e174e76b246da06871c8c9aecde069dd2be6458756d306317

因此進入 2c 目錄下面,就可以發現 mod-a 包的信息了

將 json 標準格式化一下

可以看出 mod-a 依賴包中只包含了 3 個文件,拿 README.md 文件的 integrity 再同樣處理一次:

拿到編碼找目錄

接着進入項目依賴的 mod-a 的真正源文件處

那麼現在就可以證明,項目依賴包的源文件就是 store 目錄下的 hard link 了。

因此如果要下載的依賴包已經在 store 中存在了,就不需要重新下載,而是直接創建相應的硬鏈接即可,很大程度的節省了下載時間。

此外,在本地往往會有多個工程在開發,而每個工程的依賴項大多時候都是大同小異的,因此統一在 store 中管理存儲並硬鏈出去的方式允許跨項目共享同一版本的依賴,從而也節省了大量的存儲空間。

有一點值得提出來思考一下,爲什麼 pnpm 既要用 symbol link,又要使用 hard link,爲什麼不能全部使用一種呢。

爲了弄清楚這個問題,同時加深對此模式的理解,我們需要明白 symbol link 和 hard link 對 node 尋包的影響。

demo 如下

接着嘗試運行

發現運行 symbol link 的文件會失敗,報找不到 lodash 包,而運行 hard link 的文件則正常。

接着在 source 中也創建一個 node_modules,然後再次運行

結果發現這次 symbol link 和 hard link 都運行正常,證明兩者都找到了相應的依賴包。

從這個 demo 可以看出,symbol link 的文件會回到源文件的目錄去尋找依賴包,而 hard link 的文件則會在文件本來的目錄下去尋找依賴包。

其實回過頭來發現,.pnpm 目錄的設計也正是利用 symbol link 的這一點去巧妙的與 node module resolution 機制結合才做到了規避 phantom dependencies 現象。而如果全部使用 symbol link 的話,那就會都去 store 中尋找子依賴了,這樣就很難做到區分同個包的不同版本。

缺陷

很難有完美的設計,pnpm 解決了 npm 扁平化依賴帶來的硬傷,但是同時也存在着一些小的問題:

  1. 兼容性問題,因爲整體設計上使用了 symbol link 和 hard link, 如果所處的環境對其支持存在問題 或者某些 npm 包中寫死了引用路徑,就會導致使用出錯。

  2. 因爲依賴包會在 store 中全局維護,那麼如果在開發中有調試 npm 的情況,修改 npm 包會導致所有工程引用的該包都發生修改。

yarn berry

上面提到 yarn 在正式發佈的時候,雖然在 npm 之上做了一定的改進,但是在依賴包管理上還是借鑑了 npm 扁平化模式,沒有解決依賴引用的核心問題。之後 pnpm 大膽創新的想法被提出,在設計思想層面,yarn 明顯就處於了落後位置。在內卷的環境之下,yarn pnp 模式很快就被提出。之後,yarn 放棄了 yarn v1 版本的迭代,將 yarn v1 定性爲 yarn classic,從而 yarn berry 誕生。

pnp 模式

yarn 認爲目前包管理工具出現的各種問題很大程度上來自於 node_modules 本身:無論怎麼樣利用緩存,或者使用什麼樣的思路以及目錄結構來設計 node_modules,只要你生成它,那麼就需要知道 node_modules 要包含的內容並且執行繁重的 I/O 操作。

而爲什麼之前包管理工具一定要生成 node_modules 的依賴包呢,原因在於 node module resolution 的機制就是如此,即 node 會一層一層的依照目錄層級順序去 node_modules 中去尋找相應的依賴。

但是在安裝依賴的過程中,包管理工具將會去獲取並梳理項目依賴樹的所有信息,那麼在已知了項目依賴信息之後,爲什麼還要依靠 node 再去尋找一次依賴包呢,這個就是 pnp 特性要解決的問題。

在 pnp 模式下,安裝項目依賴後根目錄下將不會出現 node_modules 文件夾了,相應代替的則是 .pnp.cjs 文件。

使用 yarn berry 安裝的項目依賴關係如下:

Application  -> mod-a.v1 -> mod-b.v1
             -> mod-c.v1 -> mod-b.v2
             -> mod-d.v1 -> mod-b.v1

其中 .yarn/cache 爲所有依賴包的 zip 文件

在 .pnp.cjs 文件中主要做了兩件事情:

可以寫一個 demo 來簡單模擬一下 .pnp.cjs 的作用:

依賴包文件

執行文件

如果正常執行 index.js,那麼一定會報錯找不到 math 模塊:

那麼在此情況下,加上一個 monkey patch:

再次運行

此時會發現在打了補丁之後,代碼就順利的找到了依賴文件了。當然 .pnp.cjs 做的事情要比 demo 複雜的多,比如各種路徑解析的 patch 以及兼容,再比如利用 zlib 來解壓依賴壓縮包。總之,pnp 模式自己解析模塊路徑的方式有着諸多優勢:

當然,相應的缺點也很明顯,即完全脫離了 node_modules resolution 機制,步子邁得太大,因此在兼容性上有一定的問題,這個也就是 yarn berry 在剛剛推出之後不怎麼受歡迎的原因。但是在後續的迭代中, yarn 在兼容性上做了很多的工作,現在主流的一些工具,比如 webpack、babel、esbuild 等都已支持 pnp 模式。

插件化

除了 pnp 模式以外, yarn berry 還提供了一種比較新穎的特性:插件擴展。從 yarn v1 到 yarn v2,yarn 將代碼架構改成了支持插件化擴展的模式。通過插件擴展,我們可以實現很多增強性功能,比如爲 yarn 添加新的命令、在生命週期鉤子上做一些定製化的事情等。

拿一個比較有意思的官方插件 @yarnpkg/plugin-typescript 舉例,通過在 afterWorkspaceDependencyAddition 生命週期鉤子裏面去查詢安裝的包是否存在有相應的 @types 的包,如果有並且沒有被安裝,那麼就會順便安裝一下。

實驗一下:

發現在安裝 lodash 的時候,@types/lodash 的包也同樣被安裝下載了。

Yarn 除了提供的官方插件之外,同樣也提供了 API 來鼓勵用戶來貢獻第三方插件,可以看出從靈活性,定製化方面,yarn 插件化的設計目前是走在其他包管理工具的前面。

最後

其實關於 npm、yarn 以及 pnpm 的迭代演進遠遠不只是做了上面提到的工作,比如:

......

通過以上發現,包管理工具在自我創新的同時都在互相學習對方的優點,尤其是 pnpm 和 yarn,爲 JavaScript 社區的發展注入了活力。而我們通過了解學習它們的演進歷程,可以加深對依賴包管理的理解,從而在工程開發中更好的選型以及解決相應的問題。

參考資料

[1]

Node's nested node_modules approach is basically incompatible with Windows: https://github.com/nodejs/node-v0.x-archive/issues/6960

[2]

SRI: https://developer.mozilla.org/zh-CN/docs/Web/Security/Subresource_Integrity

[3]

Why babel remove lerna: https://github.com/babel/babel/discussions/12622

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/beP1bxgbTT1Z91KS3svDvw