聊聊依賴管理
前端開發者們每天都在接觸 xxx install
,包管理器是必不可少的工具,我們在項目開發的過程中會引用到各種不同的庫,各種庫又依賴了其他不同的庫,這些依賴應該如何進行管理?今天這篇文章主要聊的就是依賴管理。
npm
npm 可以說是最早的依賴安裝 cli,我們先來看一下 npm 是怎麼樣安裝依賴的吧~
-
發出 npm install 命令;
-
npm 向 registry 查詢模塊壓縮包的網址;
-
下載壓縮包,存放在 ~/.npm 目錄;
-
將壓縮包解壓到當前項目的 node_modules 目錄。
針對 npm2
與 npm3
還是有區別的。
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 目錄結構。
舉個例子:
-
A@1.0.0:B@1.0.0
-
C@1.0.0:B@2.0.0
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 模塊,此時:
-
A 和 D 依賴 B@1.0
-
C 和 E 依賴 B@2.0
以下是提升 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 以上的版本安裝依賴的步驟:
- 檢查配置:讀取
npm config
和.npmrc
配置,比如配置鏡像源。
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
-
確定依賴版本,構建依賴樹:檢查是否存在
package-lock.json
。若存在,進行版本比對,處理方式和 npm 版本有關,根據最新 npm 版本處理規則,版本能兼容按照 package-lock 版本安裝,反之按照 package.json 版本安裝;若不存在,根據package.json
確定依賴包信息。 -
檢查緩存或下載:判斷是否存在緩存。若存在,將對應緩存解壓到 node_modules 下,生成
package-lock.json
;若不存在,則下載資源包,驗證包完整性並添加至緩存,之後解壓到 node_modules 下,生成package-lock.json
。
不足之處
安裝速度慢,沒有解決扁平化帶來的算法複雜性、幽靈依賴等本質問題;
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
。
-
5.0.3
表示安裝指定的 5.0.3 版本 -
~ 5.0.3
表示安裝 5.0.X 中最新的版本 -
^5.0.3
表示安裝 5.X.X 中最新的版本
同一個項目,安裝的版本不一致可能會出現 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
後會經過五個階段:
-
Validating package.json(檢查 package.json):檢查運行環境。
-
Resolving packages(解析包):整合依賴信息。
-
Fetching packages(獲取包):獲取依賴包到緩存中。
-
Linking dependencies(連接依賴):複製依賴到 node_modules。
-
Building fresh packages(構建安裝):執行 install 階段的 scripts。
-
檢查(checking):檢查系統運行環境,包括 OS、CPU、engines 等信息。
-
解析包(resolving packages):首先根據項目
package.json
中dependencies
、devDependencies
、optionalDependencies
字段形成首層依賴集合,之後對嵌套依賴逐級進行遞歸解析(將解析過和正在解析的包用一個 Set 數據結構來存儲,保證同一個版本範圍內的包不會被重複解析),結合 yarn.lock 和 Registry 獲取包的具體版本、下載地址、hash 值、子依賴等信息(過程中遵循依照 yarn.lock 優先原則)最終確定依賴版本信息、下載地址。
過程總結爲兩部分:
收集首層依賴,將 package.json 中的 dependencies、devDependencies、optionalDependencies 依賴列表和 workspaces 中的頂級 packages 列表以 「包名 @版本範圍」 的格式整合爲首層依賴集合,可以具象爲一個字符串數組;
遍歷所有依賴,收集依賴具體信息,從首層依賴集合出發,結合 yarn.lock 和 Registry 獲取包的具體版本、下載地址、hash 值、子依賴等信息。 -
獲取包(fetching packages):首先判斷緩存目錄中有沒有緩存資源,其次讀取文件系統,都不存在則從 Registry 進行下載。
-
鏈接包(linking dependencies):複製緩存至項目
node_modules
目錄。
首先解析peerDependencies
信息,之後基於扁平化原則(yarn 扁平化規則不同於 npm,使用頻率較大的版本會安裝到頂層目錄,這個過程稱爲 dedupe),從緩存複製依賴至當前項目node_modules
目錄。 -
構建包(building fresh package):依賴包存在二進制文件進行構建。
這個過程會執行install
相關鉤子,包括preinstall
、install
、postinstall
。
pnpm
pnpm
代表 performant(高性能的)npm
,如 pnpm
官方介紹,它是:速度快、節省磁盤空間的軟件包管理器
,pnpm 本質上就是一個包管理器,它的兩個優勢在於:
-
包安裝速度極快;
-
磁盤空間利用非常高效。
根據目前官方提供的 benchmark[2] 數據可以看出在一些綜合場景下比 npm/yarn 快了大概兩倍:
那爲什麼 pnpm 能這麼快呢?
這與 pnpm 獨特的 link 機制有關。
link 機制
Hard 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
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 的 link
執行 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 的依賴解析,同時也解決了以下問題:
-
幽靈依賴問題:只有直接依賴會平鋪在 node_modules 下,子依賴不會被提升,不會產生幽靈依賴。
-
依賴分身問題:相同的依賴只會在全局 store 中安裝一次。項目中的都是源文件的副本,幾乎不佔用任何空間,沒有了依賴分身。
-
最大的優點是節省磁盤空間,一個包全局只保存一份,剩下的都是軟硬連接。
不足之處
-
全局 hardlink 也會導致一些問題,比如改了 link 的代碼,所有項目都受影響;對 postinstall 不友好;在 postinstall 裏修改了代碼,可能導致其他項目出問題。pnpm 默認就是 copy on write[5],但是 copy on write 這個配置對 mac 沒生效,其實是 node 沒支持導致的,參見 issue[6]。
-
由於 pnpm 創建的 node_modules 依賴軟鏈接,因此在不支持軟鏈接的環境中,無法使用 pnpm,比如 Electron 應用。
如何遷移
How to migrate from yarn / npm to pnpm?[7]
這個是前人給的遷移指南,但是我自己在遷移時並不是這樣做的。
本人遷移步驟如下:
-
刪除 node_modules;
-
直接執行 pnpm i;
-
執行 pnpm dev,看控制檯報錯,看哪個包缺失,再給補上到 package.json。
爲什麼會有 3 呢,因爲項目存在太多幽靈依賴了,所以我在想怎麼去掃描代碼的幽靈依賴。
幽靈依賴怎麼辦
初步思路
參考:https://www.npmjs.com/package/@sugarat/ghost。
但是該 npm 包對我們項目的掃描存在一些問題,比如會全量掃描,沒有去除一些不必要的文件和文件夾。對於項目設置的 alias 沒有配置,依然會誤報,而且掃描速度有限,不夠迅速,所以這次可能使用 swc 來進行實現,與 babel
相比,swc
至少有 10 倍以上的性能優勢。
個人目前有一個思路,暫時還未實現,總結爲以下 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