面试官:pnpm 那么流行,知道它的源码架构实现吗?🤡

6,449 阅读19分钟

分享背景

pnpm 作为一个优秀的包管理工具,在如今的前端生态中呈持续发展的趋势,与 npm、yarn 相比,它在底层上做了彻底性的改变,通过软硬链接、并行处理依赖等方式实现了更快更省空间的依赖管理,有效解决了幽灵依赖的问题。

作为程序员,我们不能仅仅把目光聚焦于工具的使用上,而是要有意识地去了解工具的实现原理。这篇文章就会给大家带来 pnpm 的源码结构探究!

对于 pnpm 在应用和优势上的探究,可以阅读文章:
面试官:说说包管理工具的发展以及 pnpm 依赖治理的最佳实践 🤯 - 掘金 (juejin.cn)

其他源码阅读文章:
基于源码的 Webpack 结构分析 - 掘金 (juejin.cn)

源码解读

首先我们来观察一个总览的执行过程,大概看一下 pnpm 会做些什么:

image.png

处理命令

用户在终端执行命令之后,会进入 pnpm/src/main.ts 进行命令解析,具体逻辑如下:

async function main(inputArgv: string[]) {
  // 解析CLI参数
  let parsedCliArgs = await parseCliArgs(inputArgv);

  const { cmd, unknownOptions, workspaceDir } = parsedCliArgs;

  // 如果命令未知,退出

  // 处理配置
  let config: Config & {
    forceSharedLockfile: boolean
    argv: { remain: string[], cooked: string[], original: string[] }
    fallbackCommandUsed: boolean
  }

  const globalDirShouldAllowWrite = cmd !== 'root'
  config = await getConfig(cliOptions, {
    excludeReporter: false,
    globalDirShouldAllowWrite,
    // ……
  }) as typeof config

  if (cmd) {
    config.extraEnv = {
      ...config.extraEnv,
      // 判断是否执行 script 脚本
      npm_command: cmd === 'run' ? 'run-script' : cmd,
    }
  }

  // pnpm 更新
  // 过滤配置

  // 执行命令
  let { output, exitCode }: { output?: string | null, exitCode: number } =
    await (async () => {
      let result = pnpmCmds[cmd ?? 'help'](
        config as Omit<typeof config, 'reporter'>,
        cliParams
      )
      if (result instanceof Promise) result = await result
      // result 其他判断逻辑…… 
      return result
    })();

  // 输出结果
  if (output) console.log(output);
  // 根据执行结果设置进程退出码
  if (exitCode) process.exitCode = exitCode;
}

可以看到最终执行在 pnpmCmds 中进行,对于 pnpmCmds 的具体实现,我们放在后续进行介绍。可以看到 main.ts 作为入口函数,执行了从获取命令、处理并得到最终 config 、pnpm 更新、处理过滤行为,再到执行、输出等任务,涵盖了 pnpm 的全流程,在进一步了解具体的命令执行前,我们可以依据 main 的执行顺序依次分析源码中的一些 case。

执行自定义脚本

我们常在项目的 package.json 中定义 scripts 脚本,比如我们执行 pnpm run dev,pnpm 内部会检测到 dev 是一个特殊命令,并执行相关的命令。

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,
    }
}

但我们平时单纯执行 pnpm dev 也可以触发脚本,这是为什么呢?

我们可以在 script 中注册一个 add 命令,并分别执行 pnpm run addpnpm add 并查看结果:

可以看到 pnpm add 指向的是 pnpm 中默认的 add 命令而非 script 中注册的 add。

那么可以得到以下结论:

  • 如果注册的命令与 pnpm 中默认的命令没有重复,则执行 pnpm <command> 时,会默认执行 script 中注册的命令(如果命中)。
  • 如果有重复,优先执行 pnpm 中默认的命令。

pnpm 更新

pnpm 的版本更新分为两种:自更新、检查更新

自更新

