聊聊依賴管理

前端開發者們每天都在接觸 xxx install,包管理器是必不可少的工具,我們在項目開發的過程中會引用到各種不同的庫,各種庫又依賴了其他不同的庫,這些依賴應該如何進行管理?今天這篇文章主要聊的就是依賴管理。

npm

npm 可以說是最早的依賴安裝 cli,我們先來看一下 npm 是怎麼樣安裝依賴的吧~

  1. 發出 npm install 命令;

  2. npm 向 registry 查詢模塊壓縮包的網址;

  3. 下載壓縮包,存放在 ~/.npm 目錄;

  4. 將壓縮包解壓到當前項目的 node_modules 目錄。

針對 npm2npm3 還是有區別的。

npm2

嵌套地獄

npm2 安裝依賴的時候比較簡單直接,直接按照包依賴的樹形結構下載填充本地目錄結構,也就是嵌套的 node_modules 結構,直接依賴會平鋪在 node_modules 下,子依賴嵌套在直接依賴的 node_modules 中。

比如項目依賴了 A 和 C,而 A 和 C 依賴了相同版本的 B@1.0,而且 C 還依賴了 D@1.0.0,node_modules 結構如下:

node_modules
├── A@1.0.0
│   └── node_modules
│       └── B@1.0.0
└── C@1.0.0
    └── node_modules
        └── B@1.0.0
        └── D@1.0.0

可以看到同版本的 B 分別被 A 和 C 安裝了兩次。

如果依賴的層級越多,且依賴包數量越多,久而久之,就會形成嵌套地獄:

npm3

扁平化嵌套、不確定性、依賴分身、幽靈依賴

扁平化嵌套

針對 npm2 存在的問題,npm3 提出新的解決方案,將依賴進行展平,也就是扁平化。

npm v3 將子依賴「提升」(hoist),採用扁平的 node_modules 結構,子依賴會盡量平鋪安裝在主依賴項所在的目錄中。

舉個例子,項目依賴了 A 和 C,而 A 依賴了 B@1.0.0,而且 C 還依賴了 B@2.0.0:

node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
     └── node_modules
          └── B@2.0.0

可以看到 A 的子依賴的 B@1.0 不再放在 A 的 node_modules 下了,而是與 A 同層級。而 C 依賴的 B@2.0 因爲版本號原因還是放到了 C 的 node_modules 下。

這樣不會造成大量包的重複安裝,依賴的層級也不會太深,解決了依賴地獄問題。

那爲什麼不把 B@2.0.0 提到 node_modules 而是 B@1.0.0 呢?而且將 B 直接提取到我們的 node_modules,是不是意味着我們可以在代碼直接引用 B 包?由此引出我們下面的問題:

不確定性

我們對於這種處理方式其實很容易有一個疑問,如果我同時引用了同一個包的多個不同版本,會幫我把哪個包提出來,同時我每次 npm i 之後提出來的包版本都是一樣的嗎?這也意味着同樣的 package.json 文件,install 依賴後可能不會得到同樣的 node_modules 目錄結構。

舉個例子:

install 後究竟應該提升 B 的 1.0 還是 2.0?

node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
     └── node_modules
         └── B@2.0.0
node_modules
├── A@1.0.0
│   └── node_modules
│       └── B@1.0.0
├── B@2.0.0
└── C@1.0.0

網上大部分說法是會根據 package.json 裏面的順序決定誰會被提出來,放在前面的包依賴的內容會被先提出來,看源碼後,npm 其實會調用一個叫做 localeCompare 的方法對依賴進行一次排序,實際上就是字典序在前面的 npm 包的底層依賴會被優先提出來。

幽靈依賴

什麼叫做幽靈依賴,也就是我的 package.json 沒有指明這個包,但實際項目使用了這個包,且這個包因爲扁平化嵌套導致了可以直接使用,也就是非法訪問,最經常碰到的就是 dayjs 這個包。

比如我的項目使用了 arco,但是 arco 的子依賴有 dayjs,那麼根據扁平化,dayjs 就會被放在 node_modules 的首層。

但是存在很大的問題,一旦 arco 去掉了這個子依賴,那麼我們的代碼就直接報錯了。

依賴分身

假設繼續再安裝依賴 B@1.0 的 D 模塊和依賴 @B2.0 的 E 模塊,此時:

以下是提升 B@1.0 的 node_modules 結構:

node_modules
├── A@1.0.0
├── B@1.0.0
├── D@1.0.0
├── C@1.0.0
│    └── node_modules
│         └── B@2.0.0
└── E@1.0.0
      └── node_modules
           └── B@2.0.0

可以看到 B@2.0 會被安裝兩次,實際上無論提升 B@1.0 還是 B@2.0,都會存在重複版本的 B 被安裝,這兩個重複安裝的 B 就叫 doppelgangers。

而且雖然看起來模塊 C 和 E 都依賴 B@2.0,但其實引用的不是同一個 B,假設 B 在導出之前做了一些緩存或者副作用,那麼使用者的項目就會因此而出錯。

