今天我们开始阅读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函数。
完整的安装流程我们前面也贴了,这里再贴一下:
------------ 未完 ------------
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。