在 main.ts 中,会通过判断命令中是否为 add、update,且是否在参数中含有 pnpm 来决定是否进行自更新。此时如果判断需要执行,那么会先通过 pnpmCmds.server(config as any, ['stop']) 来关闭可能正在执行的 pnpm 命令,防止出现冲突或使用问题。

  // e.g. pnpm add pnpm
  const selfUpdate = config.global && (cmd === 'add' || cmd === 'update') && cliParams.includes(packageManager.name)

  if (selfUpdate) {
    await pnpmCmds.server(config as any, ['stop']) // eslint-disable-line @typescript-eslint/no-explicit-any
    const currentPnpmDir = path.dirname(which.sync('pnpm'))
    if (path.relative(currentPnpmDir, config.bin) !== '') {
        console.log(`The location of the currently running pnpm differs from the location where pnpm will be installed
            Current pnpm location: ${currentPnpmDir}
            Target location: ${config.bin}
        `)
    }
  }

检查更新

pnpm 还会在一定判断条件下执行 checkForUpdates,来检测是否需要更新。

if (
  config.updateNotifier !== false &&
  !isCI &&
  !selfUpdate &&
  !config.offline &&
  !config.preferOffline &&
  !config.fallbackCommandUsed &&
  (cmd === 'install' || cmd === 'add')
) {
  checkForUpdates(config).catch(() => { /* Ignore */ })
}

可以看到为了防止和 selfUpdate 冲突导致进行无效检测,这边加了一个 !selfUpdate 的判断。接下来我们看看

checkForUpdates 的内容吧:

export async function checkForUpdates(config: Config) {
  // 加载状态文件,保存更新检查的历史信息
  const stateFile = path.join(config.stateDir, 'pnpm-state.json');
  let state = await loadJsonFile(stateFile);

  // 检查是否已经在最近一次检查之后的 UPDATE_CHECK_FREQUENCY(1 day) 内进行过更新检查
  if (
    state?.lastUpdateCheck &&
    (Date.now() - new Date(state.lastUpdateCheck).valueOf()) < UPDATE_CHECK_FREQUENCY
  ) return;  // 如果是,则跳过此次检查

  // 创建一个解析器,用于从注册表解析包信息
  const resolve = createResolver({
    ...config,
    authConfig: config.rawConfig,
    retry: {
      retries: 0, // 不进行重试
    },
  });

  // 使用解析器获取pnpm最新版本信息
  const resolution = await resolve({ alias: packageManager.name, pref: 'latest' }, {
    lockfileDir: config.lockfileDir ?? config.dir,
    preferredVersions: {},
    projectDir: config.dir,
    registry: pickRegistryForPackage(config.registries, packageManager.name, 'latest'),
  });

  // 如果解析成功且获取到了最新的包版本,记录当前和最新版本信息,进行更新提示
  if (resolution?.manifest?.version) {
    updateCheckLogger.debug({
      currentVersion: packageManager.version,
      latestVersion: resolution?.manifest.version,
    });
  }

  // 更新状态文件,记录此次更新检查的时间
}

可以看到函数仅仅是做了状态更新的检查并进行提示,并没有直接进行更新操作。

过滤操作

在 pnpm 中,filter 是一种非常重要的功能,用于指定对哪些项目或包应用命令。在 monorepo 中的用户来说尤为有用,因为它允许用户精确控制每个命令的作用范围。

# e.g.
pnpm --filter "{.}" add eslint@latest -D

在了解 filter 操作前,我们先了解一下工作区(workspace)的定义: 工作区是一个包含多个包的容器,这些包可以共享依赖、配置和任务。开发者可以同时在多个相关的项目上工作,而不需要每次都重新配置每个项目或手动处理依赖关系。其中,pnpm 通过 pnpm-workspace.yaml 文件来进行工作区的管理。

filter 的操作就是基于 workspace 进行的,我们看看筛选是如何实现的吧:

// 检查是否执行了需要递归处理的命令,并确认是否有指定工作区目录
if (
  (cmd === 'install' || cmd === 'import' || cmd === 'dedupe' || cmd === 'patch-commit' || cmd === 'patch') &&
  typeof workspaceDir === 'string'
) {
  cliOptions['recursive'] = true;
  config.recursive = true;

  // 默认过滤器 '{.}...' 意味着包括当前项目及其所有依赖
  if (!config.recursiveInstall && !config.filter && !config.filterProd) {
      config.filter = ['{.}...'];
  }
}

