pnpm 原始碼結構及除錯指南

語言: CN / TW / HK

前言

隨著前段時間尤大在 vue3 以及 vite 倉庫中切換包管理為 pnpm 的 pr 成功 merge,以及 vue 生態中的一些專案例如 VueUse 也切換使用 pnpm,宣告著 vue 生態中專案倉庫完成了從原有的  yarn workspace monorepo  到  pnpm workspace monorepo  的遷移。

可以看到 vite 核心貢獻者以及 vue 團隊成員之一的 patak (https://github.com/patak-js) 在 twitter 上對這次專案遷移的生動描述:“專案如同多米諾骨牌一樣倒向了 pnpm”。

具體關於 pnpm 相關介紹可以參考筆者之前寫的一篇文章: pnpm: 最先進的包管理工具 ,本文中不會對此做更多的介紹。

前幾天 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 官網中的各命令列來對程式碼結構做個介紹,其實也有很多命令封裝使用到了相同模組的程式碼。例如 installupdateadd 等命令。

主入口

首先 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

  • findWorkspacePackages 在入口檔案中用於找到 pnpm workspace(適用於 monorepo 專案)中所有包的一些資訊(例如名稱、路徑等)。

  • filterPackages 相對而言來說是個比較關鍵的包,pnpm 官方有一篇文件專門介紹了  --filter 這一功能模組(參考: https://pnpm.io/filtering),它為幾乎所有的 pnpm 命令提供了一個很簡單且實用的篩選功能,根據使用者傳遞進來的篩選引數對 monorepo 下的子包進行一個篩選,會根據篩選引數(例如  ... )輸出帥選出來的對應包以及相關資訊。

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

  • 如果 cmd 值為 addinstallupdate 等這些涉及和依賴安裝相關的包,則會走  @pnpm/plugin-commands-installation 這個包裡面對應的子命令邏輯(基本上 pnpm 所有的核心模組都圍繞依賴安裝這一塊展開)。

  • 如果 cmd 值為 packpublish 這一類涉及到打包釋出的包,則會走  @pnpm/plugin-commands-publishing 這個包的邏輯。

  • 如果 cmd 值為 runexec 、  dlx 等這些和命令執行相關的方法,則會走  @pnpm/plugin-commands-script-runners 這個包的邏輯。

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

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

依賴管理

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

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

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

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

這裡簡單擷取一下對應的依賴安裝執行邏輯呼叫的方法,這裡的 mutateModules 方法來自於包  @pnpm/core ,該包為整個 pnpm 專案的核心包,一些關鍵性的核心邏輯(例如依賴安裝等)都是在這裡實現,具體看實現可以參考原始碼。

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

  • 用於處理 lifeCycle 方法的 @pnpm/lifecycle

  • 輸出日誌(例如依賴安裝過程中的日誌列印)的 @pnpm/core-loggers@pnpm/logger

  • 依賴安裝過程中生成、更新 pnpm-lock.yaml 檔案的 @pnpm/lockfile-file

  • 依賴安裝過程中解析依賴並拉取依賴包的 @pnpm/resolve-dependencies

之前筆者在除錯 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 的原始碼建設中來,一起共同建設可能是未來最有前景的包管理工具。

  -   E N D   -

3 6 0 W 3 C E C M A T C 3 9 L e a d e r