pnpm 源碼結(jié)構(gòu)及調(diào)試指南
前言
Hi~又是一段時(shí)間沒(méi)有更新了。前幾天?stateof js 2021?的調(diào)查結(jié)果發(fā)布了,今年在里面增加了關(guān)于 monorepo tools 的調(diào)查報(bào)告(參考鏈接: https://2021.stateofjs.com/en-US/libraries/monorepo-tools )。
其中 pnpm 一舉登頂 2021 年最受歡迎的 monorepo 工具鏈。同時(shí)在用戶使用廣度以及其他方面也取得了不錯(cuò)的成績(jī)。

剛好前段時(shí)間調(diào)試過(guò)一陣子 pnpm 以及貢獻(xiàn)過(guò)一些代碼,因此筆者對(duì) pnpm 的結(jié)構(gòu)也算有些了解,這篇文章將在筆者的理解范圍之內(nèi)給大家做一個(gè)代碼結(jié)構(gòu)解析,如果有問(wèn)題可以直接指出來(lái),一起探討學(xué)習(xí)。
源碼結(jié)構(gòu)介紹
首先 pnpm 的代碼主要集中在根目錄下的?packages?目錄中,pnpm 自身所采用的以 pnpm workspace 作為 monorepo 的管理工具,其里面的一些模塊都是作為一個(gè)個(gè)獨(dú)立的子包存在于 packages 目錄下面。
因?yàn)?pnpm 本身的 monorepo 主要管理的都是一些工具庫(kù)相關(guān)的子包,因此其采用的發(fā)包方案正是 changesets,具體可以參考我之前的文章:?https://mp.weixin.qq.com/s/DkXmsAGcT6_xl?ePYgqy4Rw。
packages 下各個(gè)子包的具體目錄結(jié)構(gòu)可以參考以下的結(jié)構(gòu):
.
// 依賴安裝的核心邏輯代碼包
├── core
// 核心包的日志輸出包
├── core-loggers
// 日志打印的包
├── default-reporter
// 解析依賴包路徑的包(包括軟鏈接的情況等)
├── dependency-path
// filter 邏輯相關(guān)的包
├── filter-workspace-packages
// monorepo 下 package、workspace 相關(guān)的包
├── find-packages
├── find-workspace-dir
├── find-workspace-packages
// git 相關(guān)的工具包
├── git-fetcher
├── git-resolver
// 處理依賴提升的工具包
├── hoist
// 生命周期相關(guān)的包
├── lifecycle
// lock 文件相關(guān)的一些工具包
├── lockfile-file
├── lockfile-to-pnp
├── lockfile-types
├── lockfile-utils
├── lockfile-walker
// 處理 npm registry 相關(guān)、以及解析對(duì)應(yīng) npm 包的包
├── normalize-registries
├── npm-registry-agent
├── npm-resolver
// 處理 cli 參數(shù)相關(guān)的包
├── parse-cli-args
// 解析依賴
├── parse-wanted-dependency
// monorepo 生成依賴圖相關(guān)包
├── pkgs-graph
// plugin-commands 包都是涉及對(duì)應(yīng)子命令邏輯相關(guān)
├── 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 整個(gè)項(xiàng)目主包
├── pnpm
// 讀項(xiàng)目 .pnpmfile.cjs 的包
├── pnpmfile
// 讀項(xiàng)目的 pkg.json 工具包
├── read-project-manifest
// 用于提升 pnpm 中的項(xiàng)目依賴的包(類似于 yarn 的方式)
├── real-hoist
// 可視化輸出依賴安裝過(guò)程中的 peerDep 問(wèn)題包
├── render-peer-issues
// 依賴安裝過(guò)程中解析依賴使用
├── resolve-dependencies
//
├── resolve-workspace-range
├── resolver-base
// 用于降級(jí)命令到 npm 相關(guān)邏輯的包
├── run-npm
// 根據(jù) pkg-graph 對(duì)子包進(jìn)行排序
├── sort-packages
// 硬鏈接 store 管理相關(guān)的包
├── store-connection-manager
├── store-controller-types
// 將依賴添軟鏈接到 node_modules 的包
├── symlink-dependency
// npm 壓縮包的抓取以及解析的包
├── tarball-fetcher
├── tarball-resolver
// 寫 pkg.json 的包
├── write-project-manifest
└── ...
pnpm 本身內(nèi)部有很多的包,上面樹(shù)狀架構(gòu)中,我已經(jīng)省略掉了一些不常用到或者說(shuō)是接近廢棄的包(即便如此,仍然還是存在很多很多的包...)。
這里我主要根據(jù) pnpm 官網(wǎng)中的各命令行來(lái)對(duì)代碼結(jié)構(gòu)做個(gè)介紹,其實(shí)也有很多命令封裝使用到了相同模塊的代碼。例如?install?、update?、add?等命令。
主入口
首先 pnpm 整個(gè)項(xiàng)目的主入口包文件為?packages/pnpm?這個(gè)包里面,這個(gè)包名稱也直接叫做?pnpm?,其中?main.ts?文件是其入口文件,這個(gè)文件會(huì)處理掉用戶傳進(jìn)來(lái)的一些參數(shù),然后根據(jù)處理后的不同的參數(shù)對(duì)各命令做一個(gè)下發(fā)執(zhí)行工作,下發(fā)后的命令參數(shù)再到各個(gè)包里面去,從而執(zhí)行里面對(duì)應(yīng)的邏輯。
處理參數(shù)用到的包為?@pnpm/parse-cli-args?,它會(huì)接收到用戶傳遞進(jìn)來(lái)的命令行參數(shù),然后將其處理成一個(gè) pnpm 內(nèi)部的統(tǒng)一格式,例如用戶輸入如下命令:
pnpm add -D axios
這里傳進(jìn)來(lái)的一些參數(shù)都會(huì)被?parseCliArgs?這個(gè)方法處理:
例如?add?會(huì)被處理給?cmd?字段,一些裸的參數(shù)例如?axios?會(huì)被放進(jìn)?cliParams?這個(gè)數(shù)組中,-D?這個(gè)參數(shù)在?cliOptions?里面去。處理后的這些變量以及參數(shù)用于主入口文件后續(xù)代碼執(zhí)行邏輯的判斷。具體的判斷邏輯可以在調(diào)試的時(shí)候遇到了,再去看對(duì)應(yīng)的入口邏輯判斷調(diào)試即可,這里不做具體的介紹。
入口包里還會(huì)用到的內(nèi)部包有?@pnpm/find-workspace-packages?以及?@pnpm/filter-workspace-packages?。
-
findWorkspacePackages?在入口文件中用于找到 pnpm workspace(適用于 monorepo 項(xiàng)目)中所有包的一些信息(例如名稱、路徑等)。
-
filterPackages?相對(duì)而言來(lái)說(shuō)是個(gè)比較關(guān)鍵的包,pnpm 官方有一篇文檔專門介紹了?--filter?這一功能模塊(參考:?https://pnpm.io/filtering),它為幾乎所有的?pnpm 命令提供了一個(gè)很簡(jiǎn)單且實(shí)用的篩選功能,根據(jù)用戶傳遞進(jìn)來(lái)的篩選參數(shù)對(duì) monorepo 下的子包進(jìn)行一個(gè)篩選,會(huì)根據(jù)篩選參數(shù)(例如?...)輸出帥選出來(lái)的對(duì)應(yīng)包以及相關(guān)信息。
在?main.ts?中會(huì)通過(guò)調(diào)用當(dāng)前包下面的?cmd?目錄下面的方法(pnpmCmds),來(lái)完成各命令的分發(fā)。
-
如果 cmd 值為?
add?、install?、update?等這些涉及和依賴安裝相關(guān)的包,則會(huì)走?@pnpm/plugin-commands-installation?這個(gè)包里面對(duì)應(yīng)的子命令邏輯(基本上 pnpm 所有的核心模塊都圍繞依賴安裝這一塊展開(kāi))。
-
如果 cmd 值為?
pack?、publish?這一類涉及到打包發(fā)布的包,則會(huì)走?@pnpm/plugin-commands-publishing?這個(gè)包的邏輯。
-
如果 cmd 值為?
run?、exec?、?dlx?等這些和命令執(zhí)行相關(guān)的方法,則會(huì)走?@pnpm/plugin-commands-script-runners?這個(gè)包的邏輯。
這里更多相關(guān)的邏輯參考?pnpm/src/cmd?這一塊的命令掛載詳情。
下面我會(huì)根據(jù)官網(wǎng)的 CLI commands 來(lái)對(duì)這里面涉及到的邏輯進(jìn)行一個(gè)講解。
依賴管理
這部分可以說(shuō)是整個(gè) pnpm 最核心的一部分了,其中涉及到了?pnpm install、pnpm add <deps>?等依賴管理相關(guān)的核心命令。
在上一節(jié)提到這一塊的邏輯主要在 pnpm 下的?@pnpm/plugin-commands-installation?這個(gè)包中完成,這里只是簡(jiǎn)單介紹一下這一塊的邏輯以及引用到的包,并不做具體的討論,因?yàn)殛P(guān)于 pnpm 的依賴安裝原理真的要結(jié)合代碼去介紹原理的話,是可以再去寫一整篇文章的。
這一塊依賴管理的核心邏輯是在對(duì)應(yīng)包目錄下的?src/installDeps?這個(gè)目錄下,幾乎所有依賴相關(guān)的命令最后的邏輯都會(huì)在這里中轉(zhuǎn)執(zhí)行,可以看到包括?install?、add?、update?命令的核心邏輯都會(huì)在這一塊執(zhí)行。具體還是根據(jù)用戶傳遞進(jìn)來(lái)的參數(shù)進(jìn)行邏輯轉(zhuǎn)換:
const result = await mutateModules([
{
...mutatedProject,
dependencySelectors,
manifest: updatedImporter.manifest,
peer: false,
targetDependenciesField: 'devDependencies',
},
], installOpts)
這里簡(jiǎn)單截取一下對(duì)應(yīng)的依賴安裝執(zhí)行邏輯調(diào)用的方法,這里的?mutateModules?方法來(lái)自于包?@pnpm/core?,該包為整個(gè) pnpm 項(xiàng)目的核心包,一些關(guān)鍵性的核心邏輯(例如依賴安裝等)都是在這里實(shí)現(xiàn),具體看實(shí)現(xiàn)可以參考源碼。
依賴管理這里還會(huì)涉及到一些其他的包:
-
用于處理 lifeCycle 方法的?
@pnpm/lifecycle
-
輸出日志(例如依賴安裝過(guò)程中的日志打印)的?
@pnpm/core-loggers?、@pnpm/logger
-
依賴安裝過(guò)程中生成、更新 pnpm-lock.yaml 文件的?
@pnpm/lockfile-file
-
依賴安裝過(guò)程中解析依賴并拉取依賴包的?
@pnpm/resolve-dependencies
之前筆者在調(diào)試?pnpm update?的一個(gè) bug 的時(shí)候,就是從?plugin-command-installation?到?resolve-dependencies?一步步抽絲剝繭,最后找到問(wèn)題出現(xiàn)在一個(gè)庫(kù)函數(shù)的語(yǔ)句處理里面,具體可以參考 pr: https://github.com/pnpm/pnpm/pull/4243。
調(diào)試技巧
如果你想調(diào)試 pnpm 的話,其實(shí)在 pnpm 的源碼倉(cāng)庫(kù)下面有個(gè)?CONTRIBUTING.md?文檔,里面比較推薦的方式是使用?pnpm run compile?對(duì)項(xiàng)目子包進(jìn)行一個(gè)整體的編譯,然后通過(guò)?node <repo_dir>/packages/pnpm [command]?的方式進(jìn)行調(diào)試。
但實(shí)際上這種方式效率比較低下,很多時(shí)候代碼修改了,調(diào)試的時(shí)候并不符合預(yù)期,修改完成之后又需要再次修改代碼進(jìn)行重新編譯。
之前有一段時(shí)間調(diào)試 pnpm 的經(jīng)歷,這里給大家分享一下我個(gè)人的一些調(diào)試經(jīng)驗(yàn)。
在?packages/pnpm?的 bin 目錄下有個(gè)?pnpm.cjs?文件,里面的?require?方法指定了 pnpm 在執(zhí)行的時(shí)候走那一塊的邏輯:

