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

分享背景

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

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

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

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

源码解读

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

处理命令

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

js 复制代码
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 是一个特殊命令,并执行相关的命令。

js 复制代码
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 命令,防止出现冲突或使用问题。

js 复制代码
  // 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,来检测是否需要更新。

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

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

checkForUpdates 的内容吧:

js 复制代码
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 中的用户来说尤为有用,因为它允许用户精确控制每个命令的作用范围。

shell 复制代码
# e.g.
pnpm --filter "{.}" add eslint@latest -D

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

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

js 复制代码
// 检查是否执行了需要递归处理的命令,并确认是否有指定工作区目录
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 中的具体实现吧!直接上源码:

js 复制代码
// 命令定义接口,用于注册和处理命令
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 中调用:

js 复制代码
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: 部署命令,可能特定于某些系统。

具体命令分析

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

js 复制代码
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)选项,值是这些选项的值的类型,用于验证用户输入的选项值是否符合规范。
js 复制代码
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 文件,将新包添加到依赖列表中,并且安装该包及其依赖。

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

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

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

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

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

js 复制代码
// 如果在工作区目录中,从所有工作区项目中选择当前目录下的项目。
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 依赖

js 复制代码
// 读取项目清单(即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 中是否有指定依赖,如:

shell 复制代码
pnpm install lodash@latest

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

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

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

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

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

js 复制代码
// 如果配置了在工作区环境中链接包,并且指定了工作区目录
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 的执行逻辑,我们再来整理一个基于四个执行部分的流程图:

具体执行逻辑

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

js 复制代码
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 举例,下面是执行的流程:

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

最后

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

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

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

相关推荐
hackeroink39 分钟前
【2024版】最新推荐好用的XSS漏洞扫描利用工具_xss扫描工具
前端·xss
迷雾漫步者2 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-3 小时前
验证码机制
前端·后端
燃先生._.4 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖5 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235245 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240256 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar6 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人6 小时前
前端知识补充—CSS
前端·css