pnpm — 全机器只存一份的 Node 包管理器
是什么
pnpm 是 Node.js 的包管理器,核心特点是全机器只存一份依赖。日常类比:像图书馆借书——每本书馆里只放一份,读者拿走的是”借书证”指向同一本书;而不是每个读者都自己印一份带回家。
你跑 pnpm install,pnpm 把每个文件按它的 sha256 哈希丢到 ~/.pnpm-store/ 这个全机器共享仓库,再在你项目的 node_modules/ 里建硬链接指过去。
# 项目 A 和项目 B 都依赖 react 18.2.0# react 的 index.js 在你硬盘上只有 1 个 inode# 项目 A 和 B 的 node_modules 里都是硬链接指向同一份结果:100 个 React 项目,react 的源码文件只占用 1 份磁盘。npm / yarn classic 是每个项目复制一份,pnpm 把它换成硬链接共享。
为什么重要
不理解 pnpm 的设计,下面这些事都没法解释:
- 为什么同样装 100 个项目,pnpm 的
~/.pnpm-store只占几 GB,而node_modules/们却累计十几 GB——硬链接共享 inode - 为什么 pnpm 项目里
require('lodash')报错”找不到”,明明node_modules里看得到——它没声明在 package.json 里(叫 phantom dependency) - 为什么 monorepo 工具链(Vue / Nuxt / Vite / Astro / Prisma)默认推 pnpm 而不是 npm
- 为什么 Windows 普通用户跑 pnpm 经常炸——symlink 创建权限默认关闭
核心要点
pnpm 的设计可以拆成 三件事:
-
内容寻址存储(CAS):每个文件按 sha256 落到
~/.pnpm-store/v3/files/<hex[:2]>/<hex[2:]>。前 2 个 hex 字符做一级目录,避免单文件夹百万 inode。类比:每本书按书号入库,不按作者分类——同一字节流的文件自动去重。 -
硬链接 + symlink 双层投影:项目里
node_modules/.pnpm/<pkg>@<ver>/node_modules/<pkg>/的每个文件是硬链接到全局仓库;顶层node_modules/<pkg>是 symlink 指向.pnpm/...。Node 找包走 symlink,磁盘共享走硬链接。 -
workspace 协议:monorepo 里
package.json写"@org/utils": "workspace:^1.0",pnpm 把它解析成本地 workspace 包;publish 时自动展开成实际版本号。npm registry 不认workspace:前缀,pnpm 在发包前帮你 strip。
三件事合起来:严格依赖边界 + 磁盘共享 + monorepo 一等公民——npm / yarn classic / yarn berry 各自只拿到其中一两件。
实践案例
案例 1:从 npm 切到 pnpm 的最小步骤
# 卸了原来的 node_modules 和 package-lock.jsonrm -rf node_modules package-lock.json# 用 pnpm 装一遍pnpm install# 顶层只看到你声明的包,被 hoist 出来的 phantom dependency 消失了ls node_modules切完第一次跑可能会炸——以前能 require 的包现在报错。这是 pnpm 在告诉你”这个包你没声明,赶紧加进 package.json”。这个报错是好事,是 phantom dependency 在编译期暴露。
案例 2:monorepo 用 workspace 协议引本地包
packages: - 'packages/*' - 'apps/*'{ "dependencies": { "@org/utils": "workspace:^1.0.0", "lodash": "^4.17.21" }}workspace:^1.0.0 告诉 pnpm “去 workspace 里找 @org/utils,版本要满足 ^1.0.0”。比 npm link 体验好两个数量级——不用手动 link / unlink,改一行配置就生效。
案例 3:验证硬链接是真的共享 inode
# 找项目里某个文件的 inodestat -f '%i' node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.js# 假设输出 1234567
# 在全局仓库里反查同一个 inodefind ~/.pnpm-store/v3/files -inum 1234567# 输出指向某个 hash 路径,证明它和项目里的文件是同一个 inode-inum 找 inode 号——硬链接的本质是”多个路径指向同一个 inode”。这个实验让你亲眼看到 pnpm 的核心机制不是”复制再 dedupe”,是文件系统层面的共享。
踩过的坑
-
store 不会自动 GC:
~/.pnpm-store一直长,几年后到几十 GB 是常态。需要手动pnpm store prune清理无引用文件,否则磁盘节省的好处会被反噬。 -
跨设备硬链接失败回退到复制:项目和 store 在不同 mount(Docker 跨卷 / Windows 跨盘 / NFS)时报
EXDEV,pnpm fallback 到 copy,磁盘节省全部失效——错误是 graceful 但用户感知不到,除非看 install log。 -
Windows 默认要开发者模式:普通 Windows 用户没有创建 symlink 的权限,
node_modules/<pkg> -> .pnpm/...会失败。逃生口是设node-linker=hoisted退化成 npm 风格,但放弃了严格依赖边界。 -
手改
pnpm-lock.yaml会破坏一致性:这文件长得像 yaml 但它是 pnpm 内部依赖图的序列化形态。要改依赖只改 package.json 然后重跑pnpm install,不要直接编辑 lockfile。
适用 vs 不适用场景
适用:
- monorepo(>3 个包)——
workspace:*协议是 pnpm 在这个场景碾压 npm 的关键 - 磁盘紧张的开发机 —— 100 个 Node 项目能省 10+ GB
- 团队需要严格依赖边界 —— phantom dependency 在编译期就报错,不会发版后炸
- CI 缓存
~/.pnpm-store—— 用pnpm install --frozen-lockfile配合 lockfile 一致性检查
不适用:
- 单包小项目 ——
.pnpm/中转目录是纯开销,npm/yarn 简单足够 - Windows 普通用户环境 —— 没开发者模式时 symlink 失败
- Docker 镜像里 node_modules 和 store 跨 mount —— 硬链接退化成复制,磁盘节省失效
- 直接缓存
node_modules/的 CI 流水 —— 跨 build 复用硬链接快照会失败,要缓存就缓存 store + lockfile
历史小故事(可跳过)
- 2013 年:Node 生态默认 npm v2,嵌套
node_modules让 Windows 路径长度爆炸 - 2015 年:npm v3 引入 flat hoist 解决路径过长,副作用是 phantom dependency 横行
- 2016 年:Zoltan Kochan 在乌克兰发起 pnpm,初版就是”硬链接共享 store + 严格 node_modules”
- 2020 年:Vite / SvelteKit 等新框架默认推荐 pnpm,monorepo 场景成为主战场
- 2024 年:pnpm v9 lockfile 加入 env 文档(捕获 nodeVersion / pnpmVersion),向 reproducible build 再走一步
之后 pnpm 成了 Vue / Nuxt / Vite / Vercel / Astro / Prisma / Storybook 等项目的默认选择。
学到什么
- 磁盘是有限资源——SSD 时代每个
node_modules200MB 不算大,但 100 个项目就是 20GB 浪费,硬链接是 Unix 几十年前就有的解法 - 协议解析和协议语义要分层——pnpm 的
workspace:*parser 只有 22 行,因为它只解字符串、不查包是否存在;上层 resolver 才管语义 - 保留兼容 ABI 才能不和生态对抗——yarn berry 选了消灭
node_modules,代价是和 IDE / TypeScript / loader 大量摩擦;pnpm 选了”形态不变、存储变” - 量化的优化决策——pnpm 源码里有”~30k calls per cold install / saves ~30ms” 这种注释,“觉得快”的优化进不了 main
延伸阅读
- 官方动机文档:pnpm.io/motivation(讲 pnpm 为什么要做硬链接共享)
- 仓库源码:github.com/pnpm/pnpm(30+ puzzle 包的 monorepo,自身就是 workspace 例子)
- 对比文章:Why pnpm? — Zoltan 在 dev.to 的系列(创始人讲设计取舍)
- npm-package-manager —— pnpm 的对照系
- yarn-berry-pnp —— 更激进的”消灭 node_modules”路线
- turborepo —— 常和 pnpm 搭档的 monorepo 任务编排器
关联
- npm-package-manager —— pnpm 的直接对照系,flat hoist vs 硬链接 + symlink
- yarn-berry-pnp —— 同样想解决 phantom dependency,但选了消灭
node_modules的激进路线 - bun-runtime —— Bun install 思路接近 pnpm(硬链接 + 全局 cache),但 lockfile 是二进制
- turborepo —— monorepo 任务图工具,常和 pnpm 搭配做大型仓库的 build 调度
- content-addressable-storage —— pnpm 的 CAS 设计和 Git object store / Nix store 同源
- unix-hardlink —— pnpm 全机器共享的底层能力,多路径共享一个 inode
- symlink-vs-hardlink —— pnpm 同时用两种链接,理解它们的差别才看得懂
node_modules的双层结构
反向链接
- bun —— Bun — JS 全能运行时
- changesets —— changesets — 让每个 PR 自带版本号 bump 声明
- dayjs —— Day.js — 用 2 KB 复刻 Moment 的极简日期库
- deno —— Deno — 安全优先的 JS/TS 运行时
- jimp —— jimp — 哪都能跑的纯 JS 图像处理库
- lerna —— lerna — 一个仓库发几十个 npm 包的祖宗工具
- mise —— mise — 一条命令切换项目用的 Node/Python/Go 版本
- nvm —— nvm — 在同一台机器上轻松切换 Node 版本
- nx —— Nx — 一个仓库装几十个项目时帮你少跑活的工具
- rolldown —— rolldown — 用 Rust 给 Vite 当统一引擎的打包器
- scoop —— Scoop — Windows 上的 Homebrew 风格命令行包管理器
- turborepo —— Turborepo — 让 monorepo 学会”哪些活已经干过了不要再干”