這里默認(rèn)的邏輯走的是打包后的?dist?目錄下的代碼邏輯,pnpm 的 compile 每次編譯產(chǎn)物的默認(rèn)目錄都是在 dist 目錄,但這里如果只是調(diào)試的話,我們其實(shí)可以完全不走 dist 目錄下的產(chǎn)物代碼邏輯。
之前筆者給 pnpm 提過(guò)一個(gè) pr,在下面加上了一段用于走本地產(chǎn)物代碼,在上面截圖中也可以看到,這里調(diào)試的時(shí)候只需要注釋掉走?dist?代碼的那段邏輯,然后去走?lib?目錄下的代碼即可:

同時(shí)目前基本上 pnpm 下大部分正在維護(hù)的子包使用 typescipt 在開(kāi)發(fā),筆者之前還給一些庫(kù)補(bǔ)上了?tsc --watch?命令:

因此如果想通過(guò)一種即時(shí)編譯的方式去調(diào)試 pnpm 源碼的話,可以直接到對(duì)應(yīng)的子包下面將對(duì)應(yīng)子包的 start 命令給 run 起來(lái)。然后針對(duì)不同的子包去進(jìn)行一個(gè)調(diào)試的工作。以下為筆者的一個(gè)調(diào)試流程,可以提供來(lái)參考。
調(diào)試流程
例如調(diào)試 pnpm 下面的一個(gè)子包,以?@pnpm/plugin-commands-installation?為例子。
首先可以對(duì)整個(gè)包代碼執(zhí)行一次全量的編譯,防止有些包代碼同步之后本地產(chǎn)物沒(méi)更新,直接在整個(gè)項(xiàng)目的根目錄下執(zhí)行一次:
pnpm run compile
這次時(shí)間可能會(huì)比較久一點(diǎn),但能保證后面一些被引用到的包且我們不去調(diào)試包的產(chǎn)物是最新的,防止出現(xiàn)一些包出現(xiàn)?require?不到的問(wèn)題。
然后直接?cd?到需要調(diào)試的包目錄下面,同時(shí)主包也要 run 起來(lái),注意這里要把上一節(jié)提到的入口代碼修改好。這里筆者一般是起多個(gè)終端進(jìn)程,然后將該包的 ts 編譯 run 起來(lái):
cd packages/pnpm && npm run start
cd packages/plugin-commands-installation && npm run start

