淺談 JS 生態系的套件管理機制與發展

莫力全 Kyle Mo
14 min readSep 17, 2022

--

談到 JS 的套件管理工具,一般來說都會想到以上三者(npm, yarn, pnpm)

過去我總認為 JavaScript 生態系的套件管理機制沒什麼好特別研究的,畢竟在 npm 這種套件管理工具出現後,開發者只需要簡單下幾個指令,例如 npm install,專案就會自動安裝好需要使用的套件,以及該套件會使用到的其他套件。直到後來在工作上為了處理一些會產生 security issues 的套件,以及發現使用 npm 安裝套件速度過慢的問題,我才花了一些時間研究這些套件管理工具背後的機制以及差別,也才發現它遠比我想像的還要複雜且有趣。

在 NPM 出現以前

我覺得自己還蠻幸運的,算是有親身經歷過前端技術發生重大變遷的時期,記得剛開始學習網頁開發的時候,雖然 npm 已經出現,還是許多教學資源教的是透過 <script> 標籤搭配 CDN 的方式載入套件,例如說:

// jquery
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"></script>
// bootstrap
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js"></script>

相信大多數人都聽過 bootstrap 這個可以讓我們快速建制特定樣式網頁的框架,然而 bootstrap 這個套件其實依賴 jquery,所以如果 jquery 沒有早於 bootstrap 載入,瀏覽器是會報錯的,因此透過上面這種方式引入套件的話就必須把被依賴的套件寫在前面。

如果網頁只使用少數的套件,這種方式倒不是什麼大問題,但現今的網頁應用越趨複雜,套件依賴同樣也不再單純,繼續使用這種方式顯然會被時代所淘汰。

NPM

npm 是由 Node.js 官方提供的工具,所以當你安裝了 Node.js,你會發現你也可以執行 npm 相關的指令。npm 嚴格來說可以依照版本劃分成三個時代:

  • npm v3 以前
  • npm v3 以後,npm v5 以前
  • npm v5 之後

之所以會這樣劃分,主要是因為「node_modules 的結構改變(npm v3 vs v3 之前)」與「package-lock.json 的出現(npm v5)」。

npm v3 以前

在 npm 3 以前,node_modules 是「遞迴的結構」,例如以下範例:

- node_modules  - A@1.0.0    - node_modules      - C@1.0.0  - B@1.0.0    - node_modules      - C@1.0.0

當執行 npm install指令,npm 會根據 package.json 檔案中的內容開始安裝所需的套件,在 npm v3 以前,會採用遞迴的方式安裝,例如根目錄的 node_modules 中會看到套件 A 與套件 B 的資料夾,這兩個資料夾中又會有各自的 node_modules,其中包含了各自會使用到的套件。

初步看下來其實沒什麼問題,套件間的依賴關係是一層一層的,最後也可以建構出一整個「依賴樹 Dependencies Tree」,就像電腦中資料夾與檔案一層一層的關係一樣。不過這種架構其實會衍生出兩個問題:

  • 如果套件依賴關係變得複雜,有可能會創造出一個非常「深」的依賴樹,這時候在樹的底層的文件的路徑可能會過長(例如 node_modules/A/node_modules/C/node_modules……….一直延伸下去),造成在特定作業系統(例如 Windows)會觸發超過路徑最長長度的錯誤。
  • 從上面的例子可以發現,套件 A 與套件 B 都是使用套件 C 的相同版本,但套件 C 的這個版本卻被重複安裝了兩次,在專案龐大且複雜的狀況下這個問題會導致安裝套件時的一些效能耗損。

NPM v3

到了 npm v3,node_modules 的結構被修改為「扁平化」的,如果同樣以上面套件 ABC 的依賴關係為例子,node_modules 的結構會變成:

- node_modules  - A@1.0.0  - B@1.0.0  - C@1.0.0

雖然套件 A 跟套件 B 都依賴於套件 C,不過在 node_modules 中套件 C 會被提升至同一個層級,形成扁平化的結構。這可以解決 npm v3 以前提到的路徑過長與套件重複安裝的問題,但是當不同套件使用了「同一個套件的不同版本」,例如假設套件 A 與套件 B 都使用套件 C 的 v1.0.0,但是套件 D 使用 v1.0.1 版本的套件 C,則 node_modules 的結構會變成以下這樣:

- node_modules  - A@1.0.0    - node_modules      - C@1.0.0  - B@1.0.0    - node_modules      - C@1.0.0  - C@1.0.1  - D@1.0.0

npm 會將同套件中比較新的版本的套件「hoist 提升」到扁平目錄的第一層,至於如果其他套件使用比較舊版的套件,就會放在各自的 node_modules 底下。所以如果換成套件 A, B 依賴套件 C v 1.0.1,套件 D 依賴套件 C v1.0.0,則 node_modules 結構會變成以下這樣:

- node_modules  - A@1.0.0  - B@1.0.0  - C@1.0.1  - D@1.0.0  - node_modules    - C@1.0.0

npm 5 : lock file 的誕生

在看 npm 5 帶來什麼改變之前,要先提到 semantic versioning 的概念。

