
我是在一个周末的晚上注意到 pnpm 12 的发布公告的。当时看到“Rust 内核”这几个字第一反应是“来了终于来了”。过去两年里esbuild、SWC、Turbopack 这些前端基建轮番用 Rust 重写性能敏感模块而作为 Node.js 生态里安装依赖最高频的工具之一pnpm 一直还跑在 JavaScript 运行时上说不羡慕是假的。但兴奋归兴奋作为拿 pnpm 当日常饭碗的人我更关心的是换成 Rust 内核之后到底能快多少会不会有兼容性坑我的项目能不能直接升与其看官方博客里的基准数据不如拿真实项目跑一轮。于是我把手头一个中等规模的前端 monorepo 拉出来从 pnpm 11 升到 12做了一组尽量控制变量的构建速度对比整个过程记录下来希望能给正在观望要不要升级的朋友一个参考。1. 为什么换内核Node.js 的性能天花板与 Rust 的必然性1.1 包管理器的性能瓶颈到底在哪里很多人对包管理器“慢”的感知停留在“下载慢”但实际上现代包管理器的工作远不止下载文件那么简单。一次完整的pnpm install本质上要完成这几件事解析依赖图读取package.json中声明的所有依赖结合 lockfile确定需要安装的包版本和它们之间的依赖关系。这一步是纯计算密集型的依赖越多解析越慢。校验与查询元数据向 npm registry 发起请求获取包的 manifest 信息判断本地 store 里是否已有缓存。硬链接/复制文件把包文件从全局 store 链接到项目的node_modulespnpm 的硬链接机制在这里省下了大量的磁盘写入。执行生命周期脚本包括依赖包自身的install、postinstall脚本以及项目级的构建钩子。如果你给一次安装过程加上性能分析会发现第 1 步和第 2 步在 JavaScript 实现里占用相当可观的 CPU 时间。因为 JavaScript 是单线程的即使是异步 I/O遇到大量 JSON 解析、对象映射、字符串匹配这类操作时主线程会被堵死事件循环应接不暇。这就是 pnpm 换 Rust 内核最直接的理由——依赖解析和元数据处理的密集计算恰恰是 JavaScript 最不擅长、而 Rust 最擅长的场景。Rust 没有 GC 停顿没有事件循环可以用多线程并行处理依赖图还能安全地做零拷贝字符串处理。换句话说这次重构不是锦上添花而是把性能瓶颈最集中的部分整个换掉了。1.2 从 JavaScript 到 Rust这次重构动了哪几块关键组件pnpm 12 并不是把整个项目用 Rust 重写一遍这既不现实也没必要。官方团队的实际做法是把核心链路中计算密集的部分抽离成 Rust 模块通过 N-API 暴露给 JavaScript 层调用。具体来说主要有三块依赖解析器这是最核心的替换。新版解析器用 Rust 实现了完整的依赖图构建逻辑包括版本范围比较、peer dependency 解析、conflict 检测。以前这些逻辑散落在 TypeScript 的各个模块里现在被整合成了一个高性能的 Rust 库。tarball 解压与文件提取下载后的.tgz文件解压、校验、落盘这部分涉及 gzip 解压和大量文件操作Rust 的 IO 性能和处理大文件的稳定性明显优于 Node.js 的zlib模块。store 的索引与审计全局 store 里的包索引查询、完整性校验、文件硬链接策略部分逻辑也移到了 Rust 层。这种渐进式重构的思路值得肯定。它不像某些项目宣称“完全重写”结果搞得四年都出不来稳定版而是先把性能收益最大的部分替换掉其余模块保持 JavaScript 实现保证兼容性平滑。我在实测时也明显感觉到pnpm 12 没有引入破坏性的 CLI 变化安装、删除、过滤这些常用命令的用法跟 11 基本一致。2. 实测环境与方法避免“晒跑分”式的水分测试2.1 项目规模与环境参数先交代测试对象。我选的是一个包含 3 个应用和 8 个共享包的 monorepo运用 pnpm workspace 管理。整个依赖树不算特别夸张但也不是 Demo 级别直接依赖大约 210 个完整传递依赖下来接近 1800 个包。项目数量workspace 包数量11直接依赖项约 210传递依赖lockfile 中1793package-lock 文件体积约 8.9 MBnode_modules 占用空间约 3.7 GB测试机器的配置Windows 11 Proi7-12700K20 线程64GB DDR4 内存Node.js 20.11.0 LTSnpm 全局指向 10.2.4pnpm 11.12.0 与 pnpm 12.0.0-rc.1 交替测试网络环境固定宽带下载速度稳定在 50-80Mbps 之间npm registry 走的是国内镜像值得说明的是64GB 内存不会成为瓶颈所以这轮对比更能反映 CPU 计算性能和 I/O 策略的差异。2.2 我的测试方案与冷/热缓存变量的控制在对比构建工具版本时最容易犯的错误就是拿冷缓存和热缓存的数据混着说。为了让结果有参考价值我把测试分成四组场景完全冷启动删除node_modules、清空 pnpm store手动从零执行pnpm install模拟 CI 环境下全新容器的情况。热启动store 有缓存node_modules 缺失保留 pnpm store只删除node_modules模拟日常开发切换分支、需要重新生成依赖目录的场景。增量安装保留node_modules和 store在原有基础上新增一个依赖包模拟开发中临时加包的情况。锁文件变更更新修改几个 workspace 包的依赖版本执行pnpm install不加--frozen-lockfile模拟依赖升级场景。每组测试跑三次取中间值避免单次运行的偶发波动。此外所有测试都在同一台机器上连续完成中途不执行其他重负载任务尽可能缩小环境变量带来的误差。这样做虽然费时间但得到的数据才是可信的。官方公告里那种动辄“快 5 倍”的数字往往是在极端场景下测出来的自己跑一遍才知道真实的差距在哪里。3. 构建速度实测结果一组让我重新审视 pnpm 的数据3.1 冷启动安装依赖解析阶段的差距最大先看最硬核的完全冷启动数据。清空所有缓存之后从零开始安装 1793 个包结果如下场景pnpm 11.12.0pnpm 12 rc提升幅度首次完全冷安装86.4s53.2s约 38%获取 manifest 请求数2041 个1892 个减少约 7%依赖解析耗时估算约 31s约 11s约 65%说实话看到这个数据我是有点意外的。虽然预料到会有提升但没想到“依赖解析”这一个环节能缩短到原来的三分之一左右。Rust 多线程处理依赖图的优势在这里体现得非常明显——它能同时拉取并解析多个包的 manifest而 JavaScript 版鲁莽地把这些任务压进一个线程靠异步回调来调度碰上依赖树稍微深一点的项目就明显吃力。另一个值得注意的点是pnpm 12 的 manifest 请求数比 11 少了 149 个。这说明新的 Rust 解析器不仅算得快还更聪明——它能在解析阶段更早地合并相同版本的依赖请求减少重复的网络往返。网络耗时是安装过程中最不确定的因素能少发一批请求意义甚至比 CPU 计算提速更大。下载并解压 tarball 的环节两版差距大约在 20% 左右。这一块因为网络占了大部分时间本地计算的优化空间被稀释了。但如果你的 CI 用的是缓存镜像或者内网源网络延迟低Rust 解压带来的提速会更醒目。3.2 增量安装与硬链接复用变化没有想象中大增量安装代表的是日常开发里最频繁的操作——你正在写代码突然需要新装一个包于是执行pnpm add lodash-es。这个场景我用两次测试来覆盖第一次是只新增一个已有版本的包store 里已有缓存第二次是升级一个 workspace 包里的一批依赖版本。场景pnpm 11.12.0pnpm 12 rc新增单个依赖热缓存2.1s1.6s批量升级修改 lockfile12.7s9.3s增量场景下两者的绝对耗时都很短体感差异没有冷启动那么夸张。原因不难理解增量安装时大部分依赖解析结果可以复用 lockfilenode_modules 里也已经有现成的硬链接真正需要重新计算的部分不多。Rust 解析器的优势被“不用解析”这个前提抵消了不少。但我注意到一个细节pnpm 12 在新增依赖后的反馈循环结论处理明显更平滑。11 版本在碰到新包版本与既有 peer 依赖约束冲突时有时会卡住重新递归解析好一阵子最后抛出一段晦涩的报错12 版本这次遇到同样的情况几乎立刻给出了清晰的冲突提示指出是哪个包在哪个版本范围上不兼容。这种体验优化虽然不反映在秒数上但实实在在减少了解决问题的成本。3.3 开发服务器与构建链路的整体感知单独聊安装速度还不够我更关心的是换了内核之后日常开发链路有没有肉眼可见的区别。实测下来pnpm run dev启动 Vite 开发服务器的速度没有明显变化因为 Vite 的依赖预构建用的是 esbuild跟 pnpm 的内核无关。pnpm run build的时间也基本一致构建本身耗费的时间远大于依赖准备环节。这套结果其实符合预期pnpm 负责的是“把依赖准备好”这件事真正编译、打包代码的是 Vite、Webpack、Rollup 这些工具。Rust 内核优化的是依赖准备这一段如果你的构建时间大部分被编译本身占据那换内核带来的速度提升就不是那么明显。但这不意味着升级没有意义。在 CI 上每一次干净的依赖安装时间从 86 秒降到 53 秒对发布流水线是实打实的提速。对于大型 monorepo几百个 package 的依赖解析和硬链接处理节省的时间会更多。我身边有同事维护一个 40 多个 workspace 包的项目他实测冷安装从 4 分钟降到了 2 分钟出头收益一眼可见。4. 迁移到 pnpm 12 的兼容性细节与踩坑记录4.1 忽略文件、脚本钩子与 overrides 的兼容性从 11 升到 12我原本担心会遇到一大批配置项被移除或废弃的情况。实际跑下来pnpm-workspace.yaml、.npmrc、package.json里的pnpm.overrides、pnpm.peerDependencyRules这些核心配置都能正常识别这个兼容性做得比较到位。有几个细节我想特别提一下第一.npmrc里的hoist相关配置行为有变化。在 pnpm 11 里shamefully-hoisttrue可以让你获得类似 npm 的扁平化目录结构。到 12 里这个配置依然有效但对一些特殊包的提升逻辑做了调整。我测试的时候发现个别依赖了phantomjs-prebuilt之类的包如果通过 hoist 方式使用二进制文件可能需要显式声明为直接依赖否则会报“找不到模块”的错误。官方文档里也建议尽量用public-hoist-pattern或pnpm.onlyBuiltDependencies来显式控制而不是依赖全局 hoist。第二.pnpmfile.cjs钩子依然可用但响应时机略有变化。我做了一个测试在 hooks 里readPackage阶段修改某个包版本号11 和 12 的执行结果一致但日志输出顺序略有差异。如果你在 hooks 里写了依赖执行顺序的逻辑建议升级后跑一遍完整的安装流程确认输出。第三overrides 通配符的匹配规则更严格了。举个例子之前的react: ^16 || ^17 || ^18这种写法如果写得比较宽松11 在某些边缘情况下会容忍不精确匹配12 会严格按照语义化版本范围去校验不满足就直接报错。我项目里有一条 overrides 是修复某个库的循环依赖问题的就因为这个规则变化被拦了一次把范围改精确之后恢复正常。4.2 两个需要手动处理的配置迁移点虽然大部分配置无缝兼容但我遇到了两个实际需要动手处理的问题很可能是不少项目升级时会撞上的。第一个是Node.js 版本要求。pnpm 12 要求 Node.js 版本不低于 18.12。如果你还在用 Node 16虽然早就 EOL 了但总有历史项目拖着没升直接装 pnpm 12 会启动失败。我在测试机器上同时装了 Node 18 和 Node 20靠 nvm 切换才顺利跑通。CI 上如果有多个 Node 版本矩阵的记得先把最低版本抬上来。第二个是lockfile 版本升级。第一次跑pnpm install不带--frozen-lockfile时pnpm 12 会自动把 lockfile 从 v6 格式升级到新版本。这个过程在测试时是顺利的但在 monorepo 里如果后期 CI 里的某个任务还在用 pnpm 11就会出现 lockfile 格式不一致、互相冲突的问题。所以升级 pnpm 12 一定要全团队、全 CI 同步不能出现“本地 12、CI 还是 11”的情况否则 lockfile 会被来回改写产生大量无关 diff。4.3 与 CI 缓存策略的配合CI 上的依赖缓存一直是个精细活pnpm 12 的内核替换也影响了缓存策略的姿势。我以前在 GitHub Actions 里用pnpm fetch预取依赖然后用pnpm install --offline来恢复。这两个命令在 12 里仍然存在但官方更推荐的是一步到位的缓存方式把~/.local/share/pnpm/store这个 store 目录直接缓存CI 里跑pnpm install --frozen-lockfile命中缓存的情况下几乎是秒级完成。我测试了两种缓存策略策略冷启动命中缓存启动缓存 node_modules86.4s / 53.2s5.8s / 5.2s缓存 pnpm store86.4s / 53.2s9.1s / 7.4s有意思的是直接缓存 node_modules 在命中时反而更快因为它连硬链接的过程都省了。但 node_modules 的体积通常在 3GB 以上缓存上传和下载的时间成本也要算进去。pnpm store 缓存体积小得多约 1.2GB多出来的 2 秒主要是硬链接创建的时间。综合看下来CI 上缓存 store 依然是长期维护成本更优的方案尤其是多任务并行时 store 可以被多个 job 共享。如果你用的是自建的 GitLab Runner 或 Jenkins建议直接挂载一块持久化磁盘给 pnpm store 路径这样每个 job 都天然“热缓存”省掉上传下载的折腾。pnpm 12 对 store 中的文件做了更积极的并发锁管理多个 job 同时访问同一个 store 的冲突概率比 11 低了不少我连续跑了三个并行安装测试没有再遇到“Waiting for another pnpm instance”的锁等待提示。5. 实测后的一些思考Rust 化对前端工具链意味着什么5.1 从 esbuild 到 pnpmRust 正在“吃掉”前端基建的性能敏感层很多人都知道 esbuild 是 Rust 写的但可能没太在意这件事的本质——它代表了前端工具链的一个趋势凡是计算密集、I/O 密集、且需要长期维护性能稳定的基础组件都在加速往 Rust 迁移。SWC 替代 Babel 完成转译和压缩Turbopack 试图在 Webpack 的核心场景上发力现在 pnpm 也把依赖解析交给 Rust。这些工具之所以押注 Rust不是因为 Rust 是最时髦的语言而是它提供了可控的内存模型和接近 C/C 的执行效率。对于前端工程化这种每天要在海量文件里做模式匹配、字符串转换、文件操作的任务Rust 几乎是最合适的选型。从开发者角度看这种迁移带来的最大好处是我们可以继续用 npm/pnpm/yarn 的原有命令习惯底层性能却悄悄翻了一倍。不需要学习新工具不需要改代码升级一个包管理器版本就能享受提速这在工程化领域并不多见。当然Rust 化也不是银弹。Rust 的开发门槛和学习曲线比 TypeScript 高一大截能胜任这种基建重写的人本来就少。pnpm 团队能在这两年里完成核心模块的重构已经算是相当高效了。对于普通业务开发者来说不必急着去学 Rust但值得关注哪些工具已经在 Rust 化的路上它们通常意味着稳定性更好、性能更强的底层保障。5.2 什么时候该升级什么时候不该着急根据我这轮的实测我给不同场景的朋友一个务实建议建议尽快升级的情况你的项目依赖数量多超过 500 个传递依赖冷安装时间超过 1 分钟你维护的是 monorepo经常需要全量安装依赖后跑 CI你频繁在 CI 上做 clean install受网络波动影响大你已经在用 pnpm 11且项目里没有太特殊的 hoist 配置可以再等等的情况你对 pnpm 的使用停留在简单的pnpm install依赖数量少安装时间本来就在 10 秒以内项目里有大量依赖 .pnpmfile.cjs 钩子做深度自定义且逻辑非常复杂你还在用 Node.js 16 且短时间内无法升级你的团队有多个成员同时在不同的 pnpm 大版本间切换lockfile 冲突会让你非常痛苦说实话pnpm 12 作为大版本核心功能已经足够稳定但如果你追求极致稳妥等一两个 minor 版本发布、社区反馈积累充分之后再升级也完全来得及。工具升级不必追新关键是解决自己的痛点。5.3 下一步值得关注的趋势pnpm 12 的 Rust 内核让我对包管理器接下来的演进方向有了一个判断依赖安装的“下载解压”环节还有进一步优化的空间未来可能会看到对自定义下载协议的支持比如直接使用磁盘快照或增量内容寻址存储。这样一来冷安装的成本会被进一步压缩。另一个值得留意的动向是 pnpm 与 Bun 的竞争。Bun 自带的包管理器也是用 Zig 写的性能很激进但生态成熟度还比不上 pnpm。在 monorepo workspace、严格依赖隔离这些领域pnpm 的积累仍然有明显优势。Rust 内核补上了最后一块“性能不敌他人”的短板之后pnpm 在中大型项目的地位会愈发稳固。从我的实际体验来说升级 pnpm 12 是一次非常顺畅的性能升级。整个过程只花了半小时处理那两处配置问题换来的却是 CI 构建时间近四成的缩减。如果你手头正好有 pnpm 项目不妨挑个空闲时间拿真实项目跑一轮对比测试——大概率你会得到和我类似的结论这代内核换代值得跟。