npm install

npm3 以上的版本安裝依賴的步驟:

registry = 'https://bnpm.byted.org/'

sass_binary_site=https://bnpm.bytedance.net/mirrors/node-sass
electron_mirror=https://bnpm.bytedance.net/mirrors/electron/
puppeteer_download_host=https://bnpm.bytedance.net/mirrors

strict-peer-dependencies=false

不足之處

安裝速度慢,沒有解決扁平化帶來的算法複雜性、幽靈依賴等本質問題;

yarn

並行安裝

無論何時 npm 或者 yarn 需要安裝包,都會產出一系列的任務。使用 npm 時,這些任務按包順序執行,也就是隻有當一個包全部安裝完成後,纔會安裝下一個。

Yarn 通過並行操作最大限度地提高資源利用率,以至於再次下載的時候安裝時間比之前更快。npm5 之前是等上一個安裝完後再執行下一個,串行下載。

最重要的 - yarn.lock 文件

我們知道 npm 中的 package.json 安裝的包結構或者版本並不是一定一致的,因爲 package.json 的寫法是根據 語義版本控制 [1] ——發佈的補丁不應該包括任何實質性的修改。但是很不幸,這並不總是事實。npm 的策略可能會導致兩臺設備使用同樣的 package.json 文件,但安裝了不同版本的包,這可能導致故障。

舉個例子,無法保證一致性,拉取的 packages 可能版本不同,例如:5.0.3~5.0.3^5.0.3

同一個項目,安裝的版本不一致可能會出現 bug:對於 ~5.0.3,有可能 A 的電腦上是安裝了 5.0.4,B 的電腦是 5.0.5,而且這個包在 5.0.5 出現了 break change,那麼很不幸,項目很可能將會出錯。

針對這個問題,yarn 推出了 lock 文件。

爲了防止拉取到不同的版本,yarn 有一個鎖定文件 (lock file) 記被確切安裝上的模塊的版本號。每次只要新增了一個模塊,yarn 就會創建(或更新)yarn.lock 這個文件。這樣就保證了每一次拉取同一個項目依賴時,使用的都是一樣的模塊版本。

yarn.lock 只包含版本鎖定,並不確定依賴結構,需要結合 package.json 確定依賴結構。這個在 install 的過程會進行詳細解答。

yarn.lock 鎖文件把所有的依賴包都扁平化的展示了出來,對於同名包但是 semver 不兼容的作爲不同的字段放在了 yarn.lock 的同一級結構中。

Yarn install

執行 yarn install 後會經過五個階段:

pnpm

pnpm 代表 performant(高性能的)npm,如 pnpm 官方介紹,它是:速度快、節省磁盤空間的軟件包管理器,pnpm 本質上就是一個包管理器,它的兩個優勢在於:

根據目前官方提供的 benchmark[2] 數據可以看出在一些綜合場景下比 npm/yarn 快了大概兩倍:

那爲什麼 pnpm 能這麼快呢?

這與 pnpm 獨特的 link 機制有關。

那麼 pnpm 是怎麼做到如此大的提升的呢?是因爲計算機裏面一個叫做 Hard link[3] 的機制,hard link 使得用戶可以通過不同的路徑引用方式去找到某個文件。pnpm 會在全局的 store 目錄裏存儲項目 node_modules 文件的 hard links

hard links 可以理解爲源文件的副本,項目裏安裝的其實是副本,它使得用戶可以通過路徑引用查找到源文件。同時,pnpm 會在全局 store 裏存儲硬鏈接,不同的項目可以從全局 store 尋找到同一個依賴,大大地節省了磁盤空間。

hard links 指通過索引節點來進行連接。在 Linux 的文件系統中,保存在磁盤分區中的文件不管是什麼類型都給它分配一個編號,稱爲索引節點號 (Inode Index)。在 Linux 中,多個文件名指向同一索引節點是存在的。比如:A 是 B 的硬鏈接(A 和 B 都是文件名),則 A 的目錄項中的 inode 節點號與 B 的目錄項中的 inode 節點號相同,即一個 inode 節點對應兩個不同的文件名,兩個文件名指向同一個文件,A 和 B 對文件系統來說是完全平等的。刪除其中任何一個都不會影響另外一個的訪問。

舉個例子:

echo "111" > a
ln a b// linux中創建hard link

然後我們進行打印,可以看到結果是一樣的:

cat a --> 111
cat b --> 111

如果我們嘗試下刪除 a 文件,此時我們可以看到:

rm a
cat a --> No such file or directory
cat b --> 111

我們嘗試恢復 a:

echo "222" > a
cat a --> 222
cat b --> 111

文件刪除後再恢復內容,那麼 hardlink 的 link 關係將不再維持,後續所有變更不會同步到 hardlink 裏。

Symbolic link[4] 也叫軟連接,可以理解爲快捷方式,pnpm 可以通過它找到對應磁盤目錄下的依賴地址。軟鏈接文件只是其源文件的一個標記,當刪除了源文件後,鏈接文件不能獨立存在,雖然仍保留文件名,但卻不能查看軟鏈接文件的內容了。