在 package.json 中,你看到的套件版本會是這樣的格式:x.x.x (major.minro.patch),這個格式其實就是採用 semantic versioning,它的定義如下:

來源:https://semver.org/lang/zh-TW/

而在 package.json 中你可能會發現版號前還會有一些特殊符號,例如:

  • ~1.0.4,代表可以更新套件的 patch version 至最新,也就是 1.0.x,小於 1.1.0
  • ^1.0.4,代表可以更新套件的 minor version 至最新,例如 1.x,小於 2.0.0

也就是說多人協作的狀況下,每個人執行 npm install 時有可能會得到 dependencies 不一致的狀況。

於是 npm 在 v5 引入了 package-lock.json 的機制(小知識補充:其實 yarn.lock 的出現比 package-lock.json 還要早喔!),這個檔案紀錄了 node_modules 裡所有 packages 的結構,白話點就是「可以儲存 node_modules 的狀態」,有了這個 lock file,不管是誰執行 npm install 都會得到相同的 node_modules 結果。

Yarn

老實說大多數人對 Yarn 的認知就是速度比較快的 npm 而已,而它效能比較快的主因有:

  • 平行安裝:npm 是序列化的依序安裝 packages,而 yarn 則是可以同步執行任務,大大提升了性能。
  • Cache:如果 Yarn 先前已經抓取過某個 package,當再次安裝時就可以直接從 cache 中取得,而不必再透過網路下載。

早期的 Yarn 其實在其他方面跟 npm 也相差不大,同樣會在安裝後產生 node_modules 檔案,直到 Yarn pnp (Plug’n’Play) 的出現,才出現了巨大的變化。

安裝套件並產生 node_modules 後,NodeJS 在引用套件時的流程其實是先去看現在的路徑裡的 node_modules 有沒有要找的檔案,沒有的話會往上層的 node_modules 尋找,這其實是一個非常沒有效率的方式,並且有產生很多缺點:

  • 產生 node_modules 其實佔了整個 install 流程約 70% 的時間,非常耗時。
  • 產生 node_modules 檔案是一個 I/O-heavy 的操作,package managers 基本上能對這個流程做的優化也十分有限。
  • 即便改成扁平化的結構,node_modules 仍然有可能只提升某個套件的特定版本,其他版本仍在許多套件的依賴中重複出現,佔用 disk 的空間。

結論就是 node_modules 「引用套件慢,安裝套件也慢。」,雖然跑第二遍 install 速度可能非常快,但對於每次都要重新安裝的 CI 環境來說,真的是一大痛點。

使用 Yarn pnp 安裝套件後,將不會再出現 mode_modules,取而代之的是 .yarn folder,裡面有 cache、unplugged 兩個資料夾,另外專案的 root 也會多一個 .pnp.js 檔案。

cache 資料夾存放著所有需要的依賴套件的壓縮檔案 (.zip),unplugged 則是存放需要手動去修改的依賴套件,至於 .pnp.js 當然就是 yarn pnp 的精髓,它是 yarn 維護的一個 static mapping 結構,裡面記錄了一些訊息:

  • 目前的依賴樹包含了哪些依賴套件的哪些版本?
  • 這些套件如何互相依賴?
  • 這些依賴套件在 file system 中的具體位置

在安裝的過程中,Yarn 會在 .pnp.js 中記錄下套件在快取中的具體位置,如此一來就可以避免大量的 I/O-heavy 的操作,也沒有必要產生 node_modules。

當 Yarn 要處理 resolve() 尋找依賴套件時,就可以直接根據 .pnp.js 的 hash mapping 找到依賴套件在 file system 的具體位置,而不用像早期的 yarn 或是 npm 一樣透過大量 I/O 操作去找到依賴套件。

所以 Yarn pnp 帶來的好處其實十分明顯,首先是安裝套件的速度比以往要產生 node_modules 的方式還要快上許多,再來在 CI 環境中的 task 也可以共用同一份快取,而不需要重新安裝。最後是我覺得最重要的,Yarn pnp 可以解決 node_modules 相同套件相同版本卻重複安裝的問題,不會無意義的佔用到磁碟空間。

最後這種捨棄 node_modules 的模式,除了在安裝套件有比較快的速度以外,再刪除上的速度也大為提升喔!

PNPM

pnpm 是最新一代的套件管理工具,它標榜著效能比 npm 與 yarn 還要快上許多,首先看看 pnpm 官方提供的 benchmark:

圖片來源:https://github.com/pnpm/pnpm

可以發現在大多數的狀況下 pnpm 的表現都是最為出色的。

那 pnpm 究竟為什麼會那麼快呢?

Hardlink 機制

Hardlink 其實不是 pnpm 引進的新技術,而是電腦本身就有的一個機制,我們可以先看看維基百科的介紹

Hardlink 機制使得我們可以透過不同的 path 卻取到相同的檔案,舉例來說,我們專案使用了依賴 A(大小為 3MB),我們會認為它佔用了 node_modules 與 store folder (稍後會提及,用於儲存依賴套件的 hardlink) 各 3MB 的空間,總共花費 6MB 的空間,但是有了 Hardlink 機制,這兩個不同 path 可以共用同一個套件檔案,因此總共還是只花費了一份依賴 A 的大小(3MB)。

