大型 Monorepo 的依赖管理之痛当项目规模增长到上百个包packagesnode_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/lodash4.17.21/node_modules/lodash。这种三层结构项目node_modules→.pnpm内符号链接 → store 硬链接实现了-磁盘复用同一个版本依赖只存一份100 个项目只占用一份空间。-安装加速硬链接创建几乎无成本相比复制文件快 5~10 倍。-严格隔离每个包只能访问其声明的依赖避免幽灵依赖。实际数据对比我们对一个有 80 个 packages 的 Monorepo含 React、Lodash、Day.js 等常用依赖做测试工具node_modules 大小首次安装耗时第二次安装耗时已有缓存npm2.8 GB312 s280 s全部重新解析yarn v12.5 GB265 s108 s缓存有效pnpm1.1 GB实际链路78 s12 sstore 复用注意第二次安装时 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/checkoutv4 with: fetch-depth: 2 - uses: pnpm/action-setupv2 - 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/cachev3 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 结合依赖图分析与增量构建为什么需要 Turborepopnpm 解决了依赖安装的磁盘和速度问题但构建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 s15 s (按需过滤)构建120 s8 s (turborepo 缓存命中)总和198 s23 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的依赖声明正确不缺失、不循环。对于极其庞大的 Monorepo500 包考虑将 store 迁移到 NAS 或 NFS 共享存储实现多台 CI 机器共享 store 文件注意并发锁问题。