pnpm(pacquet)Rust 版 hoisted 布局的多级提升(Multi-Level Hoisting)实现路线解析
【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm
导读
nodeLinker: hoisted是 pnpm 提供的一种类扁平化node_modules安装布局。在 pacquet 将其 hoister 移植到 Rust 的过程中,pnpm/crates/real-hoist已能正确运行@yarnpkg/nm的提升算法,但还缺少最后一个结构性能力:per-importer(按项目)的提升根(hoisting roots),即多级输出。本文基于仓库中的实现计划文档 MULTI_LEVEL_HOISTING.md,结合real-hoist、deps-restorer与config等 crate 的源码,讲清楚当前单层提升的实现边界、多级提升要补齐的差距、具体实现步骤、风险与验证方式。读完你会理解:为什么"嵌套放置"在语义上正确但布局密度有差距,以及如何在 Rust 移植中复刻上游hoistTo的递归提升行为。
背景:从 pnpm 的 hoisted 布局到 Rust 移植
在nodeLinker: hoisted模式下,pnpm 把整个依赖图"拍平"到一个共享的node_modules目录中——所有不冲突的传递依赖都被提升到根目录,只有发生版本冲突的名称才保持嵌套。这个提升算法源自 Yarn Berry 的@yarnpkg/nmhoister。pacquet 用 Rust 重写了它,代码位于 pnpm/crates/real-hoist/src/lib.rs,其模块注释明确说明这是对@yarnpkg/nm提升算法的直接移植:
- 输入侧用
HoisterTree表示依赖树:每个节点携带name(暴露别名)、ident_name(真实包名)、reference(版本/快照键)、peer_names(该节点拒绝越过的 peer 名)以及dependency_kind(Regular/Workspace/ExternalSoftLink三类); - 输出侧用
HoisterResult表示提升后的结果图,并额外记录了hoisted_dependencies(被提升到祖先目录的名称映射)与decoupled标记(共享节点在每条路径上的单亲拷贝状态); - 入口函数
hoist(lockfile, opts)负责把 pnpm lockfile 翻译成以虚拟.为根、每个 workspace importer 为子节点的树,然后调用nm_hoist。
在安装管线的下游,hoisted_dep_graph.rs 的lockfile_to_hoisted_dep_graph调用这个hoist拿到目录形状,再用 walker(walk_deps,位于同文件的walk子模块)把它展开成按绝对目录为键的依赖图——这正是 hoisted 布局与 isolated 布局的关键差异:同一包可以因名称冲突占据多个目录,提升决策是按目录粒度做出的,而不是按 depPath 粒度。
现状盘点:今天已具备且绝不能回退的能力
计划文档首先划出了"红线":多级提升不能破坏以下三项已实现行为。
1. 非根 importer 无条件挂入共享树(v11 parity)
在 hoist() 中,所有非根 workspace importer 被无条件添加为虚拟.根的Workspace子节点,这与 pnpm v11 的hoist()一致(对应上游 issue pnpm/pnpm#12899)。这一点之所以关键,是因为它决定了跨项目版本去重与冲突嵌套:
- 只有把整个 workspace 视为一棵树,hoister 才能做跨项目去重;
- 冲突的版本会嵌套在子树中的原有位置,由 walker
walk_deps在那里物化出来(hoisted_dep_graph.rs 中build_dep_graph对每个 importer 子树递归产出目录层级)。
测试 multi_importer_lockfile_emits_workspace_children 验证了这一点:packages/foo、packages/bar会被编码为packages%2Ffoo@workspace:packages/foo这样的Workspace子节点。而 hoist_workspace_packages_false_keeps_workspace_children 进一步确认:树成员资格不取决于hoist_workspace_packages开关——该开关只控制根目录是否为 workspace 包本身生成 name-link(即 v11 的hoistedWorkspacePackages),如果拿它去卡成员资格,会静默丢掉所有仅属于 importer 的依赖。
2. 提升边界(hoisting borders)已按一层深度生效
HoistOpts中的hoisting_limits字段(类型为HoistingLimits = BTreeMap<String, BTreeSet<String>>,per-locator 的名称黑名单)与 yarn 的hoistingLimits选项一一对应。在hoist_subtree中,under_border标志和ctx.border_names检查共同实现边界语义:名字在边界集合中的节点,其子孙不得越过它继续提升,而边界节点自身不受影响。
3. workspace 包 name-link 是独立形状
hoist_workspace_packages产生的 name-link(v11 的hoistedWorkspacePackages)是已经实现的独立能力,不依赖多级提升这项工作的落地。
差距:多级提升到底多做了什么
既然边界已经生效,那"多级"补的是什么?关键在于边界内部的行为差异:
在一个被边界圈住的子树里(例如
hoistingLimits: 'workspaces'下的 workspace importer),上游仍然会内部提升:importer 的传递依赖会被扁平化到 importer 自己的node_modules,而不是停留在它们自然的树深位置。而 pacquet 目前让它们保持嵌套。
换句话说,上游的hoistTo会把每个标记为hoist root的节点(hoistingLimits: 'workspaces'下的 workspace、'dependencies'下的直接依赖)都当作一次新的提升起点,跑一遍定点提升,从而生成一棵"每个被圈住的子树都各自扁平化"的多层树;而 pacquet 现在只对唯一的虚拟.根执行hoist_into_root。
需要强调一个关键事实:嵌套放置是解析正确的。Node 的模块解析会向上层目录查找,所以嵌套的依赖仍然能被正确找到。两者的差异纯粹是布局密度和去重密度,而不是可解析性——这正是该差距到目前为止可以带着发布的根本原因。测试 version_conflict_keeps_loser_at_parent 展示了当前行为的一个侧面:冲突失败方(如b@2.0.0)保留在父节点c之下,而c自己的冲突子d@2.0.0仍被提升到c这一层——即"以当前根为起点的内部提升"其实已经部分存在,缺的是把这种提升递归推进到每个边界内部。
实现草图:四条路径,逐条拆解
计划文档给出了清晰的实施步骤,下面结合源码逐一展开。
第 1 步:get_hoisting_limits已就绪,保持现有形状
用户配置层的hoistingLimits是pnpm-workspace.yaml里的一个枚举,定义在 setting_types.rs,三档语义如下:
| 模式 | 含义 | 效果示例(A → B → C,A 为 workspace 包) |
|---|---|---|
none(默认) | 尽可能提升 | /node_modules/B、/node_modules/C |
workspaces | 只提升到每个 workspace 包 | /packages/A/node_modules/{B,C} |
dependencies | 只提升到每个 workspace 包的直接依赖 | /packages/A/node_modules/B/node_modules/C |
该枚举在nodeLinker: isolated下无效。把用户模式翻译成 hoister 消费的 per-locator 边界映射(border map)的函数是 get_hoisting_limits(注意:实现位于deps-restorercrate,计划文档中写的package-manager/src/hoisting_limits.rs是当时规划时的路径):
none直接返回空映射;- 根边界累计在
.@下:包括根 importer 自身直接依赖的别名,以及每个(百分号编码后的)非根 importer id; dependencies模式下,还会为每个非根 importer 生成{encoded_id}@workspace:{importer_id}的 per-importer 边界条目,集合内容是该 importer 的直接依赖别名(跨dependencies、devDependencies、optionalDependencies三组收集,见 collect_direct_dep_names)。
源码注释明确写道:当前 hoister 只向单一根 importer 提升,所以只消费.@条目;dependencies模式发出的 per-importer 条目是"为 parity 而产出,待多级提升落地后才会真正承重"。这正是多级工作的第 1 步:保持这个形状不变,多级落地后它自然生效。
第 2 步:在nm_hoist中递归执行 per-root 提升
当前驱动流程(nm_hoist → hoist_to)的结构是:
- 把输入树
convert成HoisterResult图; - 对
.根调用hoist_into_root跑定点提升; - 递归进入每个存活的子节点,把子节点当作下一级提升根继续。
注意hoist_to的递归其实已经存在——它会在path_locators集合(当前递归路径上的所有根 locator,用于切断环)的守卫下,对每个留下的子节点再次hoist_into_root。真正的缺口在于:递归是"无差别"进行的,没有先判断该子节点是否是hoist root。上游hoistTo(yarnpkg-nm/sources/hoist.ts 中hoistTo实现及其hoistIdents/ per-root 偏好映射)的逻辑是:根定点结束后,对每个存活的、自身是 hoist root 的子节点(Workspace类型节点,或名字位于带 per-locatorhoisting_limits条目的边界集合中的节点),以它为根、带上它自己的 locator 边界集合,再跑一遍hoist_into_root。
也就是说,多级实现要在hoist_to的递归入口处补上"hoist root 判定":Workspace类型的子节点天然是 hoist root;名字落在opts.hoisting_limits.get(locator)集合中的节点也是。前者对应hoistingLimits: 'workspaces',后者对应'dependencies'模式(此时get_hoisting_limits产出的 per-importer 条目正好提供了每个 importer 自己的边界集合)。判定通过后,以该节点为根调用hoist_into_root,并用其 locator 对应的边界集合替换当前上下文——上游的参考实现在hoist.ts的hoistTo。
第 3 步:walker 无需结构性改动
walk_deps(hoisted_dep_graph.rs 的walk子模块)已经递归进入Workspace类型的子节点,并把 hoister 决定好的位置物化为目录。既然多级提升只是让 hoister 在子树内部多做扁平化,walker 拿到的结果图仍是一棵可递归遍历的树,所以计划文档明确:"The walker needs no structural change"。
第 4 步:移植测试
计划文档点名的待移植测试有两类:
- 上游
hoist.test.ts中hoistingLimits的用例('workspaces'与'dependencies'两种模式)——这些用例直接刻画多级输出形状; - CLI 已知失败用例
partial_install_persists_hoisted_map(对应上游 issue pnpm/pacquet#433)——它同时依赖 re-hoist 合并(见下文风险部分)。
仓库现有测试已经为多级形态埋好了观测点。例如 build_hoist_ident_map_skips_root_peer_names 的注释直接点明:"the shape a per-importer hoisting root would take once those land"(一旦 per-importer 提升根落地,这就会是它呈现的形状)——该测试直接驱动build_hoist_ident_map构造一个带 peer 声明的根,验证"根声明为 peer 的名字不会进入 ident 偏好映射",这正是 per-root 提升时每个子树根都要重算的映射语义。另外 multi_round_unlocks_peer_friendly_hoist_after_blocker_moves 验证了多轮定点提升在阻塞者移走后解锁 peer 友好提升的能力,这与 per-root 递归的多轮收敛是同一套机制。
风险与工作顺序:全局状态必须作用域化
计划文档明确列出两个实施风险,与源码一一对应:
风险一:ident 偏好映射与 per-pass ident shift 目前是全局的
在hoist_into_root中,每个提升根都会先调用build_hoist_ident_map(root)构造 per-name 的候选 ident 排序(最常用者优先),然后在多轮循环里执行per-pass ident shift:某个名字有多个候选 ident、且首选 ident 始终无法到达根时,就丢弃首选、提升下一个候选,让后续轮次可以放置次优版本(对应 yarnhoistTo中的idents.shift()循环)。此外HoistCtx中的used(来自get_used_dependencies)记录了子树里已经被提升到祖先目录解析的名称,防止不同版本抢占同名槽位。
问题在于:这些映射目前是整个调用共享的。一旦 per-root 递归落地,每个提升根都必须有自己独立的偏好映射与 ident shift 状态——上游的做法是在每次hoistTo时重新执行buildPreferenceMap(rootNode)重建这些映射。Rust 移植需要把hoist_ident_map、used等从"每次hoist_to调用时构造"改为"每次hoist_to以新根进入时按该根的子树重新构造",否则一个子树的 ident shift 会污染另一个子树的选择。
风险二:与 partial-install(re-hoist 合并)的依赖关系
partial_install_persists_hoisted_map用例同时依赖 re-hoist 合并(把上一次安装的 hoisted 位置合并进新一轮布局)。计划文档给出的顺序建议是:只有当 re-hoist 合并希望与多级提升同版本发布时,才把它排在 pnpm/pacquet#433 的 partial-install 工作之后;否则两条实现路径相互独立,可以并行推进。
验证方式与观察点
要验证多级提升是否按预期落地,可以从三个层面观察:
- 单元测试层面:
real-hoist的测试目录(pnpm/crates/real-hoist/src/tests)已按behavior.rs(深度链平铺、别名解析)、dependencies.rs(版本冲突嵌套)、workspace_settings_*(多 importer 输出、peer 偏好映射)分好了主题,新增的hoistingLimits多级用例应归入workspace_settings_*系列; - 边界映射层面:
deps-restorer的 hoisting_limits.rs 测试(见同目录tests/)验证get_hoisting_limits在workspaces/dependencies两种模式下产出的 locator 键与名称集合,多级落地后这些条目从"parity 形状"转为"承重输入"; - CLI 层面:
pnpm/crates/cli/tests/suite/hoisted_node_linker.rs是 hoisted 布局的集成测试入口,partial_install_persists_hoisted_map这类已知失败用例应在多级 + re-hoist 合并完成后转绿。
小结
多级提升是 pacquet 的real-hoistcrate 对齐上游@yarnpkg/nmhoister 的最后一块结构性拼图:单层提升已经解决了"拍平到根"和"边界一层深度"的问题,并且嵌套放置本身解析正确、可以随版本发布;多级提升要做的是把"内部扁平化"从根递归推广到每个 hoist root,让hoistingLimits: 'workspaces'/'dependencies'下每个被圈住的子树也获得自己的定点提升。实现的关键不在 walker(它无需改动),而在 hoister 内部:per-root 递归的 hoist root 判定、每个根独立的偏好映射与 ident shift 状态,以及与 partial-install 的 re-hoist 合并的协调顺序。对于想在 Rust 侧理解或贡献 hoisted 布局的开发者,real-hoist/src/lib.rs 的hoist_to→hoist_into_root→hoist_subtree调用链加上 deps-restorer/src/hoisting_limits.rs 的边界映射,就是完整的阅读与改造地图。
【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考