Chapter 03

内容寻址 + 硬链接 = 省磁盘的魔法

pnpm 把所有下载的包放到一个全局仓库(store),按文件哈希索引,全世界的项目共用——存 100 个 Next.js 项目的磁盘开销,约等于 1 个项目 + 每项目一点 symlink。

store 位置

pnpm store path
# macOS/Linux:  ~/.local/share/pnpm/store/v3
# Windows:     %LOCALAPPDATA%\pnpm\store\v3

每个磁盘盘符/分区独立一个 store——这很重要,后面讲硬链接的限制。

CAS:按内容哈希存储

store/v3/files/
├── 00/
│   ├── 1234abcd...ef            ← react index.js 的内容
│   └── ...
├── 01/
│   └── ff00aa11...              ← lodash debounce.js
├── ...
└── ff/

文件名就是它的 SHA-512 哈希。两个包里有两份相同的 LICENSE 文件(内容一样),在 store 里只占一份——不区分来自哪个包,只看内容。

CAS 是 Git 的发明
Git 对象库就是 content-addressable:文件按 SHA-1 哈希存 blob,commit/tree 是 blob 的引用。pnpm 把这套搬给 node_modules,收益:高度去重、完整性校验、可并行访问。

硬链接:0 拷贝引用

store/v3/files/ab/cd1234...         ← 真实磁盘块(8KB)
  ↑                                    (inode 被多处引用)
  ├── 项目 A/node_modules/.pnpm/react@18.3.1/node_modules/react/index.js
  ├── 项目 B/node_modules/.pnpm/react@18.3.1/node_modules/react/index.js
  └── 项目 C/node_modules/.pnpm/react@18.3.1/node_modules/react/index.js

硬链接不是拷贝:系统只记录「多了一个引用」,实际数据还是那一块。你在项目 A 打开 index.js 和在 B 打开,操作的是同一个物理文件。

硬链接 vs symlink

硬链接符号链接(symlink)
本质同一个 inode 的多个 dentry一个指向路径的"快捷方式"文件
跨文件系统❌ 不行✅ 可以
删除原文件数据仍在(其它链接仍有效)symlink 变死链
工具识别几乎无感(看起来就是文件)某些工具要 follow
pnpm 用来store → node_modules 文件层复用node_modules 目录层组织

为什么跨盘符不行

硬链接不能跨 partition/volume——数据块必须在同一个文件系统内。

# 场景:项目在 D 盘,store 在 C 盘
# pnpm 发现跨盘,退回到"复制"(仍然比 npm 快,不过没有去重)

# 解决:把 store 移到和项目同盘
pnpm config set store-dir D:\pnpm-store
# .npmrc
store-dir=D:\pnpm-store

store 内部结构

~/.local/share/pnpm/store/v3/
├── index/                     ← 包的索引(metadata)
│   └── react@18.3.1-<hash>.json
├── files/                     ← 内容寻址的实际文件
│   ├── 00/
│   ├── 01/
│   └── ...
├── tmp/                       ← 下载中转
└── v3/.storage                ← 访问时间等
// index/react@18.3.1-xxx.json 示例
{
  "name": "react",
  "version": "18.3.1",
  "files": {
    "index.js": {
      "checkedAt": 1732...,
      "integrity": "sha512-xxx",
      "mode": 420,
      "size": 3421
    },
    "package.json": { ... }
  }
}

完整性校验

每次安装,pnpm 会根据 lockfile 的 integrity 字段校验 store 里文件的哈希。被篡改/损坏 → 报错并重下。这是供应链安全的第一道门

# pnpm-lock.yaml 片段
packages:
  react@18.3.1:
    resolution:
      integrity: sha512-xxx...       // SRI 哈希
      tarball: https://registry.npmjs.org/react/-/react-18.3.1.tgz

store prune:清理没引用的包

pnpm store prune
# 扫 store,找到没有被任何项目引用的文件,删掉
# 日常不需要跑,几个月一次

删了也没事,下次 install 会重新下。

store status:检查一致性

pnpm store status
# 对比 store 里文件和 index 里记录的哈希,找损坏

CI 缓存 store

CI 最大的瓶颈是每次 install 重下所有包。pnpm store 是个目录,只要把它缓存下来,二次 CI 几乎零下载:

# GitHub Actions
- uses: actions/cache@v4
  with:
    path: ~/.local/share/pnpm/store
    key: pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: pnpm-store-

- run: pnpm install --frozen-lockfile

首次 CI:2 分钟;有缓存时:20 秒。再配合 Turborepo 的任务缓存,端到端 30 秒出构建产物。

Docker 多阶段利用 store

FROM node:20-alpine AS base
RUN corepack enable
WORKDIR /app

FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

FROM base AS runtime
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["pnpm", "start"]

--mount=type=cache 让 BuildKit 把 store 挂成 layer-less 缓存,跨构建持久——CI 构建速度从 3 分钟降到 30 秒。

真实节省

实测一台 Mac 上装 20 个 Next.js 项目:

总大小平均
npm(20 项目)11.8 GB590 MB/项目
pnpm store + 20 项目1.9 GB95 MB/项目
节省9.9 GB83%

CAS 的副作用

修改 node_modules 里的文件 = 改 store
因为是同一个 inode。你手动改了 node_modules/react/index.js,所有用 react@18.3.1 的项目都会看到改动。永远别手动改,改了也别期望保留——用 pnpm patch 正规做。(第 8 章)
node_modules 只读
好的 IDE 插件会识别 node_modules 只读,防止误编辑。
某些工具不认 symlink
极少,主要是老 bundler。遇到可以 node-linker=hoisted 临时降级。

本章小结