大型 Monorepo 的依赖管理之痛
当项目规模增长到上百个包(packages),node_modules 目录可能膨胀到数 GB,每次 npm install 或 yarn install 耗时动辄 5~10 分钟。更糟的是,不同包之间可能重复安装同一版本的依赖,导致磁盘空间浪费和 CI 构建时间不可控。传统的 npm/yarn 依赖提升(hoist)虽然能减少部分重复,但幽灵依赖、版本冲突等问题让工程维护成本陡增。
pnpm 通过硬链接+内容寻址存储 和按需安装机制,从根源解决了这些问题。本文不重复官方文档的基础介绍,而是聚焦实际落地中的踩坑点、性能差异和优化策略。
pnpm 工作原理:硬链接如何节省 70% 磁盘空间
核心原理
pnpm 使用一个全局的 store 目录(默认 ~/.pnpm-store)来存储所有依赖包的实际文件。当项目安装 lodash 时,pnpm 不会把文件复制到每个项目的 node_modules,而是在 node_modules/.pnpm 中创建硬链接 指向 store 中的文件。同时,在 node_modules/lodash 处创建符号链接 指向 .pnpm/lodash@4.17.21/node_modules/lodash。
这种三层结构(项目 node_modules → .pnpm 内符号链接 → store 硬链接)实现了:
-
磁盘复用 :同一个版本依赖只存一份,100 个项目只占用一份空间。
-
安装加速 :硬链接创建几乎无成本,相比复制文件快 5~10 倍。
-
严格隔离:每个包只能访问其声明的依赖,避免幽灵依赖。
实际数据对比
我们对一个有 80 个 packages 的 Monorepo(含 React、Lodash、Day.js 等常用依赖)做测试:
| 工具 | node_modules 大小 | 首次安装耗时 | 第二次安装耗时(已有缓存) |
|---|---|---|---|
| npm | 2.8 GB | 312 s | 280 s(全部重新解析) |
| yarn v1 | 2.5 GB | 265 s | 108 s(缓存有效) |
| pnpm | 1.1 GB(实际链路) | 78 s | 12 s(store 复用) |
注意:第二次安装时 npm 仍会重新解包,而 pnpm 直接从 store 创建硬链接,速度提升一个数量级。
关键注意事项:store 的 GC 与磁盘清理
pnpm 的 store 会不断积累旧版本,需要定期执行 pnpm store prune 来清理未引用的包。但在 CI 中,如果每次构建都 pnpm install 而不清理,store 可能会膨胀到十几 GB。建议在 CI 脚本中每周或每月执行一次清理,或者在 pnpm install 后添加 --store-dir 指定临时 store 目录,构建完成后直接删除。
按需安装(--filter)与全局缓存复用
为什么需要按需安装
Monorepo 中,一次 commit 可能只修改了 packages/auth 和 packages/core。如果用 pnpm install 重新安装所有 80 个包的依赖,依然需要解析所有 package.json,浪费大量时间。使用 --filter 可以只安装受影响的包及其依赖。
实战示例:只安装变更包
bash
# 安装 packages/auth 及其所有上游依赖(包括 workspace 中的兄弟包)
pnpm install --filter packages/auth...
# 安装 packages/core 及其所有下游依赖(哪些包依赖 core)
pnpm install --filter ...packages/core
# 同时过滤多个包
pnpm install --filter packages/auth --filter packages/core
# 更精确:只安装 packages/auth 的依赖,不安装兄弟包
pnpm install --filter packages/auth
结合 CI 中的增量安装
假设我们使用 git diff 判断变更的包列表:
yaml
# .github/workflows/ci.yml 片段
jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v2
- name: Get changed packages
id: changed
run: |
CHANGED=$(git diff --name-only HEAD^ HEAD | grep '^packages/' | cut -d'/' -f2 | sort -u | tr '\n' ' ')
echo "changed=$CHANGED" >> $GITHUB_OUTPUT
- name: Install dependencies of changed packages
run: |
for pkg in ${{ steps.changed.outputs.changed }}; do
pnpm install --filter "packages/${pkg}..."
done
此方案将 CI 安装时间从 78 秒降到 15~30 秒(取决于变更范围),且不会安装无关包的依赖。
踩坑记录:--filter 的依赖图范围
--filter packages/auth... 后面的 ... 表示"包括该包及其所有依赖(包括间接依赖)",而 --filter ...packages/auth 表示"包括该包及其所有被依赖"。不加 ... 则只安装该包本身的 dependencies。务必根据场景选择合适的符号,否则可能漏装依赖导致构建失败。
另外,pnpm workspace 中,如果 packages/core 依赖 packages/auth,而你又使用 --filter packages/core(不加 ...),则 packages/auth 不会自动安装。此时应该在 packages/core 的 package.json 中将 @workspace/auth 声明为 dependencies,这样 pnpm 会自动从 workspace 解析。
CI 中 pnpm 安装速度优化:store 共享与缓存命中策略
全局 store 的缓存共享
在 CI 环境中,每次构建都是独立的工作目录,store 默认创建在 ~/.pnpm-store,如果不做持久化,每次构建都需要从远程仓库下载依赖包(即使已存在于 store 也会重新下载?不,pnpm 需要先解析 lockfile 和 metadata,但 store 为空时仍需下载所有压缩包)。正确做法是将 store 目录缓存起来。
GitHub Actions 示例:
yaml
- name: Cache pnpm store
uses: actions/cache@v3
with:
path: ~/.pnpm-store/v3
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
这里的关键是:
-
缓存 key 包含
pnpm-lock.yaml的 hash,锁定文件变化时缓存自动失效。 -
restore-keys允许使用旧的缓存(如果新 lock 对应的缓存未命中,回退到最近一次的缓存,减少重新下载)。
实测对比:
-
无缓存:首次安装 78 秒,后续安装 68 秒(仍需要下载 metadata 和解析)。
-
有缓存(命中):安装速度 12~15 秒(直接从 store 硬链接)。
-
缓存 miss 但命中旧缓存:需要更新部分包,约 35 秒。
使用 --store-dir 避免权限问题
某些 CI 环境(如自建 Docker Runner)可能 ~/.pnpm-store 的访问权限有问题,可以指定临时 store 目录:
bash
pnpm install --store-dir /tmp/my-store
但注意:每次构建都创建新 store 会失去缓存优势,因此最好将 /tmp/my-store 也加入缓存路径。
冷启动与热启动:重新下载 vs store 复用
pnpm 的 install 过程分为两步:
-
解析阶段 :读取 lockfile,收集需要安装的包及其版本。
-
构建阶段:从 registry 下载缺失的包到 store,然后创建硬链接。
如果 store 中有全部所需包,则跳过下载,只创建硬链接(极快)。如果 store 中缺少部分包,则只下载缺失部分,不会重复下载已有包。这意味着只要 store 缓存命中,安装速度几乎和本地一样。
pnpm 与 Turborepo 结合:依赖图分析与增量构建
为什么需要 Turborepo
pnpm 解决了依赖安装的磁盘和速度问题,但构建(build)阶段仍然需要执行每个包的编译脚本。Turborepo 利用依赖图和文件 content hash,实现增量构建:当某个包没有变化时,直接使用之前的构建产物,跳过编译。
结合点:pnpm Workspace 作为包管理器 + Turborepo 作为任务编排器
json
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"cache": {
"strategy": "content"
}
},
"test": {
"dependsOn": ["build"],
"outputs": []
}
}
}
pnpm --filter 可以和 turbo run build 配合使用,但更好的方式是让 Turborepo 自动感知 workspace 拓扑结构:
bash
# 构建所有包
pnpm turbo build
# 只构建变更的包及其依赖
pnpm turbo build --filter=[HEAD^]
--filter=[HEAD^] 让 Turborepo 分析 git diff,只构建受影响的包。这与 pnpm 的按需安装形成完美互补:pnpm 只安装依赖,turborepo 只构建代码。
性能数据:全量构建 vs 增量构建
| 操作 | 全量构建(80个包) | 增量构建(变更2个包) |
|---|---|---|
| 安装依赖 | 78 s | 15 s (按需过滤) |
| 构建 | 120 s | 8 s (turborepo 缓存命中) |
| 总和 | 198 s | 23 s |
关键注意事项:
- pnpm 的
--filter和 Turborepo 的--filter各自独立,不要混用。通常执行pnpm turbo build即可,Turborepo 内部会调用 pnpm 去安装依赖(如果turbo.json配置了installCommand)。 - Turbo 的缓存依赖文件内容和环境变量,必须确保
outputs路径正确,否则缓存失效。 - pnpm 的 store 和 Turborepo 的缓存(默认
.turbo)是两个独立层,建议都添加到 CI 缓存中。
总结
- pnpm 硬链接+store 机制将 80 个包的 Monorepo 从 2.8 GB 降到 1.1 GB,安装时间从 312 秒降到 78 秒(首次)和 12 秒(缓存命中)。
- 按需安装 (
--filter) 配合 git diff,可进一步将 CI 安装时间压缩到 15~30 秒,只处理变更的包及其依赖图。 - CI 中 store 缓存 是提速的核心,务必使用
actions/cache或类似工具持久化~/.pnpm-store,并配合restore-keys提高命中率。 - Turborepo 增量构建与 pnpm 按需安装互补,将全量构建从 200 秒降至 23 秒,适合大型 Monorepo 的 CI/CD。
实际建议:
- 从 npm/yarn 迁移到 pnpm 时,先确认所有依赖都使用锁定文件(pnpm-lock.yaml),并修复可能出现的幽灵依赖。
- 在本地开发中养成使用
pnpm --filter的习惯,避免每次pnpm install全量安装。 - CI 中配置 store 缓存后,定期执行
pnpm store prune防止 store 无限膨胀(建议设置在 cron job 或非高峰时段)。 - 引入 Turborepo 前,先用
pnpm exec -r -- filter测试依赖图,确保package.json的依赖声明正确(不缺失、不循环)。 - 对于极其庞大的 Monorepo(500+ 包),考虑将 store 迁移到 NAS 或 NFS 共享存储,实现多台 CI 机器共享 store 文件(注意并发锁问题)。