舉個例子:

echo "111" > a
ln -s a c

此時 a、c 的結果爲:

cat a --> 111
cat c --> 111

我們看到 a、c 的結果保持同步,如果我們嘗試下刪除 a 文件,此時我們可以看到:

rm a
cat a --> No such file or directory
cat c --> No such file or directory

c 的內容一併被刪除了,我們再嘗試將 a 的內容復原:

echo "222" > a
cat a --> 222
cat c --> 222

刪除文件會影響 symlink 的內容,文件刪除後再恢復內容,但是仍然會和 symlink 保持同步,鏈接文件甚至可以鏈接不存在的文件,這就產生一般稱之爲” 斷鏈” 的現象。

執行 pnpm install,你會發現它打印了這樣一句話:

包是從全局 store 硬連接到虛擬 store 的,這裏的虛擬 store 就是 node_modules/.pnpm。

我們打開 node_modules 看一下:

確實不是扁平化的了,依賴了 solid-js,那 node_modules 下就只有 solid-js。

展開 .pnpm 看一下:

所有的依賴都在這裏鋪平了,都是從全局 store 硬連接過來的,然後包和包之間的依賴關係是通過軟鏈接組織的。

比如 .pnpm 下的 solid-js,這些都是軟鏈接:

也就是說,所有的依賴都是從全局 store 硬連接到了 node_modules/.pnpm 下,然後之間通過軟鏈接來相互依賴。

官方給了一張 pnpm 的實現原理圖,配合着看一下就明白了:

優勢

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

  1. 幽靈依賴問題:只有直接依賴會平鋪在 node_modules 下,子依賴不會被提升,不會產生幽靈依賴。

  2. 依賴分身問題:相同的依賴只會在全局 store 中安裝一次。項目中的都是源文件的副本,幾乎不佔用任何空間,沒有了依賴分身。

  3. 最大的優點是節省磁盤空間,一個包全局只保存一份,剩下的都是軟硬連接。

不足之處

  1. 全局 hardlink 也會導致一些問題,比如改了 link 的代碼,所有項目都受影響;對 postinstall 不友好;在 postinstall 裏修改了代碼,可能導致其他項目出問題。pnpm 默認就是 copy on write[5],但是 copy on write 這個配置對 mac 沒生效,其實是 node 沒支持導致的,參見 issue[6]。

  2. 由於 pnpm 創建的 node_modules 依賴軟鏈接,因此在不支持軟鏈接的環境中,無法使用 pnpm,比如 Electron 應用。

如何遷移

How to migrate from yarn / npm to pnpm?[7]

這個是前人給的遷移指南,但是我自己在遷移時並不是這樣做的。

本人遷移步驟如下:

  1. 刪除 node_modules;

  2. 直接執行 pnpm i;

  3. 執行 pnpm dev,看控制檯報錯,看哪個包缺失,再給補上到 package.json。

爲什麼會有 3 呢,因爲項目存在太多幽靈依賴了,所以我在想怎麼去掃描代碼的幽靈依賴。

幽靈依賴怎麼辦

初步思路

參考:https://www.npmjs.com/package/@sugarat/ghost。

但是該 npm 包對我們項目的掃描存在一些問題,比如會全量掃描,沒有去除一些不必要的文件和文件夾。對於項目設置的 alias 沒有配置,依然會誤報,而且掃描速度有限,不夠迅速,所以這次可能使用 swc 來進行實現,與 babel 相比,swc 至少有 10 倍以上的性能優勢。

個人目前有一個思路,暫時還未實現,總結爲以下 4 個步驟:

  1. 掃文件;

  2. 提取導入資源路徑;

  3. 提取包名;

  4. 剔除 package.json 中存在的。

設計思路:設計成一個 cli 工具,其中可以在項目根目錄自定義一個 config.js 文件。

module.exports ={
  ignoreFiles:[]// 填寫一些不希望被掃描的文件後綴
  ignoreDirs:[]// 填寫一些不希望被掃描的文件夾後綴
  alias:{
    // 將項目配置別名,對引用路徑進行映射的文件給註明,
    //比如import xxx from '@/abc';可能會造成誤報,將項目中設置的alias照搬就行了
  }
}

參考資料

[1]

語義版本控制: https://semver.org/

[2]

benchmark: https://pnpm.io/benchmarks

[3]

Hard link: https://en.wikipedia.org/wiki/Hard_link

[4]

Symbolic link: https://baike.baidu.com/item/%E8%BD%AF%E9%93%BE%E6%8E%A5/7177481

[5]

pnpm 默認就是 copy on write: https://pnpm.io/npmrc#package-import-method

[6]

參見 issue: https://github.com/pnpm/pnpm/issues/2761

[7]

How to migrate from yarn / npm to pnpm?: https://dev.to/andreychernykh/yarn-npm-to-pnpm-migration-guide-2n04

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