if (cliOptions['recursive']) {
  // 确定工作区目录,默认为当前目录
  const wsDir = workspaceDir ?? process.cwd();

  // 构建过滤器数组,包括对生产依赖和开发依赖的不同处理
  const filters = [
      ...config.filter.map((filter) => ({ filter, followProdDepsOnly: false })),
      ...config.filterProd.map((filter) => ({ filter, followProdDepsOnly: true })),
  ];

  // 计算相对于当前目录的工作区路径
  const relativeWSDirPath = () => path.relative(process.cwd(), wsDir) || '.';

  // 根据配置决定是否包括工作区的根目录
  if (config.workspaceRoot) {
      filters.push({ filter: `{${relativeWSDirPath()}}`, followProdDepsOnly: Boolean(config.filterProd.length) });
  } else if (!config.includeWorkspaceRoot && (cmd === 'run' || cmd === 'exec' || cmd === 'add' || cmd === 'test')) {
      filters.push({ filter: `!{${relativeWSDirPath()}}`, followProdDepsOnly: Boolean(config.filterProd.length) });
  }

  // 使用过滤器在工作区目录中筛选项目
  const filterResults = await filterPackagesFromDir(wsDir, filters, {
    // ……
  });

  // 更新配置中的项目图谱和选中的项目图谱
  config.allProjectsGraph = filterResults.allProjectsGraph;
  config.selectedProjectsGraph = filterResults.selectedProjectsGraph;

  // 更新配置中的所有项目列表和工作区目录
  config.allProjects = filterResults.allProjects;
  config.workspaceDir = wsDir;
}

可以看到,filters 中记录了不同的筛选匹配规则,最终得到筛选结果 filterResults,提取出所有需要处理的项目。

执行命令

接下来我们看看 pnpmCmds 中的具体实现吧!直接上源码:

// 命令定义接口,用于注册和处理命令
export interface CommandDefinition {
  handler: Command; // 命令的主逻辑处理函数
  help: () => string; // 返回命令的帮助文本
  commandNames: string[]; // 触发此命令的命令名列表
  completion?: CompletionFunc; // 自动完成函数,如果有的话
  // ……
};

// 所有命令的集合
const commands: CommandDefinition[] = [
  add, audit, bin, ci, config, dedupe, getCommand, setCommand, create, deploy, dlx,
  doctor, env, exec, fetch, importCommand, init, install, installTest, link, list,
  ll, licenses, outdated, pack, patch, patchCommit, prune, publish, rebuild, recursive,
  remove, restart, root, run, server, setup, store, test, unlink, update, why,
];

// 注册命令处理函数和相关信息
const handlerByCommandName: Record<string, Command> = {};

// 通过遍历命令定义数组,填充命令相关信息
for (const cmdDef of commands) {
  const { commandNames, handler, } = cmdDef;
  for (const name of commandNames) {
    handlerByCommandName[name] = handler;
  }
}

// 创建"帮助"和"自动完成"功能
handlerByCommandName.help = createHelp(helpByCommandName);
handlerByCommandName.completion = createCompletion({xxx});

// 导出命令处理函数集合和其他功能
export const pnpmCmds = handlerByCommandName;

可以看到 pnpmCmds 是一个命令执行对象,其中存放了 commands 中相关命令的 handler 函数,在 main.ts 中调用:

let result = pnpmCmds[cmd ?? 'help'](
  config
  cliParams
)

