pnpm 12 正式发布之后,很多人第一句话就问 Rust 重写后到底能快多少。先给结论:单条 install 的启动阶段和依赖链接阶段确实会比旧版本更利落,但“快多少”不能用一个统一倍率概括,要看你项目的依赖规模、缓存状态、Node 版本和网络环境。这篇不是替 pnpm 做宣传,而是站在从 npm 迁过来、准备升级到 12、以及要在 CI 里跑批量任务的角度,把安装、构建、部署、性能对比和问题排查的完整流程拆一遍。适合前端开发、全栈工程师,以及负责前端工程化治理的同学。
1. 先搞清楚 Rust 重写到底重写了哪一段
1.1 为什么包管理器值得用 Rust 重写
pnpm 的核心工作不是把压缩包下载下来那么简单。它要做版本解析、依赖关系计算、内容寻址存储、符号链接创建、构建脚本调度,还要在 monorepo 里处理工作区依赖。这些步骤在 Node.js 原生栈里写,问题不是不能跑,而是依赖一多,解析和校验带来的开销会变得不可控。
Rust 进到 pnpm 的底层链路之后,比较明显的变化在三个地方:
- 命令行启动速度更快。以前会被 Node 运行时启动拖一部分时间,现在二进制启动路径更短。
- 依赖解析和安装调度阶段的逻辑更集中,CPU 占用模式更稳定,GC 抖动少。
- 并发场景下的数据一致性处理更严格,团队维护起来更有底气。
注意,这不是说 pnpm 12 的所有代码都用 Rust 重写了。更准确的说法是,核心性能敏感路径交给了 Rust 组件,外围的命令体验还是 Node 生态的插件和脚本体系。理解这一点有实际价值:你在处理问题时不需要去读 Rust 源码,也不需要为了跑 pnpm 安装 Rust 工具链。
1.2 内容寻址 store 和硬链接机制
真正让 pnpm 和其他包管理器拉开差距的,不只是 Rust,而是它一直坚持的内容寻址存储。
简单解释一下:npm 安装依赖时,通常会直接铺开一个扁平的 node_modules,同一个版本的包如果被多个项目需要,就得在磁盘上各存一份。pnpm 不一样,它把所有下载过的包放到一个统一的 store 目录里,通过文件名和内容哈希来保证唯一性。项目安装时,实际文件并不复制到项目的 node_modules 下,而是通过硬链接或符号链接指向 store 里的原文件。
这样一来,磁盘占用会明显下降,安装时间也会减少。Rust 重写之后,链接的创建和校验速度更快,尤其是依赖数量超过几百个的项目,你会更容易感受到这一点。
如果你以前用过 npm,第一次看到 pnpm 安装后的 node_modules 结构可能会懵。里面是一堆符号链接,真正的包文件藏在 .pnpm 目录里。这个结构不是 bug,是设计。
1.3 用 pnpm 12 不需要学 Rust
这里要顺手纠正一个网上常见的误会。搜索“pnpm Rust”的时候,经常能看到一堆 Rust 安装教程、Rust 开发环境搭建、Rust 离线安装源码之类的内容。这些和普通前端项目的 pnpm 使用没有直接关系。
pnpm 发布的是编译好的可执行文件,你是使用者,不是开发者。如果你只是想在项目里跑pnpm install或者写 CI 流程,完全不需要安装 Cargo,也不需要配置 Rust 的 toolchain。
只有当你准备给 pnpm 做二次开发、提交插件、研究源码时,才需要 Rust 环境和整套编译工具链。普通项目迁移到 pnpm 12,只需要关心一件事:Node 版本够不够。
2. 升级前先确认 Node 版本、镜像和缓存状态
2.1 Node 版本过低是最大的坑
pnpm 12 对 Node 版本有要求。网上会看到类似这样的报错:
error: this version of pnpm requires at least node.js v22.13 the current version is ...这个报错的意思很直白:当前 Node 版本低于 pnpm 12 要求的最低版本。遇到这种情况,第一步不是去降 pnpm,而是把 Node 升级到合理版本。
我建议升级前先确认一下当前环境:
node -v npm -v pnpm -v这里有个容易忽略的点:用node -v看到的版本,不一定是你执行pnpm时使用的那一个。如果你用了 nvm、fnm、Volta 这类版本管理工具,要确认当前 shell 是否切到了正确的 Node 版本。Windows 上尤其容易出现多版本并存的情况:一个 Node 装在了 Program Files,另一个装在了 nvm 的目录,PATH 顺序一变,pnpm 就会认错运行时。
如果项目里还有老代码依赖 Node 16 或 Node 18,不一定要立刻升到 22。可以先在一个分支里升级 Node 和 pnpm,跑一遍构建和生产用例,确认兼容性之后再推广到团队。
2.2 四种常见安装方式
pnpm 的安装方式不少,不同环境的推荐做法不一样,我把常见方式列出来:
| 安装方式 | 命令 | 适用场景 |
|---|---|---|
| npm 全局安装 | npm install -g pnpm | 最通用,任何有 Node 的环境都能用 |
| Corepack 管理 | corepack enable后corepack prepare pnpm@latest --activate | Node 自带 Corepack 时比较省事 |
| 独立可执行包 | npm install -g @pnpm/exe | 避免和 npm 全局路径混在一起 |
| 包管理器自托管 | 下载官方预编译产物 | CI 或离线环境 |
升级过程中有一个很常见的报错:
pnpm : 无法将“pnpm”项识别为 cmdlet、函数、脚本文件或可运行程序的名称或者是:
'pnpm' 不是内部或外部命令,也不是可运行的程序或批处理文件。这两种报错本质一样:系统在 PATH 里找不到 pnpm。先执行npm config get prefix看看 npm 全局安装目录在哪,再把这个目录加到系统 PATH。Windows 下改完 PATH 后记得重新打开终端,最好是重新开一个新的 PowerShell 窗口,不要只刷新会话变量。
如果是用 npx 临时跑一次,比如npx pnpm -v,确实能启动,但这不是长期可用的姿势。日常开发要的是全局命令能直接命中,建议把环境变量一次配好。
2.3 镜像源和下载失败的调整思路
安装 pnpm 或者安装依赖时,最烦人的是下载超时和包体下载失败。你看到“pnpm 下载失败”,未必是工具坏了,大概率是连接默认 registry 不稳定,或者公司网络对某些域名做了限制。
常见做法是设置国内镜像:
pnpm config set registry https://registry.npmmirror.com也可以用阿里云或腾讯云等团队维护的 npm 镜像,不过镜像地址会变,建议以你的实际网络验证为准。设置完之后,执行:
pnpm config get registry先确认配置生效,再重新pnpm install。
除了 registry,还有一个容易被忽略的地方:pnpm store。如果之前用 npm 下载过一些包,pnpm 会把下载内容重新整理到自己的 store 目录。如果你更换了磁盘、换了电脑,或者把项目挂到了网络盘上,store 路径变化会影响链接结果。可以用pnpm store path查看当前 store 的完整路径。
另外,新版 pnpm 安装依赖时如果发现某些包需要执行安装脚本,可能会提示你运行:
pnpm approve-builds这个命令的目的是让你选择允许哪些依赖执行 postinstall 脚本。默认阻止所有依赖的构建脚本,是为了安全性考虑。到了你确实需要某个依赖跑脚本时,再运行这个命令交互式勾选,而不是用--ignore-scripts一棍子打死。
3. 从 npm 迁移到 pnpm,node_modules 结构为什么会不一样
3.1 幽灵依赖问题
用 npm 安装依赖时,所有的依赖和间接依赖都会被提升到 node_modules 根目录。好处是代码里 import 一个没有直接声明的包时也能跑通。坏处也很明显:你根本没在 package.json 里声明它,却悄悄用了它,一旦升级或删除这个间接依赖,项目就会莫名其妙报错。
这种依赖在工程上叫“幽灵依赖”。pnpm 默认不提升所有包,它把直接的依赖装在最外层,间接依赖收进 .pnpm 内部的集中式结构里。所以你安装完成后,项目里并没有“所有包都在顶层”这件事。
如果从 npm 直接切到 pnpm,最可能出现的现象是:install 成功,但pnpm run build时报错某个模块找不到。最终原因通常是代码里使用了未声明依赖,或者某个工具隐式访问了提升目录。
3.2 一个迁移步骤的推荐顺序
迁移不要只改一个安装命令就完事,我一般建议按这个顺序:
- 在 git 分支上新建一个迁移分支。
- 备份 package-lock.json 或 yarn.lock。
- 删除 node_modules 和原有锁文件。
- 把 package.json 里的包管理器字段设置为 pnpm。
- 执行
pnpm install,生成 pnpm-lock.yaml。 - 运行
pnpm run build和项目自带的测试用例。 - 处理缺少声明依赖的报错。
第 4 步里,如果项目支持 Corepack,可以在 package.json 里加:
{ "packageManager": "pnpm@12.x.x" }这样团队其他成员执行命令时,Corepack 会自动切换到对应版本,减少“我这里能跑,你那里不行”的版本不一致问题。
3.3 处理未声明依赖的写法
如果构建时报错“Cannot find module”,先别急着把依赖改成--shamefully-hoist或者关闭严格结构。更推荐的做法是找出哪些依赖确实被用到了,然后显式补进 package.json。
一个快速定位的办法是打开pnpm list,查看当前项目的直接依赖和间接依赖层级:
pnpm list --depth 5如果报错的包是某个直接依赖的间接依赖,你在代码里引用了它,就应该判断它是否适合直接声明。适合就直接加进 dependencies,不适合就改代码,不要继续依赖提升行为。
只有极少数工具链,比如某些老版本 Electron 构建脚本,或者依赖深度解析的 monorepo 工具,才需要用shamefully-hoist来模拟 npm 的扁平结构。从工程维护角度来说,这种方案是最后手段,不建议一上来就开。
4. 单项目跑通 install,再处理 build 和部署
4.1 第一次 install 时重点看什么
第一次安装依赖,先不要急着开最高并发。可以执行:
pnpm install --reporter append-only这种输出模式更接近日志流。安装完成之后,从结果里看几个信息:
- 是否报依赖解析错误
- 是否执行了 postinstall 脚本
- 总耗时
- store 路径变化
- 是否有包被跳过或复用
如果你发现安装过程非常久,并且卡在某个包下载,那问题大概率在网络侧。先看日志里的包名和地址,再用 curl 或者 wget 手动拉一下这个地址,判断是网络访问不了还是包源响应慢。盲目提高--network-timeout只能缓解暂时超时,解决不了源头不稳定。
4.2 pnpm run build 的产物怎么交给 Nginx
很多前端团队项目中用的是 Vite、Webpack 或 Next.js。pnpm run build之后,产物一般会输出到 dist、build、.next 或 out 这类目录。
下面是一个比较常见的部署方式,以 Nginx 为例:
server { listen 80; server_name example.com; root /var/www/my-project/dist; index index.html; location / { try_files $uri $uri/ /index.html; } }这里的核心点是try_files $uri $uri/ /index.html。前端路由如果是 History 模式,不写这一行的话,刷新二级页面很容易 404。
有一个经常被忽略的细节:用 pnpm 构建后,dist 目录里的文件权限可能是当前构建用户生成的。部署时若用 nginx 用户托管静态文件,需要确保 nginx 用户能读取这些文件,否则页面空白或者 Nginx 返回 403。
如果你的项目不是纯静态资源,而是 Node 服务端渲染,那就不能只开静态服务器。需要先pnpm build,再用pnpm start启动应用服务,Nginx 只做反向代理。
location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }4.3 构建常见报错的排查思路
项目构建时报错,最典型的不是“pnpm 有问题”,而是脚本兼容和路径问题。
比如pnpm run build报错“xxx command not found”。这个 xxx 可能是 node、npm、yarn、npx 或项目自定义脚本。pnpm run 执行脚本时会把项目的 node_modules/.bin 放进 PATH,但不会自动找到全局工具。解决办法是把这个工具改为项目依赖,而不是依赖全局环境。
再比如“postinstall script failed”。这类错误要在 install 日志里往前翻,找到具体是哪个包、哪条脚本、哪个退出码。不要只看最后一行红色错误。很多情况下,是依赖里的二进制下载失败,或者是 Python、C++ 编译链缺失,和 pnpm 本身没有关系。
如果你需要跳过脚本跑一次纯安装,可以临时使用:
pnpm install --ignore-scripts但要注意,这只是排查手段。如果把依赖安装脚本全跳过,很多需要编译的原生模块产出的二进制就会缺失,项目跑起来会更奇怪。
5. 性能对比怎么做才靠谱
5.1 冷启动和热启动必须分开测
聊“Rust 重写后快多少”,最怕拿一个已经缓存完的环境去测第二次安装,然后宣布“快了很多倍”。因为第二次安装可能直接在 store 里复用已经下载过的包,网络消耗几乎为零,这个结果不能代表真实冷启动。
要对比,必须先分清场景:
- 冷安装:清空 store 缓存,清空 node_modules,重新下载所有依赖。
- 热安装:保留 store 和锁文件,只对增量部分做处理。
- 增量安装:只新增了一个小依赖,看需要多久完成。
不同场景的判断标准完全不同。冷安装主要看网络和解析能力,热安装主要看文件链接和校验速度,增量安装主要看是否存在冗余遍历。
5.2 记录哪些指标才不算白测
建议每次对比至少记录这些指标:
| 指标 | 含义 | 怎么看 |
|---|---|---|
| 总耗时 | install 或 run 命令的墙钟时间 | 快速感知整体差距 |
| 解析时间 | 解析依赖树的时间 | 判断版本解析算法优劣 |
| 下载耗时 | 网络下载内容的时间 | 网络影响大时不能归功于工具 |
| 链接耗时 | 创建硬链接和符号链接的时间 | Rust 重写最有价值的区域 |
| 磁盘占用 | node_modules 和 store 的体积 | 验证空间节省情况 |
| 缓存命中率 | store 内已有文件的比例 | 判断热安装是否有复用 |
我自己做对比时,不会只跑一次。一般跑三次,取中间值。如果有某次安装因为网络抖动特别慢,先把那次剔除,再看稳定数据。
5.3 用命令记录耗时
Linux 和 macOS 下可以这样记录:
/usr/bin/time -v pnpm install也可以简单点:
time pnpm installWindows PowerShell 下可以用:
Measure-Command { pnpm install }如果你想看更细的日志,可以开 verbose 模式:
pnpm install --reporter ndjson不过这种输出刷屏很厉害,更适合导入到文件里分析,不适合直接读。
5.4 安装慢不一定是 pnpm 的锅
热搜词里“pnpm install 延长等待时间”这类问题,经常被误判为版本问题。我见过一个项目,安装耗时三分钟,最后排查发现是 registry 配置指向了一个很慢的平台,pnpm 本身只占了不到三分之一时长。
遇到安装慢,按这个顺序排查:
- 看是不是首次下载,store 里没有缓存。
- 看日志里是卡在 download、resolve 还是 link。
- 手动下载一个最大依赖包的 tarball,测试网络速度。
- 临时切换镜像源再测一次。
- 对比不同 pnpm 版本在同样缓存下的差异。
不要一上来就把并发调成 64,也不要急着换回 npm。先找到瓶颈在哪一段,再决定改参数还是改网络配置。
6. CI 和 monorepo 环境下,不能只关注下载速度
6.1 CI 里的缓存策略
本地环境可以容忍一个较大的 store,CI 环境不能随便扔文件,因为每次都是新的临时目录。为了提升 CI 构建速度,需要把 pnpm store 放到一个可恢复的缓存路径里。
常见的做法是:
# .github/workflows/ci.yml 片段示意 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 12 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run build这里有一个关键习惯:CI 里尽量使用--frozen-lockfile。它的作用是严格按 pnpm-lock.yaml 安装,锁文件有任何变动都直接报错。这样能避免有人本地改了依赖但没有提交 lock 文件,导致 CI 和本地安装结果不一致。
6.2 并发、超时和失败重试
依赖很大的项目,在 CI 上安装时容易卡在依赖下载。不要无限提高并发,先观察一段时间内的稳定性。
常用参数:
| 参数 | 作用 |
|---|---|
--network-timeout 600000 | 加长单次网络请求的超时时间 |
--fetch-retries 5 | 下载失败后的重试次数 |
--fetch-retry-factor 2 | 重试延迟的增长系数 |
--child-concurrency | 同时运行的子进程数量 |
--workspace-concurrency | workspace 内并行执行的最大数量 |
如果你不知道该怎么设,先在本地跑一次默认配置,观察日志里有没有超时重试。有的话再按当前网络情况调整。
6.3 monorepo 批量任务要用好 --filter
pnpm 很适合 monorepo,但如果你在 monorepo 里跑pnpm run build,要注意它到底在跑哪些子项目。用--filter可以精确控制:
pnpm run build --filter @your-project/core也可以按目录过滤:
pnpm run build --filter "./packages/*"批量发布或批量测试时,建议先列出需要执行的包,再做全量并行。不然很容易出现依赖关系还没构建完,下游包已经启动构建的情况。
CI 里看日志也要分清楚:pnpm 是把多个包并行构建的,一个子项目报错,不代表所有包都失败。所以排查时先看具体是哪个 package 卡住,再点开对应的日志,不要只看主进程退出码。
7. 遇到问题先按这个顺序排查
7.1 先分类现象
项目装了 pnpm 之后报错,先不要笼统说“pnpm 有问题”,先看现象归属哪一类:
- 命令找不到:属于 PATH 或安装方式问题。
- 版本报错:属于 Node 版本或 pnpm 版本不匹配。
- 依赖下载慢:属于网络、镜像、缓存问题。
- 构建脚本失败:通常是原生模块编译或未声明依赖问题。
- 安装成功但运行时报模块不存在:重点查 node_modules 结构和幽灵依赖。
- 锁文件冲突:查 Git 合并冲突和 lock 文件版本。
7.2 一个可以直接套用的检查清单
| 检查项 | 使用命令 | 达标标准 |
|---|---|---|
| Node 版本 | node -v | 不低于 pnpm 12 要求 |
| pnpm 版本 | pnpm -v | 确认已切到目标版本 |
| 命令是否全局可用 | where pnpm或which pnpm | 能定位到可执行文件 |
| registry 是否正常 | pnpm config get registry | 返回可访问的镜像地址 |
| store 路径是否稳定 | pnpm store path | 确认路径存在且非临时目录 |
| lock 文件是否存在 | 查看 pnpm-lock.yaml | 提交前至少有一份 |
| 脚本是否跑通 | pnpm run build | 在默认配置下能成功 |
7.3 常见误判
有一个非常常见的场景:Windows 用户用 PowerShell 执行pnpm,提示“无法将 pnpm 项识别为 cmdlet”。很多人以为是 pnpm 坏了,到处卸载重装。实际上大部分原因就是 npm 全局目录没有进 PATH,或者 PATH 环境变量改完之后没有重新打开终端。
还有一个场景:pnpm 安装成功后,执行pnpm run dev报错,项目里配置了.npmrc或.pnpmfile.cjs。如果你是从旧的 pnpm 6 或 8 项目升上来的,这些配置里的字段可能已经废弃,需要逐一确认。
另一个误判是“pnpm 安装太快是不是没装全”。如果你用的是热安装,大部分包在 store 里有缓存,安装过程非常快是正常的。判断到底有没有装全,看两个东西:一是退出码是否为 0,二是pnpm list的输出是否完整,不要只看终端刷屏速度。
7.4 清理和重装的小技巧
遇到实在查不清的依赖问题,可以按这个顺序重置一次:
pnpm store prunerm -rf node_modulesrm -rf pnpm-lock.yamlpnpm install但注意:清空 lock 文件再重新生成,会产生一次新的依赖解析。如果你对 lock 文件的版本变化很敏感,清理之前一定要保留原 lock 文件备份。生产环境或多人协作时,不建议随便删除 lock 文件。
如果只是想清掉全局 pnpm,可以执行:
npm rm -g pnpm如果是通过 @pnpm/exe 安装的:
npm rm -g @pnpm/exe如果再配合 Corepack,还要确认 Corepack 里是否还缓存了 pnpm 的版本,避免卸载之后pnpm -v仍然能用。
7.5 留档和团队规范
最后建议团队把 pnpm 使用规范写进项目文档,不用写太长,至少包含这几条:
- 使用统一 Node 版本,必要时加 .nvmrc。
- 使用 packageManager 字段固定 pnpm 版本。
- 提交 pnpm-lock.yaml 到 git。
- CI 里使用
--frozen-lockfile。 - 新增代理依赖时,先确认是否在 package.json 中显式声明。
- 切换镜像时统一 registry,不要求每个开发者各自改配置。
我在看这个文章时,通常不会只回答“pnpm 12 快不快”,而是优先看一个项目能不能稳定迁移、批量构建时会不会隐藏问题。如果你正准备升级到 pnpm 12,我的建议很直接:先选择一个中小型项目做迁移测试,把锁文件、镜像和 CI 缓存都跑顺了,再逐步推到大项目。等到单任务链路稳定之后,再根据日志和耗时数据决定要不要调整并发和缓存策略。性能提升不是一次性结论,是一个可以持续优化的过程。