头图

今天我们开始阅读pnpm的源码,深入了解pnpm安装原理,先上图,pnpm安装的整体的核心流程如下:
image.png
下面我们开始逐步分析。

一、从哪里开始

每次说到源码,不太熟悉的人总会有种无从下手的感觉,而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中
image.png

(二)获取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函数。

图片
完整的安装流程我们前面也贴了,这里再贴一下:
图片


------------ 未完 ------------

更多请关注我的个人公众号查看

图片


花伊浓
55 声望2 粉丝

« 上一篇
Web Components