这一步再往后,会因为执行命令的不同而触发各自的 handler 函数,我们可以将所有的命令进行分类,然后在每个类中挑选几个重要的进行解析。首先我们基于 command 内容进行分类:

  • 包安装与管理

    • add: 添加新的包依赖。
    • install (alias i): 安装所有依赖。
    • ci: 类似 npm ci,用于持续集成环境中快速且可靠地安装依赖。
    • update (alias up): 更新依赖包。
    • unlink: 解除包链接。
    • link: 链接本地包。
    • prune: 清除未列在包依赖中的包。
    • remove (alias rm, uninstall, r): 移除依赖包。
  • 工作区和多包管理

    • recursive: 递归执行命令。
    • exec: 在每个包中执行任意命令。
    • run: 在包中运行定义在 package.json 的脚本命令。
  • 包信息查询与分析

    • list (alias ls): 列出已安装的包。
    • outdated: 检查过时的包。
    • why: 解释为什么包被安装。
    • licenses: 列出项目依赖的许可信息。
  • 配置与环境管理

    • config: 管理 pnpm 配置。
    • getCommand: 获取配置的值。
    • setCommand: 设置配置的值。
    • env: 管理环境变量。
  • 发布与版本管理

    • publish: 发布包到注册中心。
    • pack: 打包成 tarball。
  • 特殊用途命令

    • audit: 审计依赖以检测安全漏洞。
    • bin: 显示二进制文件的安装位置。
    • doctor: 检查 pnpm 配置和依赖健康状况。
    • fetch: 预下载依赖而不安装。
    • importCommand: 从 npm 或 yarn 的 lock 文件导入生成 pnpm-lock.yaml
    • init: 创建一个新的 package.json 文件。
    • rebuild: 重建依赖。
    • server: 管理一个或多个 pnpm 服务器。
    • setup: 设置 pnpm 的环境。
  • 开发辅助命令

    • create: 快速启动新项目的生成器。
    • dlx: 在没有全局安装的情况下临时运行命令。
    • patch: 创建和应用补丁。
    • patchCommit: 提交补丁。
  • .pnpm-store 管理命令

    • store: 管理 .pnpm-store(存储所有 pnpm 包的地方)。
  • 维护与其他命令

    • dedupe: 精简冗余包。
    • deploy: 部署命令,可能特定于某些系统。

具体命令分析

在了解具体命令实现之前,我们可以先了解一个命令的结构设计。可以发现,命令的类型已经被定义好了:

export interface CommandDefinition {
  handler: Command
  help: () => string
  commandNames: string[]
  cliOptionsTypes: () => Record<string, unknown>
  rcOptionsTypes: () => Record<string, unknown>
  completion?: CompletionFunc
  shorthands?: Record<string, string | string[]>
}

这个接口定义了 pnpm 命令的核心结构和元数据,我们来看看每个属性的具体作用:

  • handler:命令的主逻辑处理函数,当命令被调用时执行。
  • help:提供命令的帮助文本,描述命令的用途、用法和可用选项,当执行帮助命令时被调用(pnpm help <command>)。

  • commandNames:存放命令标识符,可以在命令行中输入来调用。第一个名称通常是主命令名,其他的可能是简写或别名。
  • cliOptionsTypes:返回一个对象,键是此命令接受的命令行接口(CLI)选项,值是这些选项的值的类型,用于验证用户输入的选项值是否符合规范。
export function cliOptionsTypes() {
  return {
    'save-dev': Boolean,        // --save-dev 选项,类型为布尔值
    'fetch-retries': Number,    // --fetch-retries 选项,类型为数字
    'custom-option': String     // --custom-option 选项,类型为字符串
  };
}
// Boolean 表示选项后面不需要跟任何值,例如 --save-dev,默认为 true。
// Number 表示该选项后需要跟一个数字,如 --fetch-retries 3。
// String 表示该选项后需要跟一个字符串,如 --custom-option "value"。
  • rcOptionsTypes:与 cliOptionsTypes 同样的数据结构和作用,最后会被传入 cliOptionsTypes

用于定义 .npmrc.pnpmrc 配置文件中可以设置的选项的类型。

  • 区别:cliOptionsTypes 主要影响单次命令行会话,而 rcOptionsTypes 影响所有命令的执行环境和行为。两者虽然可能涉及同样的设置项,但应用层面和影响范围有所不同。
  • completion:可选,用户输入命令时监听 Tab 键,提供自动补全。

  • shorthands:可选,定义命令选项的简写形式,例如,-D 可以代表 --save-dev。可以发现 shorthands 与 help 中的 shortALias 存在一定耦合,这个问题可以在后面仔细探究。

我们在了解一个具体命令实现的时候,可以通过观察 helper 了解具体的使用方式,通过观察 handler 了解具体的执行逻辑,为控制篇幅,下面仅介绍一下 pnpm 的一个核心内容:包的安装与更新。

触发执行与执行类型

这个操作涉及到 pnpm add/update/install 等命令,由于核心的执行函数是一致的,我们放在一起去讲述。

