news 2026/9/21 15:30:45

pnpm 工作区项目图构建器 @pnpm/workspace.projects-graph 深度解析:从包清单到依赖图

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
pnpm 工作区项目图构建器 @pnpm/workspace.projects-graph 深度解析:从包清单到依赖图

pnpm 工作区项目图构建器 @pnpm/workspace.projects-graph 深度解析:从包清单到依赖图

【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm

@pnpm/workspace.projects-graph是 pnpm 工作区(workspace)体系中负责把一组包(packages)的清单(manifest)转化为依赖关系图的核心工具包。它在 pnpm 的安装、过滤、拓扑排序、发布审批等流程中被广泛使用,本文将以 pnpm11/workspace/projects-graph/README.md 为主线,结合仓库源码与测试用例,完整讲解其安装方式、API 用法、内部实现原理及在 pnpm 实际流程中的调用链。读完本文,你将掌握如何用createProjectsGraph在自己的工具链中构建工作区依赖图,并理解unmatchedlinkWorkspacePackagesignoreDevDeps等关键行为背后的设计意图。

一、这个包解决什么问题

在 pnpm 的 monorepo 工作区中,pnpm-workspace.yaml声明了哪些目录属于工作区成员,每个成员都有自己的package.json(即 manifest)。为了执行安装、--filter递归过滤、按依赖顺序运行脚本等操作,pnpm 需要回答一个核心问题:这些包之间谁依赖谁

@pnpm/workspace.projects-graph的职责正是把“一个包数组”变成“一张以目录为节点的有向图”。它位于 pnpm11 的 workspace 模块之下,与 projects-reader(负责读取工作区项目)、projects-sorter(负责拓扑排序)、projects-filter(负责按选择器过滤)协同工作,构成工作区元数据处理的完整链路。

二、安装

在任意使用 pnpm 作为包管理器的项目中,直接添加该依赖:

pnpm add @pnpm/workspace.projects-graph

从该包的 package.json 可以看到,它是一个"type": "module"的 ESM 包,入口为lib/index.js,对 Node.js 的要求是>=22.13。其运行时依赖包括:

  • @pnpm/npm-package-arg:解析npa(npm package arg)规格;
  • @pnpm/resolving.npm-resolver:提供parseBareSpecifierworkspacePrefToNpm等规格解析工具;
  • @pnpm/workspace.range-resolver:负责把 semver 范围与工作区可用版本做匹配;
  • @pnpm/types:类型定义;
  • ramda:提供map等函数式工具。

三、基本用法

README 中的核心用法如下(原示例中函数名为createPkgsGraph,仓库源码中实际导出名为createProjectsGraph,下文以源码实现为准):

import createPkgsGraph from 'pkgs-graph' const { graph } = createPkgsGraph([ { dir: '/home/zkochan/src/foo', manifest: { name: 'foo', version: '1.0.0', dependencies: { bar: '^1.0.0', }, }, }, { dir: '/home/zkochan/src/bar', manifest: { name: 'bar', version: '1.1.0', }, } ]) console.log(graph) //> { // '/home/zkochan/src/foo': { // dependencies: ['/home/zkochan/src/bar'], // manifest: { // name: 'foo', // version: '1.0.0', // dependencies: { // bar: '^1.0.0', // }, // }, // }, // '/home/zkochan/src/bar': { // dependencies: [], // manifest: { // name: 'bar', // version: '1.1.0', // }, // }, // }

从源码 src/index.ts 来看,实际的 API 签名是:

export interface BaseProject { manifest: BaseManifest rootDir: ProjectRootDir } export interface ProjectGraphNode<Pkg extends BaseProject> { package: Pkg dependencies: ProjectRootDir[] } export function createProjectsGraph<Pkg extends BaseProject> ( projects: Pkg[], opts?: { ignoreDevDeps?: boolean linkWorkspacePackages?: boolean } ): { graph: Record<ProjectRootDir, ProjectGraphNode<Pkg>> unmatched: Array<{ pkgName: string, range: string }> }

3.1 输入:包数组

每个输入项是一个BaseProject,包含两个字段:

  • rootDir:项目根目录的绝对路径,作为图中节点的唯一标识(README 示例中的dir即对应源码中的rootDir);
  • manifest:该项目的package.json内容(BaseManifest类型),至少应包含nameversion,以及可选的dependenciesdevDependenciesoptionalDependenciespeerDependencies

3.2 输出:graph 与 unmatched

函数返回一个对象,包含两部分:

  • graph:以ProjectRootDir为键、ProjectGraphNode为值的映射。每个节点的package保留原始项目对象,dependencies是解析出的工作区内部依赖目录列表;
  • unmatched:本次解析中未能匹配到任何工作区成员的依赖清单,每项形如{ pkgName: 'bar', range: '^10.0.0' }。未匹配的依赖说明它需要从 registry 安装,这正是 pnpm 区分“链接本地包”与“下载外部包”的依据。

