npm#
npm 是最早的依賴安裝命令行工具,以下是 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 提升到 node_modules
而是 B@1.0 呢?如果將 B 直接提取到我們的 node_modules
,是否意味著我們可以在代碼中直接引用 B 包?這就引出了我們下面的問題:
不確定性
對於這種處理方式,我們很容易有疑問:如果我同時引用了同一個包的不同版本,會幫我提取哪個包?每次運行 npm i
之後提取出的包的版本是否都是相同的?這意味著即使使用相同的 package.json
文件,安裝依賴項後也可能得到不同的 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 刪除了 dayjs 的子依賴項,我們的代碼將直接報錯。
依賴分身
假設繼續再安裝依賴 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 配置,比如配置鏡像源。
- 確定依賴版本,構建依賴樹:檢查是否存在
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
文件的寫法是基於語義化版本控制(semantic versioning)的:發布的補丁應該只包含未實質修改的內容。但不幸的是,這並不總是符合事實的。npm 的策略可能會導致兩個設備使用相同的 package.json
文件,但安裝了不同版本的包,這可能會導致故障。
為了防止拉取到不同版本的包,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):檢查系統運行環境,包括 OS、CPU、engines 等信息。
- Resolving packages(解析包):整合依賴信息。
- Fetching packages(獲取包):首先判斷緩存目錄中有沒有緩存資源,其次讀取文件系統,都不存在則從 Registry 進行下載。
- Linking dependencies(連接依賴):複製依賴到 node_modules。首先解析 peerDependencies 信息,之後基於扁平化原則(yarn 扁平化規則不同於 npm,使用頻率較大的版本會安裝到頂層目錄,這個過程稱為 dedupe),從緩存複製依賴至當前項目 node_modules 目錄
- Building fresh packages(構建安裝):這個過程會執行 install 相關鉤子,包括 preinstall、install、postinstall。
解析包(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 值、子依賴等信息。
pnpm#
pnpm 代表 performant(高性能的)npm,如 pnpm 官方介紹,它是:速度快、節省磁碟空間的軟件包管理器,pnpm 本質上就是一個包管理器,它的兩個優勢在於:
- 包安裝速度極快
- 磁碟空間利用非常高效
link 機制#
hard link
那麼,pnpm 如何實現如此大的性能提升呢?這是因為在計算機中有一種叫做 "硬鏈接" (hard link) 的機制。硬鏈接允許用戶通過不同的路徑引用方式找到某個文件。pnpm 會將項目 node_modules
目錄下的硬鏈接文件存儲在全局 store 目錄中。
硬鏈接可以理解為源文件的副本,而實際上,在項目中安裝的就是這些副本。它使得用戶可以通過路徑引用查找到源文件。同時,pnpm 會在全局 store 中存儲硬鏈接,不同的項目可以從全局 store 中尋找到相同的依賴項,從而大大節省了磁碟空間。
硬鏈接是通過索引節點來連接的。在 Linux 文件系統中,保存在磁碟分區中的每個文件都被分配一個編號,稱為索引節點號 (inode index)。在 Linux 中,多個文件名可以指向同一個索引節點。例如:如果 A 是 B 的硬鏈接 (A 和 B 都是文件名),則 A 的目錄項中的 inode 節點號與 B 的目錄項中的 inode 節點號相同,即一個 inode 節點對應兩個不同的文件名,兩個文件名指向同一個文件。對於文件系統來說,A 和 B 是完全平等的。刪除其中任何一個都不會影響另一個的訪問。
symbolic link
Symbolic link 也稱為軟鏈接,它可以理解為快捷方式。pnpm 可以通過軟鏈接找到對應磁碟目錄下的依賴項地址。軟鏈接文件只是其源文件的標記。當刪除源文件後,鏈接文件將無法獨立存在。雖然保留了文件名,但不能查看軟鏈接文件的內容。
刪除文件會影響 symlink 的內容,文件刪除後再恢復內容,但是仍然會和 symlink 保持同步,鏈接文件甚至可以鏈接不存在的文件,這就產生一般稱之為” 斷鏈” 的現象。
pnpm 的 link
這個全新的機制設計非常巧妙,不僅兼容了 node 的依賴解析,同時還解決了以下問題:
-
幽靈依賴問題:只有直接依賴項會展開在
node_modules
目錄下,子依賴項不會被提升,因此不會產生幽靈依賴。 -
依賴分身問題:相同的依賴項只會在全局存儲中安裝一次。項目中只包含源文件的副本,幾乎不會佔用任何空間,因此沒有依賴分身問題。
-
最大的優點是節省磁碟空間,每個包在全局 store 中只保存一份,其餘都是軟鏈接或硬鏈接。
不足之處
-
全局硬鏈接也會導致一些問題。例如,修改了鏈接的代碼,所有項目都會受到影響;也不友好於 postinstall(自動安裝後)操作;在 postinstall 中修改代碼可能會導致其他項目出現問題。pnpm 默認採用 cow(Copy on Write,寫時複製)策略,但該配置在 Mac 上不生效。這實際上是由於 node 不支持所導致的,可以參考相應的 issue。
-
由於 pnpm 創建的
node_modules
依賴項是軟鏈接,因此在不支持軟鏈接的環境中無法使用 pnpm,例如 Electron 應用程序。