以 pnpm add 开头,该命令主要用于将新的包添加到项目中,会自动更新 package.json 文件,将新包添加到依赖列表中,并且安装该包及其依赖。

// 定义`add`命令的处理逻辑
export async function handler(opts: AddCommandOptions, params: string[]) {
  // 相关检查 ……
  // 设置包的依赖类型包含哪些

  // 执行安装依赖的函数
  return installDeps({
    ...opts, // 传入命令选项
    include, // 指定依赖类型
    includeDirect: include, // 直接包含的依赖类型
  }, params);
}

installDeps 就是这几个命令共同的核心逻辑,我们可以来看看 installDeps 的实现,这里的实现逻辑相方复杂,我们可以先通过结构图来了解函数所执行的功能:

image.png

根据三个判断进行拆分,可以得到四个处理部分,我们分别来进行介绍:

第一部分:当前在工作区且存在内部依赖关系

// 如果在工作区目录中,从所有工作区项目中选择当前目录下的项目。
if (opts.workspaceDir) {
  const selectedProjectsGraph = opts.selectedProjectsGraph ?? selectProjectByDir(allProjects, opts.dir);
  // 如果存在选定的项目图,继续后续处理。
  if (selectedProjectsGraph != null) {
    // 对项目图进行排序,检查是否存在循环依赖,如果发现循环依赖并且没有忽略它们,记录警告信息。
    // 如果启用了dedupe配置,创建所有项目的图表,避免不必要的重复和潜在的版本冲突。

    // 进行递归处理,执行安装、更新或添加依赖。
    await recursive(allProjects, params, { ...opts, xxx },
      // 根据是否为更新操作,选择执行更新还是安装/添加依赖。
      opts.update ? 'update' : (params.length === 0 ? 'install' : 'add')
    );
    // 完成后返回,不再执行后续处理逻辑。
    return;
  }
}

function selectProjectByDir (projects: Project[], searchedDir: string) {
  const project = projects.find(({ dir }) => path.relative(dir, searchedDir) === '')
  if (project == null) return undefined
  return { [searchedDir]: { dependencies: [], package: project } }
}

selectProjectByDir 会分析工作区中项目的依赖关系,返回一个项目依赖图,如果存在项目内依赖,则会递归执行安装、更新或添加依赖,然后直接结束流程。

第二部分:没有内部依赖关系(monorepo 或单项目),指定了 params 依赖

// 读取项目清单(即package.json),如果不存在则创建空的清单。
let { manifest, writeProjectManifest } = await tryReadProjectManifest(opts.dir, opts);

// 创建或连接到 .pnpm-store,用于处理依赖的存储。
const store = await createOrConnectStoreController(opts);
// 设置安装选项,这些选项将传递给安装过程。
const installOpts = {
  ...opts,
  ...getOptionsFromRootManifest(manifest),
  // ……
};

let updateMatch = null;
// 如果是更新操作,则设置更新匹配器,用于确定哪些依赖项需要更新。
if (opts.update) updateMatch = params.length ? createMatcher(params) : null;
// 处理 params……

// 如果指定了依赖项,指定项目安装
if (params?.length) {
  // 构建将要修改的项目对象
  const mutatedProject = {
    dependencySelectors: params,         // 指定的依赖项选择器
    manifest,                            // 当前项目的 package.json
    rootDir: opts.dir,                   // 项目的根目录
    // ……
  }
  // 执行单个项目的依赖变更
  const updatedImporter = await mutateModulesInSingleProject(mutatedProject, installOpts)
  // 更新项目的package.json
  await writeProjectManifest(updatedImporter.manifest)
  return
}

可以看到,在第二部分执行之前,有创建或连接 .pnpm-store 的行为,可以优化 pnpm 依赖的安装速度。

第二部分会判断在命令传入的 params 中是否有指定依赖,如:

pnpm install lodash@latest

如果 params 有值,就会执行 mutateModulesInSingleProject,更新指定依赖,并直接结束流程。

第三部分:没有内部依赖关系(monorepo 或单项目),没有指定 params 依赖

const updatedManifest = await install(manifest, installOpts)
// 如果是更新操作,更新项目的package.json
if (opts.update === true) {
  await writeProjectManifest(updatedManifest)
}

