簡述前端包管理工具機制和相關實踐

npm 依賴管理機制

區別於 Python 的包管理工具 pip 的全局安裝,npm 會安裝依賴包到當前項目目錄,使不同項目的依賴更成體系,這樣做的好處是減輕了包作者的 API 兼容性壓力;但是缺陷是如果兩個項目依賴了一個相同的庫,一般這個庫會在這兩個項目中各安裝一次,即相同的依賴包會被多次安裝。
我們先通過一張流程圖 (源自掘金) 來了解下 npm install 的整體流程

可以看到執行 npm install 後依次會進行以下流程

緩存機制

我們可以從流程圖中看到,npm install 的流程中會查找和使用緩存,以及下載包後會添加緩存的環節。由於依賴嵌套機制,項目中 node_moudles 佔用的磁盤空間無疑是最大的,如果安裝時每次都通過網絡下載獲取,那麼時間成本是巨大的。常見的優化方式是 “空間換時間”,npm 也通過緩存機制來解決這個問題。
簡單瞭解下緩存的目錄的和清除機制。
通過 npm config get cache 命令可以查詢到緩存目錄:默認是用戶主目錄下的 .npm/_cacache 目錄。
npm cache clean --force 即可強制清除緩存。

yarn 帶來了什麼?

yarn 是於 2016 年誕生的,它的出現解決了歷史上 npm 的很多問題,比如缺乏對於依賴的完整性和一致性保障 (npm v3 版本還沒有 package-lock.json),以及 npm 安裝速度過慢的問題等。npm 目前已經迭代到 v8 版本,在很多方面已經借鑑了 yarn 的優點,但是我們不妨瞭解下 yarn 誕生時帶來的理念。

  1. 確定性。通過 yarn.lock 等機制,保證了確定性,這裏的確定性包括但不限於明確的依賴版本、明確的依賴安裝結構等。即在任何機器和環境下,都可以以相同的方式被安裝。

  2. 模塊扁平化安裝。將依賴包的不同版本,按照一定策略,歸結爲單個版本,以避免創建多個副本造成冗餘。

  3. 更快的速度。yarn 採取並行安裝的機制進行包的安裝任務,提高了性能;yarn 引入的緩存機制使二次安裝的速度更快。

  4. 更好的語義化。yarn 的命令更加簡潔。解決早期 npm 的依賴管理問題

文章的開始提到 npm 是將依賴放到項目的 node_modules 中,同時如果 node_modules 中的依賴 A 還依賴了其他依賴 B,那麼 B 也會被安裝到 A 的 node_modules 文件夾,依次遞歸最終形成非常複雜和龐大的依賴樹。
這種依賴管理方式會隨着項目的迭代,node_moudles 會變得越來越複雜,從而造成:

App
 -a@2.0
   -b@2.0
 -b@2.0
 -c@1.0
   -b@2.0

yarn 在安裝依賴時會打平依賴,並對重複依賴進行提升,最終形成的依賴結構如下:

App
 -a@2.0
 -b@2.0
 -c@1.0

但是需要注意的是:**模塊的安裝順序可能影響 node_modules 內的文件結構。**在 npm v3 版本中,假如 項目一開始依賴了 a@1.0,此時 a@1.0 會被安裝在頂層目錄;隨着迭代,又引入了模塊 b@1.0,而 b@1.0 又依賴了 a@2.0,此時 a@2.0 會被安裝在 b@1.0 下,因爲頂層已經有一個 a@1.0 了。

pnpm: 最先進的包管理工具?

在各個場景下,pnpm 相比較於 npm(v8) 和 yarn(v3) 在性能上都有不錯的提升。
pnpm 之所以有如此大的性能提升,簡單來說 pnpm 是通過全局 store(目錄 ${os.homedir}/.pnpm-store)來存儲 node_modules 依賴的 hard-links,當在項目文件中引用依賴的時候則是通過 symlink 去找到對應虛擬磁盤目錄下 (.pnpm 目錄) 的依賴地址。相比於 npm 和 yarn 會在每個項目中都安裝一份 node_moudles, pnpm 的全局 store 則實現了“安裝一次,所有項目複用”,這樣避免了二次安裝帶來的時間消耗。
除此之外,pnpm 本身的設計機制解決了 monorepo 的很多痛點,比如 ” 幽靈依賴 “ 和 **” 依賴重複安裝 “**的問題。如圖:下面兩小節內容源自: pnpm: 最先進的包管理工具 [1]

幽靈依賴

Phantom dependencies 被稱之爲幽靈依賴,解釋起來很簡單,即某個包沒有被安裝 (package.json 中並沒有,但是用戶卻能夠引用到這個包)。
引發這個現象的原因一般是因爲 node_modules 結構所導致的,例如使用 yarn 對項目安裝依賴,依賴裏面有個依賴叫做 foo,foo 這個依賴同時依賴了 bar,yarn 會對安裝的 node_modules 做一個扁平化結構的處理 (npm v3 之後也是這麼做的),會把依賴在 node_modules 下打平,這樣相當於 foo 和 bar 出現在同一層級下面。那麼根據 nodejs 的尋徑原理,用戶能 require 到 foo,同樣也能 require 到 bar。

package.json -> foo(bar 爲 foo 依賴)
node_modules
  /foo
  /bar -> 👻依賴

那麼這裏這個 bar 就成了一個幽靈依賴,如果某天某個版本的 foo 依賴不再依賴 bar 或者 foo 的版本發生了變化,那麼 require bar 的模塊部分就會拋錯。

