大型 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可以只安装受影响的包及其依赖。
实战示例:只安装变更包
# 安装 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 判断变更的包列表:
# .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 示例:
- 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 目录:
pnpm install --store-dir /tmp/my-store但注意:每次构建都创建新 store 会失去缓存优势,因此最好将/tmp/my-store也加入缓存路径。
冷启动与热启动:重新下载 vs store 复用
pnpm 的install过程分为两步:
1.解析阶段:读取 lockfile,收集需要安装的包及其版本。
2.构建阶段:从 registry 下载缺失的包到 store,然后创建硬链接。
如果 store 中有全部所需包,则跳过下载,只创建硬链接(极快)。如果 store 中缺少部分包,则只下载缺失部分,不会重复下载已有包。这意味着只要 store 缓存命中,安装速度几乎和本地一样。
pnpm 与 Turborepo 结合:依赖图分析与增量构建
为什么需要 Turborepo
pnpm 解决了依赖安装的磁盘和速度问题,但构建(build)阶段仍然需要执行每个包的编译脚本。Turborepo 利用依赖图和文件 content hash,实现增量构建:当某个包没有变化时,直接使用之前的构建产物,跳过编译。
结合点:pnpm Workspace 作为包管理器 + Turborepo 作为任务编排器
// turbo.json { "pipeline": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"], "cache": { "strategy": "content" } }, "test": { "dependsOn": ["build"], "outputs": [] } } }pnpm --filter可以和turbo run build配合使用,但更好的方式是让 Turborepo 自动感知 workspace 拓扑结构:
# 构建所有包 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 文件(注意并发锁问题)。