如果 params 没有值,那么就会执行默认的全项目安装。

第四部分:配置了在工作区环境中链接包(linkWorkspacePackages),并且指定了工作区目录

// 如果配置了在工作区环境中链接包,并且指定了工作区目录
if (opts.linkWorkspacePackages && opts.workspaceDir) {
  // 过滤出需要处理的工作区项目图
  const { selectedProjectsGraph } = await filterPkgsBySelectorObjects(allProjects, [{ xxx }], {
    workspaceDir: opts.workspaceDir,
  })
  // 在工作区中递归地安装依赖
  await recursive(allProjects, [], {
    ...opts,
    ...OVERWRITE_UPDATE_OPTIONS,
    allProjectsGraph: opts.allProjectsGraph!,
    selectedProjectsGraph,
    workspaceDir: opts.workspaceDir,
  }, 'install')

  // 如果设置忽略脚本,直接返回
  if (opts.ignoreScripts) return

  // 重建工作区项目
  await rebuildProjects(xxx)
}

可以看到 1、4 两个部分逻辑十分相似,这边就需要研究一下 linkWorkspacePackages 和 workspcae 配置项目依赖的关系了。

文档链接:www.pnpm.cn/npmrc#link-…

可以看到 linkWorkspacePackages 的配置功能如下:

  • true(默认) :如果在同一工作区中存在包间的依赖关系,这些依赖会通过创建符号链接(symlinks)直接链接到依赖的本地包,而不是从外部 npm 注册表下载。

  • deep:不仅顶层的直接依赖会链接到工作区中的其他包,所有依赖(包括深层依赖)也会尽可能链接到工作区中的包。

  • false:禁用了工作区包的自动链接功能。即使包在工作区中可用,也会从 npm 注册表下载这些包。

可以看到当允许工作区项目间创建符号链接时,会触发安装。我们再看第一、四部分在 recursive 中的差异。第一部分传入的方法可以是 install、add、update,第四部分只能传入 install,由此可以总结:

触发到第四部分的条件为:

  1. 在工作区且项目之间没有互相依赖。
  2. linkWorkspacePackages 为 true/deep。
  3. 执行的命令为 pnpm install

那么这一部份会逐个安装每个 importer(子项目),确保项目的正常运行。

了解清楚了 installDeps 的执行逻辑,我们再来整理一个基于四个执行部分的流程图:

image.png

具体执行逻辑

上一部分的核心逻辑还是依据执行的环境选择执行的方式,具体如何实现还需要关注相关的函数,可以看到有:recursivemutateModulesInSingleProject,以及 install,通过观察三个函数的代码,发现都存在一个核心函数 mutateModules,此外都是一些参数的处理操作,因此我们可以直接来研究 mutateModules 的实现。

export async function mutateModules(projects: MutatedProject[], maybeOpts: MutateModulesOptions): Promise<UpdatedProject[]> {
  // 扩展和验证选项
  const opts = await extendOptions(maybeOpts);
  // 验证是否只处理依赖安装,不进行更新或匹配
  const installsOnly = projects.every((project) => project.mutation === 'install' && !project.update && !project.updateMatching);
  opts['forceNewModules'] = installsOnly;

  // 获取上下文
  const ctx = await getContext(opts);
  // 执行预安装 hooks
  if (opts.hooks.preResolution) {
    await opts.hooks.preResolution({ xxx });
  }

  // 执行安装逻辑,返回结果
  return await _install();

  // 内部函数定义安装过程
  async function _install(): Promise<UpdatedProject[]> {
    // 一系列处理……
    // 处理需要更新或安装的项目
    const projectsToInstall = [] as ImporterToUpdate[]
    for (const project of projects) {
      switch (project.mutation) {
        case 'uninstallSome':
          projectsToInstall.push({ xxx })
          break
        case 'install': {
          await installCase({ xxx })
          break
        }
        case 'installSome': {
          await installSome({ xxx })
          break
        }
        case 'unlink': {
          await installCase({ xxx })
          break
        }
        case 'unlinkSome': {
          await installSome({ xxx })
          break
        }
      }
    }

    // 完整安装所有列出的依赖
    async function installCase(project: any) { }
    // 根据项目配置执行实际的安装过程
    async function installSome(project: any) { }

    const result = await installInContext(projectsToInstall, ctx, { xxx })
    return result.projects
  }
}

