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在自己的工具链中构建工作区依赖图,并理解unmatched、linkWorkspacePackages、ignoreDevDeps等关键行为背后的设计意图。
一、这个包解决什么问题
在 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:提供parseBareSpecifier与workspacePrefToNpm等规格解析工具;@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类型),至少应包含name与version,以及可选的dependencies、devDependencies、optionalDependencies、peerDependencies。
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@1与foo@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':当bar的foo依赖只写在devDependencies中时,开启该选项后bar的dependencies变为[](见 test/index.ts)。
4.3 对每条依赖逐一解析
对每个[depName, rawSpec]条目,解析流程如下:
- 判断是否为 workspace 协议:若
rawSpec.startsWith('workspace:'),则先通过workspacePrefToNpm将其转换为普通 npm 规格。若转换后是相对路径形式(workspace:../foo、workspace:./foo),则降级为目录依赖处理; - 解析 bare specifier:对
workspace:foo@*这类别名语法,用parseBareSpecifier拆出真实包名foo与范围*; - npa 解析:调用
npa.resolve(depName, rawSpec, project.rootDir)得到规格对象spec,解析失败(如格式怪异)则跳过该依赖; - 按类型分流:
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类型(如../foo、file:../foo@2、workspace:./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'分别验证了../foo、file:../foo@2、workspace:./nested-foo三种写法的解析结果。
4.5 按名称 + 版本范围匹配
对于 version/range 类型的依赖:
- 在
projectMapByManifestName[depName]中找出同名候选项目,收集它们的version列表; linkWorkspacePackages === false的严格模式:若显式传入false(注意代码注释:向后兼容,undefined不算),且依赖不是workspace 协议写法,则该依赖一律视为“不链接工作区包”,记入unmatched并跳过(见 src/index.ts);- workspace 协议 + 无版本项目:当候选项目都没有
version字段(例如仅声明name)且规格为 workspace 协议时,直接取同名项目。测试'successfully create a package graph even when a workspace package has no version'正是针对此场景(对应 issue #3933 的修复); - 精确版本命中:若
versions中包含rawSpec,则找到同名同版本的项目; - 范围匹配:否则调用
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@1的workspace:*、bar@3的workspace:~1.0.0、bar@4的workspace:^、bar@5的workspace:~仍全部链接到FOO1_PATH。
5.2 ignoreDevDeps(忽略开发依赖)
当为true时,devDependencies不再参与建图。这通常用于“生产依赖闭包”的计算场景——例如发布或生产安装时不需要 dev 依赖产生的内部边。
六、在 pnpm 实际流程中的调用链
createProjectsGraph并非孤立工具,它在 pnpm 的多条核心路径上被调用:
- 安装流程:installDeps.ts 在安装时通过
createProjectsGraph(allProjects, { linkWorkspacePackages: Boolean(opts.linkWorkspacePackages) }).graph构建全量工作区图,作为allProjectsGraph传给后续流程——这也解释了linkWorkspacePackages选项与 pnpm 配置项link-workspace-packages的直接对应关系; - 过滤流程:projects-filter/src/index.ts 导入
createProjectsGraph,在--filter选择器解析、变更项目检测中基于全量图计算“被选中项目之间的依赖关系”,从而支持pnpm --filter ... run时按依赖顺序执行; - 发布审批:approvalOrder.ts 在发布流程中依据该图确定审批顺序。
配合测试目录 test/index.ts 中的十余个用例,可以完整覆盖:基本建图、peer 依赖、目录依赖、workspace 协议(含./前缀、别名workspace:foo@*、workspace:^/workspace:~)、linkWorkspacePackages=false、ignoreDevDeps=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),仅供参考