依賴重複安裝

這個問題其實也可以說是 hoist 導致的,這個問題可能會導致有大量的依賴的被重複安裝,舉個例子:
例如有個 package,下面依賴有 lib_a、lib_b、lib_c、lib_d,其中 a 和 b 依賴 util_e@1.0.0,而 c 和 d 依賴 util_e@2.0.0。
那麼早期 npm 的依賴結構應該是這樣的:

- package
  - package.json
  - node_modules
     - lib_a
       - node_modules <- util_e@1.0.0
     - lib_b
       - node_modules <- util_e@1.0.0
     _ lib_c
       - node_modules <- util_e@2.0.0
     - lib_d
       - node_modules <- util_e@2.0.0

這樣必然會導致很多依賴被重複安裝,於是就有了 hoist 和打平依賴的操作:

- package
  - package.json
  - node_modules
     - util_e@1.0.0
     - lib_a
     - lib_b
     _ lib_c
       - node_modules <- util_e@2.0.0
     - lib_d
       - node_modules <- util_e@2.0.0

但是這樣也只能提升一個依賴,如果兩個依賴都提升了會導致衝突,這樣同樣會導致一些不同版本的依賴被重複安裝多次,這裏就會導致使用 npm 和 yarn 的性能損失。
如果是 pnpm 的話,這裏因爲依賴始終都是存在 store 目錄下的 hard links ,一份不同的依賴始終都只會被安裝一次,因此這個是能夠被徹徹底底的消除的。

項目中的相關場景實踐和常見問題

適用場景:本地調試 npm 模塊,將模塊鏈接到對應的業務項目中運行 使用方法:假如我們需要把模塊 pkg-a 鏈接到主項目 App 中,首先在 pkg-a 根目錄中執行 npm link,然後在 App 根目錄中執行 npm link pkg-a 即可。調試完可以使用 npm unlink 取消關聯。原理:npm link 通過軟連接將 pkg-a 鏈接到 node 模塊的全局目錄和可執行文件中,實現 npm 包命令的全局可執行。

npx

適用場景:在 npm 5.2.0 版本之後,npm 內置了 npx 的包。npx 是一個簡單的 cli 工具,可以幫助我們快速的調試,還可以讓我們在不通過 npm 安裝包的前提下執行一些 npm 包。

使用方法:
Before: 一般情況下,如果我們想使用 es-lint, 會先通過 npm install es-lint, 然後在項目根目錄執行 ./node_modules/.bin/es-lint your_file.js 或者 通過 package.json 的 npm scripts 調用 eslint。
After: npx es-lint your_file.js
原理:npx 在運行時會自動去 ./node_moudles/.bin 和 環境變量 尋找命令

是否提交 lock.json 到代碼倉庫

前面我們提到 yarn 帶來了 .lock 文件的機制,使得在任何環境下執行 install,都能得到一致的 node_modules 安裝結果。但是是否需要提交 lockfiles(package-lock.json/yarn.lock) 到代碼倉庫呢?
npm 官方文檔 [2] 是建議把 package-lock.json 文件提交到代碼倉庫的。在多人協作的項目中,這樣做確實沒有問題。但是如果開發的是庫,在 npm publish 的時候最好忽略 lockfiles。因爲庫一般是被其他項目依賴的,在不使用 lockfiles 的情況下,由於新版 npm 和 yarn 的 hoist 機制,可以複用住項目已經加載過的包,減少依賴重複和體積。
但是存在這樣一種現象:即使在一些發佈時忽略 lockfiles 的庫中,在主項目頂層存在相關依賴包的前提下,最終生成的 lockfile 仍然沒複用主項目的包。這是爲什麼呢?原因是庫的依賴包版本和主項目存在的依賴包版本不一致。具體看下圖:主項目的 yarn.lock 中顯示 browser 這個包依賴了 @babel/runtime@7.0.0

主項目 node_modules 頂層的 @babel/runtime 版本爲 7.10.1

知道了原因,那麼如何減少庫項目的依賴項呢。到這裏,解決方案也就呼之欲出了:

  1. 庫項目儘量使用和主項目版本一致的依賴包

  2. 在庫項目 package.json 的 “peerDevpendencies” 字段中聲明主項目已有的依賴包

合入其他分支代碼後編譯報錯

相信很多同學都遇到過和我一樣的問題:當自己的 feat 分支代碼合入 master 或者業務班車分支的代碼時,重新 yarn 時,有時候會編譯失敗,報大量 "can't resolve module xxx" 的錯誤。這種錯誤有很多情況是依賴版本不一致的問題,但是又極其難以定位,令人頭痛。那麼此時有另外一個思路,那就是從 master 拉一個最新的分支再進行合入。
但更好的解決方式是:建議在日常開發過程中,定時合入 master 代碼,一方面可以合入最新的 feat,另一方面可以避免長時間不合入,最後在上線階段合入代碼,可能出現大量衝突,解決不當或遺漏而造成的編譯問題。同時也可以考慮將工具升級爲 pnpm,以解決潛在的 “幽靈依賴” 和“依賴嵌套”問題,同時帶來性能上的提升。

參考資料

[1]

pnpm: 最先進的包管理工具: https://bytedance.feishu.cn/docs/doccngSUrvF0qPVmBE1rq1iPZQf

[2]

npm 官方文檔: https://docs.npmjs.com/cli/v7/configuring-npm/package-lock-json

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