Store 資料夾

store 資料夾是 pnpm 建立用來儲存依賴套件的 hardlinks,一般來說這個資料夾預設會放置在 ${os.homedir}/.pnpm-store底下,如果想更換路徑,也可以透過 .npmrc 進行修改。

有了 Hardlink 機制與 store 資料夾,每次安裝依賴套件時,如果有很多專案都使用了相同的套件與相同的版本,那這個依賴實際上只會被安裝一次,這也是 pnpm 跟 npm 或是 yarn 一個很大的差別,因為對於 npm 與 yarn 來說,就算是相同的套件與相同的版本,只要在不同專案中,每次安裝都會被下載一次,因此 pnpm 可以說大大的減少了磁碟空間的用量。

pnpm 的 node_modules 結構

pnpm 同樣會產生 node_modules,但它的結構與引用套件的方式都跟 npm 與 yarn 有所不同。

這邊非常建議去看看 pnpm 官方的文章 <Flat node_modules is not the only way> ,文中假設透過 pnpm 安裝 Node.js 的後端框架 express (pnpm add express),如果是透過 npm 安裝的話會得到以下扁平的 node_modules 結構:

使用 npm 安裝,獲得扁平的 node_modules 結構

但如果透過 pnpm 安裝,則會得到:

再進一步看看 express 裡面有什麼東西

咦!?express 底下沒有其他 node_modules 了,那 express 所依賴的套件呢?
實際上這個 express folder 只是個 symlink(類似於快捷鍵),形成一個到另外一個目錄的軟連結,Node.JS 在找尋套件 express 時,會透過 symlink 實際找到 .pnpm 資料夾底下的內容 (node_modules/.pnpm/express@4.17.1/node_modules/express.)。

.pnpm 使用扁平的方式儲存了所有的 packages.所以每一個 package 都可以被以下格式的路徑找到:

.pnpm/<name>@<version>/node_modules/<name>

pnpm 官方也叫它「虛擬磁碟目錄 virtual store directory.」

以上面 express 的例子來說,express 使用到的依賴套件會被平鋪到 .pnpm/express@x.x.x/node_modules/這個路徑下,這樣保證了依賴仍能被 require 到,同時也不會造成過深的依賴層級。總體來說 pnpm 的 node_modules 不同於 npm 與 yarn,採用了樹狀加上平鋪的結構,並且主要基於 symlink 機制來達成。

了解 pnpm 的 symlink 與 hardlink 機制後,就可以大概得知 pnpm 的運作方式,假設今天專案要安裝套件 A 與套件 B,透過 pnpm 安裝後會得到以下結構

node_modules
└── A // symlink to .pnpm/A@1.0.0/node_modules/A
└── B // symlink to .pnpm/B@1.0.0/node_modules/B
└── .pnpm
├── A@1.0.0
│ └── node_modules
│ └── A -> <store>/A
│ ├── index.js
│ └── package.json
└── B@1.0.0
└── node_modules
└── B -> <store>/B
├── index.js
└── package.json

node_modules 中的 A 與B 兩個資料夾會透過 symlink 機制連結到 .pnpm 這個資料夾下的真實資料夾中,而這些依賴套件則通過 Hardlink 存取到 global 的 store 資料夾中。

結論

筆者在工作中的專案目前使用的是 npm v8,使用上也沒有遇到太大的問題,不過最近其他專案的同事嘗試從 npm 轉換到 yarn pnp 後讓 CI process 的 install time 大幅減少,也因為少了 node_modules,讓 Docker image size 從 200MB 減少到 85 MB。看到這樣巨幅的改善,才讓我願意花一些時間研究 JS 生態系的各種套件管理工具與其間的差異,當然要從一個工具 migrate 到另一個工具也許沒有看起來這麼簡單,例如筆者的專案現在有一些 Phantom dependencies 的問題,意思是我可以直接透過 require() 引入「套件使用的套件」,例如我在專案的 package.json 中指定了使用套件 A,而套件 A 使用了套件 B,此時我在專案中寫:

const b = require('b');

即使套件 B 沒有被列在 package.json 當中,因為 npm v8 扁平 node_modules 結構的關係,Node 是可以引入得到 B 套件的,然而如果轉換成 pnpm,這樣的寫法都會造成錯誤回報,需要特別修正才行。

pnpm 看似完美,但它仍然有一些缺點,例如有些 Node.js 基礎套件不支援 pnpm、lambda 環境不支援 pnpm…等等,因此最佳的方法還是了解各種工具的機制,並且選擇最適合專案現況的解法囉!

--

--

莫力全 Kyle Mo
莫力全 Kyle Mo

Written by 莫力全 Kyle Mo

什麼都想學的雜食性軟體工程師 🇹🇼 (https://github.com/kylemocode) 合作與聯繫 📪 oldmo860617@gmail.com IG 技術自媒體:@kylemo.webdev.life

No responses yet