pnpm 源碼結構及調試指南

前言

Hi~又是一段時間沒有更新了。前幾天 stateof js 2021 的調查結果發佈了,今年在裏面增加了關於 monorepo tools 的調查報告 (參考鏈接: https://2021.stateofjs.com/en-US/libraries/monorepo-tools)。

其中 pnpm 一舉登頂 2021 年最受歡迎的 monorepo 工具鏈。同時在用戶使用廣度以及其他方面也取得了不錯的成績。

剛好前段時間調試過一陣子 pnpm 以及貢獻過一些代碼,因此筆者對 pnpm 的結構也算有些瞭解,這篇文章將在筆者的理解範圍之內給大家做一個代碼結構解析,如果有問題可以直接指出來,一起探討學習。

源碼結構介紹

首先 pnpm 的代碼主要集中在根目錄下的 packages 目錄中,pnpm 自身所採用的以 pnpm workspace 作爲 monorepo 的管理工具,其裏面的一些模塊都是作爲一個個獨立的子包存在於 packages 目錄下面。

因爲 pnpm 本身的 monorepo 主要管理的都是一些工具庫相關的子包,因此其採用的發包方案正是 changesets,具體可以參考我之前的文章: https://mp.weixin.qq.com/s/DkXmsAGcT6_xl‍ePYgqy4Rw。

packages 下各個子包的具體目錄結構可以參考以下的結構:

.
// 依賴安裝的核心邏輯代碼包
├── core
// 核心包的日誌輸出包
├── core-loggers
// 日誌打印的包
├── default-reporter
// 解析依賴包路徑的包(包括軟鏈接的情況等)
├── dependency-path
// filter 邏輯相關的包
├── filter-workspace-packages
// monorepo 下 package、workspace 相關的包
├── find-packages
├── find-workspace-dir
├── find-workspace-packages
// git 相關的工具包
├── git-fetcher
├── git-resolver
// 處理依賴提升的工具包
├── hoist
// 生命週期相關的包
├── lifecycle
// lock 文件相關的一些工具包
├── lockfile-file
├── lockfile-to-pnp
├── lockfile-types
├── lockfile-utils
├── lockfile-walker
// 處理 npm registry 相關、以及解析對應 npm 包的包
├── normalize-registries
├── npm-registry-agent
├── npm-resolver
// 處理 cli 參數相關的包
├── parse-cli-args
// 解析依賴
├── parse-wanted-dependency
// monorepo 生成依賴圖相關包
├── pkgs-graph
// plugin-commands 包都是涉及對應子命令邏輯相關
├── plugin-commands-audit
├── plugin-commands-env
├── plugin-commands-installation
├── plugin-commands-listing
├── plugin-commands-outdated
├── plugin-commands-publishing
├── plugin-commands-script-runners
├── plugin-commands-server
├── plugin-commands-setup
├── plugin-commands-store
// pnpm 整個項目主包
├── pnpm
// 讀項目 .pnpmfile.cjs 的包
├── pnpmfile
// 讀項目的 pkg.json 工具包
├── read-project-manifest
// 用於提升 pnpm 中的項目依賴的包(類似於 yarn 的方式)
├── real-hoist
// 可視化輸出依賴安裝過程中的 peerDep 問題包
├── render-peer-issues
// 依賴安裝過程中解析依賴使用
├── resolve-dependencies
// 
├── resolve-workspace-range
├── resolver-base
// 用於降級命令到 npm 相關邏輯的包
├── run-npm
// 根據 pkg-graph 對子包進行排序
├── sort-packages
// 硬鏈接 store 管理相關的包
├── store-connection-manager
├── store-controller-types
// 將依賴添軟鏈接到 node_modules 的包
├── symlink-dependency
// npm 壓縮包的抓取以及解析的包
├── tarball-fetcher
├── tarball-resolver
// 寫 pkg.json 的包
├── write-project-manifest
└── ...

pnpm 本身內部有很多的包,上面樹狀架構中,我已經省略掉了一些不常用到或者說是接近廢棄的包 (即便如此,仍然還是存在很多很多的包...)。

這裏我主要根據 pnpm 官網中的各命令行來對代碼結構做個介紹,其實也有很多命令封裝使用到了相同模塊的代碼。例如 install 、update 、add 等命令。

主入口

首先 pnpm 整個項目的主入口包文件爲 packages/pnpm 這個包裏面,這個包名稱也直接叫做 pnpm ,其中 main.ts 文件是其入口文件,這個文件會處理掉用戶傳進來的一些參數,然後根據處理後的不同的參數對各命令做一個下發執行工作,下發後的命令參數再到各個包裏面去,從而執行裏面對應的邏輯。

處理參數用到的包爲 @pnpm/parse-cli-args ,它會接收到用戶傳遞進來的命令行參數,然後將其處理成一個 pnpm 內部的統一格式,例如用戶輸入如下命令:

pnpm add -D axios

這裏傳進來的一些參數都會被 parseCliArgs 這個方法處理:

例如 add 會被處理給 cmd 字段,一些裸的參數例如 axios 會被放進 cliParams 這個數組中,-D 這個參數在 cliOptions 裏面去。處理後的這些變量以及參數用於主入口文件後續代碼執行邏輯的判斷。具體的判斷邏輯可以在調試的時候遇到了,再去看對應的入口邏輯判斷調試即可,這裏不做具體的介紹。

入口包裏還會用到的內部包有 @pnpm/find-workspace-packages 以及 @pnpm/filter-workspace-packages 。

在 main.ts 中會通過調用當前包下面的 cmd 目錄下面的方法 (pnpmCmds),來完成各命令的分發。

這裏更多相關的邏輯參考 pnpm/src/cmd 這一塊的命令掛載詳情。

下面我會根據官網的 CLI commands 來對這裏面涉及到的邏輯進行一個講解。

依賴管理

這部分可以說是整個 pnpm 最核心的一部分了,其中涉及到了 pnpm installpnpm add <deps> 等依賴管理相關的核心命令。

在上一節提到這一塊的邏輯主要在 pnpm 下的 @pnpm/plugin-commands-installation 這個包中完成,這裏只是簡單介紹一下這一塊的邏輯以及引用到的包,並不做具體的討論,因爲關於 pnpm 的依賴安裝原理真的要結合代碼去介紹原理的話,是可以再去寫一整篇文章的。

這一塊依賴管理的核心邏輯是在對應包目錄下的 src/installDeps 這個目錄下,幾乎所有依賴相關的命令最後的邏輯都會在這裏中轉執行,可以看到包括 install 、add 、update 命令的核心邏輯都會在這一塊執行。具體還是根據用戶傳遞進來的參數進行邏輯轉換:

 const result = await mutateModules([
  {
    ...mutatedProject,
    dependencySelectors,
    manifest: updatedImporter.manifest,
    peer: false,
    targetDependenciesField: 'devDependencies',
  },
], installOpts)

這裏簡單截取一下對應的依賴安裝執行邏輯調用的方法,這裏的 mutateModules 方法來自於包 @pnpm/core ,該包爲整個 pnpm 項目的核心包,一些關鍵性的核心邏輯 (例如依賴安裝等) 都是在這裏實現,具體看實現可以參考源碼。

依賴管理這裏還會涉及到一些其他的包:

之前筆者在調試 pnpm update 的一個 bug 的時候,就是從 plugin-command-installation 到 resolve-dependencies 一步步抽絲剝繭,最後找到問題出現在一個庫函數的語句處理裏面,具體可以參考 pr: https://github.com/pnpm/pnpm/pull/4243。

調試技巧

如果你想調試 pnpm 的話,其實在 pnpm 的源碼倉庫下面有個 CONTRIBUTING.md 文檔,裏面比較推薦的方式是使用 pnpm run compile 對項目子包進行一個整體的編譯,然後通過 node <repo_dir>/packages/pnpm [command] 的方式進行調試。

但實際上這種方式效率比較低下,很多時候代碼修改了,調試的時候並不符合預期,修改完成之後又需要再次修改代碼進行重新編譯。

之前有一段時間調試 pnpm 的經歷,這裏給大家分享一下我個人的一些調試經驗。

在 packages/pnpm 的 bin 目錄下有個 pnpm.cjs 文件,裏面的 require 方法指定了 pnpm 在執行的時候走那一塊的邏輯:

這裏默認的邏輯走的是打包後的 dist 目錄下的代碼邏輯,pnpm 的 compile 每次編譯產物的默認目錄都是在 dist 目錄,但這裏如果只是調試的話,我們其實可以完全不走 dist 目錄下的產物代碼邏輯。

之前筆者給 pnpm 提過一個 pr,在下面加上了一段用於走本地產物代碼,在上面截圖中也可以看到,這裏調試的時候只需要註釋掉走 dist 代碼的那段邏輯,然後去走 lib 目錄下的代碼即可:

同時目前基本上 pnpm 下大部分正在維護的子包使用 typescipt 在開發,筆者之前還給一些庫補上了 tsc --watch 命令:

因此如果想通過一種即時編譯的方式去調試 pnpm 源碼的話,可以直接到對應的子包下面將對應子包的 start 命令給 run 起來。然後針對不同的子包去進行一個調試的工作。以下爲筆者的一個調試流程,可以提供來參考。

調試流程

例如調試 pnpm 下面的一個子包,以 @pnpm/plugin-commands-installation 爲例子。

首先可以對整個包代碼執行一次全量的編譯,防止有些包代碼同步之後本地產物沒更新,直接在整個項目的根目錄下執行一次:

pnpm run compile

這次時間可能會比較久一點,但能保證後面一些被引用到的包且我們不去調試包的產物是最新的,防止出現一些包出現 require 不到的問題。

然後直接 cd 到需要調試的包目錄下面,同時主包也要 run 起來,注意這裏要把上一節提到的入口代碼修改好。這裏筆者一般是起多個終端進程,然後將該包的 ts 編譯 run 起來:

cd packages/pnpm && npm run start
cd packages/plugin-commands-installation && npm run start

接下來就可以找個真實的 pnpm 項目來進行調試了。

例如這裏以 naive-ui (https://www.naiveui.com/)這個項目 (使用 pnpm 作爲依賴管理) 作爲例子,這裏可以在 plugin-commands-installation 中需要調試的代碼打上斷點,然後通過 vscode 的 debug terminal 來進行調試:

# 在調試項目的目錄下,例如筆者這裏是 naive-ui
node ~/path/to/pnpm/packages/pnpm install

這樣通過 node 直接到指定的 pnpm 源文件目錄去進行調試,這時命令就會分發到對應代碼邏輯裏面去,前面設置的斷點就會很快生效。參考如圖:

這樣就可以相對簡潔且能直接針對源碼進行調試了,如果有代碼修改也可以在源碼裏面修改之後直接進行調試。

不過這樣調試也有個缺點,例如調試依賴層級比較深的庫的時候,會出現同時起很多進程的現象,例如下圖爲筆者調試 pnpm 依賴安裝流程時,對各個庫進行斷點觀察的圖:

圖中一共起了 6 個進程,但總的來說的話,還是要比去構建產物裏面進行調試找問題要簡潔明瞭得多。

總結

目前 pnpm 已經在 2021 年取得了不俗的成績,期待 2022 年這一年同樣也能帶來更多驚喜的 feature。同時也期待越來越多的 contributor 能參與到 pnpm 的源碼建設中來,一起共同建設可能是未來最有前景的包管理工具。

也喜歡這篇文章能給大家帶來收穫,期待越來越好~

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