接下來(lái)就可以找個(gè)真實(shí)的 pnpm 項(xiàng)目來(lái)進(jìn)行調(diào)試了。
例如這里以 naive-ui (https://www.naiveui.com/)這個(gè)項(xiàng)目(使用?pnpm 作為依賴管理)作為例子,這里可以在?plugin-commands-installation?中需要調(diào)試的代碼打上斷點(diǎn),然后通過(guò) vscode 的?debug terminal?來(lái)進(jìn)行調(diào)試:
# 在調(diào)試項(xiàng)目的目錄下,例如筆者這里是 naive-ui
node ~/path/to/pnpm/packages/pnpm install
這樣通過(guò) node 直接到指定的 pnpm 源文件目錄去進(jìn)行調(diào)試,這時(shí)命令就會(huì)分發(fā)到對(duì)應(yīng)代碼邏輯里面去,前面設(shè)置的斷點(diǎn)就會(huì)很快生效。參考如圖:

這樣就可以相對(duì)簡(jiǎn)潔且能直接針對(duì)源碼進(jìn)行調(diào)試了,如果有代碼修改也可以在源碼里面修改之后直接進(jìn)行調(diào)試。
不過(guò)這樣調(diào)試也有個(gè)缺點(diǎn),例如調(diào)試依賴層級(jí)比較深的庫(kù)的時(shí)候,會(huì)出現(xiàn)同時(shí)起很多進(jìn)程的現(xiàn)象,例如下圖為筆者調(diào)試 pnpm 依賴安裝流程時(shí),對(duì)各個(gè)庫(kù)進(jìn)行斷點(diǎn)觀察的圖:

圖中一共起了 6 個(gè)進(jìn)程,但總的來(lái)說(shuō)的話,還是要比去構(gòu)建產(chǎn)物里面進(jìn)行調(diào)試找問(wèn)題要簡(jiǎn)潔明了得多。
總結(jié)
目前 pnpm 已經(jīng)在 2021 年取得了不俗的成績(jī),期待 2022 年這一年同樣也能帶來(lái)更多驚喜的 feature。同時(shí)也期待越來(lái)越多的 contributor 能參與到 pnpm 的源碼建設(shè)中來(lái),一起共同建設(shè)可能是未來(lái)最有前景的包管理工具。
也喜歡這篇文章能給大家?guī)?lái)收獲,期待越來(lái)越好~