const installInContext: InstallFunction = async (projects, ctx, opts) => {
  // 初始化导入者的结构
  // 处理 uninstallSome 变更
  await Promise.all(projects.map(async (project) => {
    if (project.mutation !== 'uninstallSome') return
    const _removeDeps = async (manifest: ProjectManifest) => removeDeps(manifest, project.dependencyNames, { prefix: project.rootDir, saveType: project.targetDependenciesField })
    project.manifest = await _removeDeps(project.manifest)
    if (project.originalManifest != null) {
      project.originalManifest = await _removeDeps(project.originalManifest)
    }
  })
  )

  // 解析相关的依赖树
  let { dependenciesGraph, dependenciesByProjectId, xxx } = await resolveDependencies(xxx)

  if (!opts.lockfileOnly && !isInstallationOnlyForLockfileCheck && opts.enableModulesDir) {
    // 处理依赖包,包括处理依赖图和链接物理文件、删除/新增包
    const result = await linkPackages(xxx)
    // 更新锁文件。
    await finishLockfileUpdates()
    // 链接二进制文件,并处理相关的钩子。
  }

  // 对每个项目进行后续处理,更新项目的锁文件和模块清单文件
  await Promise.all([ xxx ]);
  // 完成所有更新后,关闭存储控制器并提交锁文件变更。
  await opts.storeController.close();
  // 提供 peerDependencies 的建议
  reportPeerDependencyIssues(xxx)
  return { xxx }
}

尽管经过大量压缩,还是有很多的代码量。

我们可以看到在 projects 的遍历中,switch 语句根据不同的 project.mutation 值,决定了对于每个项目应该采取的操作,其中每个 case 都对应了一种特定的操作模式:

  1. uninstallSome

    • 功能:移除指定的依赖包。
    • 操作:从项目的依赖清单中移除指定的依赖名称(project.dependencyNames)。
  2. install

    • 功能:安装项目的所有依赖。
    • 操作:调用 installCase 函数,处理安装操作。
  3. installSome

    • 功能:安装或更新项目的特定依赖。
    • 操作:调用 installSome 函数,指定需要处理的依赖项。这通常是用于添加新依赖或更新现有依赖。
  4. unlink

    • 功能:解除链接已安装的包。

    • 操作:

      • 首先读取项目的 modules 目录来确定哪些包是外部链接。
      • 通过 pFilter 函数确定哪些包实际上是从外部链接的(而非本地安装的)。
      • modules 目录中移除这些外部链接的包。
      • 如果包在项目的依赖定义中,将这些包加入重新安装列表。
  5. unlinkSome

    • 功能:解除特定依赖包的链接。

    • 操作:

      • 对于每个指定的依赖名称,检查它是否为外部链接。
      • 如果是外部链接,则从 modules 目录中移除。
      • 不会自动在 package.json 中更新版本规范,而是重新安装这些包。

这样的设计保证了不同的执行命令调用函数时,触发各种各样的结果,我们拿 pnpm add jest -w 举例,下面是执行的流程:

image.png

其中还有很多细节值得研究,如 .pnpm-store 与项目的交互、pnpm 的生命周期钩子、软硬链接的具体实现等,后续可以作为独立模块进行分享。

最后

相较于其他开源库,在阅读 pnpm 源码的过程中,明显能感觉到学习难度涨幅很大,一开始 main 的结构十分清晰简单,当深入到 installDeps 时,整体的代码量开始越来越夸张,且具有一定跳跃性,需要结合 debugger 和 gpt,以及大量的时间进行分析,逐渐锁定核心的执行过程,如此反复……好不容易整理清楚全流程后,却发现这仅仅是 pnpm 众多执行命令中的冰山一角😰。

然而源码的阅读就是如此,想要成为 pnpm 的专家,必然付出大量的时间进行研究。

最后总结一下,这篇文章介绍了 pnpm 的架构设计和执行逻辑,涉及到具体的命令仅有包的安装与更新这一块,后续的更新会更专注于细节问题的实现和分析上,如果本文有任何问题,欢迎评论指出,感谢!