四、内部实现:依赖边是如何算出来的

createProjectsGraph的实现分为三部分:建索引、遍历依赖、输出结果。

4.1 建立索引

在 src/index.ts 中,首先通过createProjectMap把项目数组转为Record<ProjectRootDir, BaseProject>;随后按需惰性创建两个辅助索引(仅当遇到相应类型的依赖时才构建):

  • getProjectMapByManifestName:以 manifest 的name为键,指向同名项目列表(因为工作区中允许存在同名不同版本的项目,例如foo@1foo@2);
  • getProjectMapByDir:以path.resolve(rootDir)为键的目录索引,用于快速定位目录依赖。

4.2 合并四类依赖

createNode函数按如下顺序合并依赖对象(src/index.ts):

const dependencies = { ...project.manifest.peerDependencies, ...(!opts?.ignoreDevDeps && project.manifest.devDependencies), ...project.manifest.optionalDependencies, ...project.manifest.dependencies, }

这里有两个关键点:

  • 四类依赖全被纳入图:peer、dev(默认)、optional、普通 dependencies 都会被当作工作区内部的潜在依赖边;
  • ignoreDevDeps: true时 devDependencies 被排除。这对应测试'create package graph respects ignoreDevDeps = true':当barfoo依赖只写在devDependencies中时,开启该选项后bardependencies变为[](见 test/index.ts)。

4.3 对每条依赖逐一解析

对每个[depName, rawSpec]条目,解析流程如下:

  1. 判断是否为 workspace 协议:若rawSpec.startsWith('workspace:'),则先通过workspacePrefToNpm将其转换为普通 npm 规格。若转换后是相对路径形式(workspace:../fooworkspace:./foo),则降级为目录依赖处理;
  2. 解析 bare specifier:对workspace:foo@*这类别名语法,用parseBareSpecifier拆出真实包名foo与范围*
  3. npa 解析:调用npa.resolve(depName, rawSpec, project.rootDir)得到规格对象spec,解析失败(如格式怪异)则跳过该依赖;
  4. 按类型分流
    • spec.type === 'directory':走目录解析路径(见下文 4.4);
    • spec.type === 'version' | 'range':走按包名 + 版本匹配路径(见下文 4.5);
    • 其他类型(git、tag、file 等)不构成工作区内部边,直接返回空。

测试中有一项专门验证“怪异依赖被跳过”:'weird-dep': ':aaaaa'不会出现在结果边中(见'create package graph for local directory dependencies'测试)。

4.4 目录依赖的解析

当规格是directory类型(如../foofile:../foo@2workspace:./nested-foo)时:

  • project.rootDir为基准,用path.resolve计算出绝对路径resolvedPath,在projectMapByDir中精确查找;
  • 若未命中,再走“慢路径”——在全部项目中做一次path.relative比较,用于兼容大小写不敏感文件系统上的大小写不一致情况;
  • 命中则返回对应项目的rootDir作为依赖边;未命中则返回空。

测试'create package graph for local directory dependencies''create package graph for local directory dependencies using the workspace protocol with a ./ prefix'分别验证了../foofile:../foo@2workspace:./nested-foo三种写法的解析结果。

4.5 按名称 + 版本范围匹配

