今天我們開始閲讀pnpm的源碼,深入瞭解pnpm安裝原理,先上圖,pnpm安裝的整體的核心流程如下:
下面我們開始逐步分析。
一、從哪裏開始
每次説到源碼,不太熟悉的人總會有種無從下手的感覺,而pnpm又與我們的框架源碼比如vue又有所不同,因為它是全局安裝的。
前端的小夥伴都知道,全局安裝的依賴不在項目中,那如何找到全局安裝目錄呢?可以通過下面的命令查看:
npm config get prefix
它通常會有以下目錄:
這裏我們主要關心2個目錄,bin與lib。bin裏面包含我們所有的全局命令,lib則是所有的全局依賴所在。
我們想看全局命令pnpm如何執行的,那就直接看bin/pnpm文件就好了,可以清晰的看見大部分bin下的文件都是一個鏈接,可以右擊查看源文件內容:
它最終指向了lib/node_modules/pnpm內的文件:
二、主入口函數
這裏我們以最新版本pnpm@9.5.0-beta.3進行分析。
// bin/pnpm.cjs
require('../dist/pnpm.cjs')bin/pnpm.cjs
內容比較簡單,它直接指向了dist/pnpm.cjs。
由於我們是直接打開的全局安裝依賴的文件,dist/pnpm.cjs是打包後的比較大的文件,直接閲讀這個源碼還是太費勁了。
有2種方式可以找到入口函數:
-
為確保調用的函數/變量已經定義了,通常開發者會將主入口函數定義到文件最後。因此可以直接拉到文件末尾,可以找到一個自執行函數,這就是我們的主入口函數:
var argv = process.argv.slice(2); (async () => { switch (argv[0]) { case "-v": case "--version": { const { version: version2 } = (await Promise.resolve().then(() => __importStar2(require_lib4()))).packageManager; console.log(version2); break; } case "access": case "adduser": case "bugs": case "deprecate": case "dist-tag": case "docs": case "edit": case "home": case "info": case "login": case "logout": case "owner": case "ping": case "prefix": case "profile": case "pkg": case "repo": case "s": case "se": case "search": case "set-script": case "show": case "star": case "stars": case "team": case "token": case "unpublish": case "unstar": case "v": case "version": case "view": case "whoami": case "xmas": await passThruToNpm(); break; default: await runPnpm(); break; } })(); - 利用vs code的斷點能力,可以在我們的項目中定義.vscode/launch.json文件:
通過以上操作會自動在當前項目根目錄下創建文件.vscode/launch.json,這裏我們將其內容替換為:
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "pnpm源碼分析",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "pnpm",
"runtimeArgs": [ "install"],
"console": "integratedTerminal",
"protocol": "auto",
"restart": true,
"port": 9999,
"autoAttachChildProcesses": true
}
]
}
這裏配置的是一個pnpm install的命令,也是我們本次分析的命令。為什麼這麼配置?有興趣的小夥伴可以閲讀官方文檔:Launch configurations(https://go.microsoft.com/fwlink/?linkid=830387)。配置完成後,回到剛剛的tab,可以看到有一個名為“pnpm源碼分析的”命令,可以點擊名字左側的運行按鈕執行。
在運行之前,我們還需要做一件事情,就是為我們的源碼添加斷點(即在需要斷點的地方所在行前面點一下即可,設置斷點後將看到一個小紅圓點):
執行命令後,將在此處停留,與我們熟悉的在瀏覽器打斷點類似,可以逐步分析每一步的執行:
它會進入dist/pnpm.cjs逐步執行,最終也會走入到文件末尾的自執行函數那裏。process.argv是node提供的一個屬性,用於獲取命令行參數,process.argv 的前兩個元素是固定的:
- process.argv[0] 是 Node.js 可執行文件的路徑。
- process.argv[1] 是當前執行的 JavaScript 文件的路徑。
從第三個元素開始,才是用户傳遞的命令行參數。
當前我們運行的是pnpm install,因此將調用runPnpm函數:
async function runPnpm() {
const { errorHandler } = await Promise.resolve().then(() => __importStar2(require_errorHandler()));
try {
const { main } = await Promise.resolve().then(() => __importStar2(require_main3()));
await main(argv);
} catch (err) {
await errorHandler(err);
}
}
解析後的代碼比較沒有源碼好看,可以對照着分析,即:
// pnpm/src/pnpm.ts
async function runPnpm (): Promise<void> {
const { errorHandler } = await import('./errorHandler')
try {
const { main } = await import('./main')
await main(argv)
} catch (err: any) { // eslint-disable-line
await errorHandler(err)
}
}
三、分析pnpm install
main函數位於pnpm/src/main.ts,整個函數比較長,整體流程簡化如下:
共分幾步,下面我們詳細介紹。
(一)將命令行參數格式化
try {
parsedCliArgs = await parseCliArgs(inputArgv)
} catch (err: any) { ... }
在/pnpm/src/parseCliArgs.ts中
(二)獲取pnpm的配置
它會讀取如下內容,並按照優先級合併:
- 全局配置文件
- 項目級配置文件(通常是 .npmrc 文件)
- 環境變量
- 命令行參數
代碼如下:
const {
argv, // 'install'
params: cliParams, // []
options: cliOptions, // {}
cmd, // 'install'
fallbackCommandUsed, // false
unknownOptions, // Map(0) { size: 0 }
workspaceDir, // "/Users/huigao/Documents/WORKSPACE/Learning/pnpm-example"
} = parsedCliArgs
// ...
let config
try {
// When we just want to print the location of the global bin directory,
// we don't need the write permission to it. Related issue: #2700
const globalDirShouldAllowWrite = cmd !== 'root' // true
const isDlxCommand = cmd === 'dlx' // false
// 獲取pnpm配置
config = await getConfig(cliOptions, {
excludeReporter: false,
globalDirShouldAllowWrite,
rcOptionsTypes,
workspaceDir,
checkUnknownSetting: false
ignoreNonAuthSettingsFromLocal: isDlxCommand,
}) as typeof config
// ...
config.argv = argv
config.fallbackCommandUsed = fallbackCommandUsed
// Set 'npm_command' env variable to current command name
if (cmd) {
config.extraEnv = {
...config.extraEnv,
// Follow the behavior of npm by setting it to 'run-script' when running scripts (e.g. pnpm run dev)
// and to the command name otherwise (e.g. pnpm test)
npm_command: cmd === 'run' ? 'run-script' : cmd,
}
}
} catch (err: any) { ... }
// ...
if (
(cmd === 'install' || cmd === 'import' || cmd === 'dedupe' || cmd === 'patch-commit' || cmd === 'patch' || cmd === 'patch-remove') &&
typeof workspaceDir === 'string'
) {
cliOptions['recursive'] = true
config.recursive = true
if (!config.recursiveInstall && !config.filter && !config.filterProd) { // false
config.filter = ['{.}...']
}
}
getConfig實現如下:
最終得到的config如下(內容較多,這裏就截取部分):
(三)讀取pnpm工作區
if (cliOptions['recursive']) { // true
const wsDir = workspaceDir ?? process.cwd() // /Users/huigao/Documents/WORKSPACE/Learning/pnpm-example
config.filter = config.filter ?? [] // []
config.filterProd = config.filterProd ?? [] // []
const filters = [
...config.filter.map((filter) => ({ filter, followProdDepsOnly: false })),
...config.filterProd.map((filter) => ({ filter, followProdDepsOnly: true })),
] // []
const relativeWSDirPath = () => path.relative(process.cwd(), wsDir) || '.'
// ...
const filterResults = await filterPackagesFromDir(wsDir, filters, {
engineStrict: config.engineStrict, // false
nodeVersion: config.nodeVersion ?? config.useNodeVersion, // undefined
patterns: config.workspacePackagePatterns, // ['packages/*']
linkWorkspacePackages: !!config.linkWorkspacePackages, // false
prefix: process.cwd(), // /Users/huigao/Documents/WORKSPACE/Learning/pnpm-example
workspaceDir: wsDir, // /Users/huigao/Documents/WORKSPACE/Learning/pnpm-example
testPattern: config.testPattern, // undefined
changedFilesIgnorePattern: config.changedFilesIgnorePattern, // undefined
useGlobDirFiltering: !config.legacyDirFiltering, // config.legacyDirFiltering: undefined
sharedWorkspaceLockfile: config.sharedWorkspaceLockfile, // true
})
if (filterResults.allProjects.length === 0) {
if (printLogs) {
console.log(`No projects found in "${wsDir}"`)
}
process.exitCode = config.failIfNoMatch ? 1 : 0
return
}
config.allProjectsGraph = filterResults.allProjectsGraph
config.selectedProjectsGraph = filterResults.selectedProjectsGraph
// ...
config.allProjects = filterResults.allProjects
config.workspaceDir = wsDir
}
重點在於filterPackagesFromDir函數,它定義在@pnpm/filter-workspace-packages中:
它總共分為3塊部分:
1、讀取並解析根目錄下與所有工作區內的項目下的package文件,支持3種格式:package.json、package.json5、package.yaml。
2、校驗根目錄與所有工作區的package文件配置環境在當前環境中是否適用,包含系統、node版本與pnpm版本。
3、規範化package文件中的依賴版本,並識別器工作區的依賴
最終拿到的filterResults如下:
(四)開始安裝
這裏只調用了2個函數:checkForUpdates、pnpmCmds[cmd ?? 'help']。
1、checkForUpdates
整個checkForUpdates分了3部分內容:
1)創建解析器createResolver
即:
- createFetchFromRegistry:得到專門用於從 npm 註冊表(registry)獲取包的信息和內容函數
- createGetAuthHeaderByURI:得到一個可以根據給定的 URI 生成適當的認證頭信息的函數
- _createResolver:返回一個包含解析函數resolve的對象
2)執行解析resolve
值得注意的是,這裏解析的是pnpm包(packageManager.name為"pnpm"):
registry得到的是pnpm的源:
resolve函數如下:
代碼非常清晰,優先從npm解析 -> 從Tarball解析 -> 從Git解析 -> 從本地解析。
- 從npm解析
- 從Tarball解析
- 從Git解析
- 從本地解析
完整流程如下:
3)寫入JSON文件
引入write-json-file 實現,它是一個 Node.js 庫,用於將 JavaScript 對象寫入 JSON 文件。這個庫提供了一種簡單且可靠的方式來創建或更新 JSON 文件。checkForUpdates全流程如下:
2、pnpmCmds[cmd ?? 'help']對應install的handler函數。
完整的安裝流程我們前面也貼了,這裏再貼一下:
------------ 未完 ------------