对于 version/range 类型的依赖:

  1. projectMapByManifestName[depName]中找出同名候选项目,收集它们的version列表;
  2. linkWorkspacePackages === false的严格模式:若显式传入false(注意代码注释:向后兼容,undefined不算),且依赖不是workspace 协议写法,则该依赖一律视为“不链接工作区包”,记入unmatched并跳过(见 src/index.ts);
  3. workspace 协议 + 无版本项目:当候选项目都没有version字段(例如仅声明name)且规格为 workspace 协议时,直接取同名项目。测试'successfully create a package graph even when a workspace package has no version'正是针对此场景(对应 issue #3933 的修复);
  4. 精确版本命中:若versions中包含rawSpec,则找到同名同版本的项目;
  5. 范围匹配:否则调用resolveWorkspaceRange(rawSpec, versions)求最大满足版本;仍不匹配则记入unmatched

范围匹配的实现在 range-resolver/src/index.ts:

export function resolveWorkspaceRange (range: string, versions: string[]): string | null { if (range === '*' || range === '^' || range === '~' || range === '') { return semver.maxSatisfying(versions, '*', { includePrerelease: true, }) } return semver.maxSatisfying(versions, range, { loose: true, }) }

值得注意的是:*^~、空字符串这四种“裸范围”会被统一当作*处理,并且includePrerelease: true——这正是测试'* matches prerelease versions'foo: '*'能匹配到1.0.0-0的原因。

4.6 拓扑排序:下游如何消费这张图

createProjectsGraph只负责建图,排序由 projects-sorter/src/index.ts 中的sequenceGraph完成:它把graph转成Map<ProjectRootDir, ProjectRootDir[]>后交给graphSequencer(来自@pnpm/deps.graph-sequencer),返回确定性的拓扑顺序与环检测结果。这也印证了 projects-graph 在整个工作区流水线中处于“地基”位置。

五、关键选项的行为语义

5.1 linkWorkspacePackages(链接工作区包开关)

该选项控制“是否把工作区成员当作本地可链接依赖”:

  • 不传或传true(默认):凡是能在工作区内找到匹配版本的依赖,都解析为内部边;
  • false:只有显式使用workspace:协议的依赖才被链接,普通 semver 范围(如foo: '1.0.1')一律视为外部依赖,记入unmatched

测试'create package graph respects linked-workspace-packages = false'给出了完整对照:bar@2依赖foo: '1.0.1'(非 workspace 协议),在linkWorkspacePackages: false下其dependencies[],并进入unmatched;而bar@1workspace:*bar@3workspace:~1.0.0bar@4workspace:^bar@5workspace:~仍全部链接到FOO1_PATH

5.2 ignoreDevDeps(忽略开发依赖)

当为true时,devDependencies不再参与建图。这通常用于“生产依赖闭包”的计算场景——例如发布或生产安装时不需要 dev 依赖产生的内部边。

六、在 pnpm 实际流程中的调用链

createProjectsGraph并非孤立工具,它在 pnpm 的多条核心路径上被调用:

  1. 安装流程:installDeps.ts 在安装时通过createProjectsGraph(allProjects, { linkWorkspacePackages: Boolean(opts.linkWorkspacePackages) }).graph构建全量工作区图,作为allProjectsGraph传给后续流程——这也解释了linkWorkspacePackages选项与 pnpm 配置项link-workspace-packages的直接对应关系;
  2. 过滤流程:projects-filter/src/index.ts 导入createProjectsGraph,在--filter选择器解析、变更项目检测中基于全量图计算“被选中项目之间的依赖关系”,从而支持pnpm --filter ... run时按依赖顺序执行;
  3. 发布审批:approvalOrder.ts 在发布流程中依据该图确定审批顺序。

配合测试目录 test/index.ts 中的十余个用例,可以完整覆盖:基本建图、peer 依赖、目录依赖、workspace 协议(含./前缀、别名workspace:foo@*workspace:^/workspace:~)、linkWorkspacePackages=falseignoreDevDeps=true、预发布版本匹配、无版本工作区包等边界场景。

七、总结

@pnpm/workspace.projects-graph以极简的 API(一个函数、两个可选开关)封装了 pnpm 工作区依赖解析的完整逻辑:四类依赖合并、workspace 协议转换、目录依赖定位、semver 范围匹配与未匹配收集。它是 pnpm 安装、过滤、排序、发布等上层能力的“图数据源”,理解它的输入输出与选项语义,是理解 pnpm monorepo 行为的关键一步。若需继续深入,可阅读其依赖的 workspace.range-resolver(版本匹配)、workspace.projects-reader(项目读取)以及消费方 workspace.projects-sorter(拓扑排序)和 workspace.projects-filter(过滤)。

【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/21 15:28:08

Luxon 升级指南:从 1.x / 2.x 迁移到 3.0 的破坏性变更全解析

Luxon 升级指南&#xff1a;从 1.x / 2.x 迁移到 3.0 的破坏性变更全解析 【免费下载链接】luxon ⏱ A library for working with dates and times in JS 项目地址: https://gitcode.com/gh_mirrors/lu/luxon Luxon 是专为 JavaScript 设计的日期与时间处理库&#xff0…

作者头像 李华
网站建设 2026/9/21 15:25:06

AI前端面试核心:TypeScript+流式传输工程实践

1. 这不是鸡汤&#xff0c;是9月AI前端面试现场的真实战报“最后提醒一次&#xff0c;9月的AI前端面试不用太老实”——这句话我上周在三个不同公司的技术终面里都听到了。不是HR说的&#xff0c;是CTO、前端架构师、甚至一位刚从大模型团队轮岗回来的资深工程师&#xff0c;面…

作者头像 李华
网站建设 2026/9/21 15:24:45

PCS层链路故障排查:从Local Fault到Wireshark抓包实战

1. 从一个让人抓狂的链路故障说起机房里最让人头疼的问题&#xff0c;往往不是配置写错&#xff0c;而是链路时通时断、端口反复 up/down&#xff0c;日志里刷出一行Local Fault&#xff0c;然后就没有然后了。你查光模块、换跳线、重启设备&#xff0c;折腾半天&#xff0c;问…

作者头像 李华
网站建设 2026/9/21 15:22:29

递归树方法解析分治算法时间复杂度

1. 习题背景与核心考察点这道算法习题看似简单&#xff0c;却蕴含着算法设计中几个关键思维模式的训练价值。题目要求我们分析特定算法的时间复杂度&#xff0c;但实际考察的是对递归算法、分治策略以及数学归纳法的综合运用能力。在真实的软件开发场景中&#xff0c;这类分析能…

作者头像 李华