pnpm workspace实践:从依赖管理到构建编排的monorepo方案
发布时间:2026/10/10 3:48:17 锦皓数字建站

1. 为什么我最终选择了 pnpm workspace1.1 从 npm workspace 的岁月静好到分崩离析先说我自己的转变路径。我在一个中大型前端项目里最早用的是 npm workspace 配合一套自研的发布脚本。项目刚开始只有两三个包一切都很美好公共工具库放在 packages/shared两个业务应用各自引用install 一次搞定改动共享代码也能立即看到效果。但等包数量涨到十几个团队成员从几个扩张到几十个问题就开始集中爆发了。最让人头疼的就是依赖提升带来的幽灵依赖。npm 的 workspace 默认把所有依赖往上提公共的 node_modules 里堆着一大堆包业务代码里哪怕没有声明某个依赖也能顺手import到。短期看确实方便长期看就是一颗定时炸弹哪天某个间接依赖升级了版本或者被彻底移除了你的项目就会在完全没有预兆的情况下编译失败。我在一次版本升级中就在线上遇到了这种问题——某个业务包一直白嫖依赖包 A 传递出来的 loadsh 方法A 一升级整个页面直接白屏。所以我再做新项目的时候毫不犹豫把方案定为 pnpm workspace。它最核心的优势不是省磁盘装得快而是从依赖布局上做了一次彻底的重构把谁依赖谁这件事变得非常透明。这篇文章我尽量讲清楚它的核心机制、实操配置和我在真实项目中踩过的坑希望正在评估 monorepo 方案的团队能少走弯路。1.2 pnpm 的依赖布局强在哪里pnpm 的安装策略用一句话概括内容寻址存储 符号链接。所有依赖包的真实文件都存放在全局的 store 目录里项目内的 node_modules 里放的不是真实文件副本而是指向 store 的硬链接和软链接。每个包只能看到自己声明过的依赖以及这些依赖的传递依赖不能越界访问别人的 node_modules。这套机制跟 npm/yarn 的提升式布局有本质差别。打一个比方npm 的做法是所有人共用一个巨型公共书架谁都能顺手拿走不属于自己的书pnpm 则是给每个包发一个私人书架只有在自己dependencies/devDependencies里登记过的书才会摆上去。后者首次安装时会多一点链接操作但从可维护性来看收益远远覆盖成本。这也解释了为什么 pnpm 深受大型 monorepo 项目欢迎项目规模越大依赖关系越复杂越需要这种强约束。依赖关系清晰之后构建顺序、版本管理、CI 缓存都有了可靠的基础。2. 从零初始化一个 pnpm workspace2.1 目录骨架与核心配置文件初始化 pnpm workspace 并不需要什么脚手架你只要手动创建好目录结构和一个配置文件剩下的交给pnpm install就行。我目前常用的目录长这样my-monorepo/ ├── package.json ├── pnpm-workspace.yaml ├── pnpm-lock.yaml ├── .npmrc ├── tsconfig.base.json ├── packages/ │ ├── shared-utils/ │ ├── ui-components/ │ ├── admin-app/ │ └── biz-app/关键入口是pnpm-workspace.yamlpnpm 靠它识别哪些目录属于 workspace 包packages: - packages/** - !packages/**/test/**这里有一个我在实际项目中踩过的坑如果某个子目录下的 package.json 是残缺的比如只放了 name 没放 version或者你误把一些非包的目录如 e2e 测试套件、内部脚本目录暴露在 glob 匹配范围内pnpm 会直接报ERR_PNPM_NO_PACKAGE_MANIFEST。所以删除规则一定要写全尤其是当目录里存在专门存放测试 fixtures 或构建产物的地方时记得用!排除。根目录的.npmrc也值得提前配置好:link-workspace-packagestrue shared-workspace-lockfiletrue auto-install-peerstrue前面两个在 pnpm 中其实默认就是开启的但我习惯显式写上一方面让团队新人一眼看清行为另一方面防止某些情况下被外部配置覆盖。auto-install-peerstrue则建议在组件库类项目里保留pnpm 默认不会主动安装 peerDependencies如果项目里有 React 组件库之类的依赖关闭这个选项会频繁出现安装了但找不到依赖的诡异错误。2.2 根 package.json 与全局脚本设计根目录的 package.json 是整个 workspace 的调度中心。有两个细节需要注意private必须设为true避免粗心触发整仓发布根包的 scripts 尽量只放面向全 workspace的编排命令具体逻辑放到各子包的 scripts 里。我常用的根 scripts 大概是这样{ name: my-monorepo, private: true, scripts: { build: pnpm -r --sort build, dev: pnpm --parallel --filter \./packages/**\ dev, lint: pnpm -r lint, test: pnpm -r test, publish:packages: pnpm -r publish --no-git-checks } }pnpm -r --sort build会按依赖拓扑关系自动决定执行顺序shared-utils 一定在 ui-components 之前构建ui-components 一定在 admin-app 之前构建。pnpm --parallel --filter ./packages/** dev则用并行方式把所有包的 dev 服务一次性拉起来本地联调时非常方便不用一家家登录终端手动启动。这里提醒一下--sort在构建场景几乎是必须的。假如 admin-app 依赖 ui-components你却在 ui-components 还没编译完成时就去编译 admin-app大概率会得到一堆Cannot resolve module的报错。事后排查才发现不是代码错了而是任务编排出了问题这种时间浪费完全可以靠参数规避。2.3 从 lerna 迁移的最低成本路径现在还有不少老项目跑在 lerna npm 的组合上。我的建议是不要一次性推翻重来可以先保留 lerna.json只把安装和日常脚本切换到 pnpm观察几周稳定性后再逐步迁移发布流程。迁移时第一步是删掉旧的 node_modules 和 package-lock.json或 yarn.lock然后新建 pnpm-workspace.yaml执行pnpm install。大多数情况下pnpm 会自动复用 package.json 里的依赖声明第一次 install 会因为要重建全局 store 和链接而稍慢之后就会稳定下来。需要重点检查的是各子包之间互相引用的版本号——如果之前是直接写死版本号比如shared-utils: 1.0.3建议迁移时顺手改成workspace:*这样后续迭代就不用手动同步版本了。lerna 的命令过渡也很直接lerna run build→pnpm -r --sort buildlerna run dev --parallel→pnpm --parallel --filter ./packages/** devlerna changed→ 用 changesets 或 git diff 脚本替代真正需要保留 lerna 的场景主要是独立版本 自动生成 changelog 逐个 publish这套成熟发布链路如果团队已经用得很顺不必为了统一工具链而强制替换。3. workspace 依赖管理的核心机制3.1 workspace: 协议怎么用以及为什么用它pnpm 处理本地包互引用的方案是 workspace 协议。比如在 admin-app 的 package.json 里想引用 ui-components不要写死版本号而是这样声明{ dependencies: { ui-components: workspace:*, shared-utils: workspace:* } }workspace:*的含义是引用当前仓库内对应名称的包并自动解析它的真实版本。开发阶段pnpm 会在 node_modules 里建立符号链接修改 ui-components 的源码后admin-app 的重新编译能立即感知到变化不需要重新 install也不需要发一个测试版本。当这个包被pnpm publish发布时pnpm 会把workspace:*自动重写成实际的 semver 版本号比如workspace:*变成1.0.3。这就保证了线上消费者拿到的是标准 npm 依赖不会因为链接逻辑而出问题。除了通配符*你还可以用workspace:^、workspace:~或者直接跟具体版本号。我在实践中几乎都用*因为大多数 monorepo 场景下引用本地包时永远最新是最符合直觉的只有遇到特殊锁定需求的场景我才会精确到版本。3.2 版本策略统一版本还是独立版本很多团队在 monorepo 里纠结的第一个问题是所有包用同一个版本号还是各自独立版本。这两种模式各有适用场景没有绝对优劣。统一版本fixed/locked mode的操作成本低release 流程很简单一个版本号覆盖所有包发版时统一打 tag、统一升版。适合整体式产品比如一个平台同时包含后台、前台和公共库发布节奏一致。缺点是版本粒度太粗哪怕只改了一个工具函数所有依赖它的包都得同步升版版本号很快就失去了信息量。独立版本independent mode则更灵活每个包按自己的节奏发版适合被外部团队单独消费的库类项目。代价是版本编排和 changelog 管理变复杂。我自己的经验是如果仓库里有对外发布的组件库或工具库独立版本几乎是必然选择否则下游无法感知精细变更。无论是哪种模式我推荐引入 changesets 统一管理。它的工作流是每次有变更开发者执行pnpm changeset生成一个变更描述文件发版时pnpm changeset version统一计算新的版本号并生成 changelog最后pnpm -r publish发布。这套流程跟 pnpm workspace 配合得很顺因为 pnpm 会在 publish 时自动改写 workspace 协议。3.3 dependencies、devDependencies 与 peerDependencies 的边界这个看似基础的划分在 monorepo 里尤其重要。我见过太多子包把构建工具如 vite、tsc、eslint塞进 dependencies导致业务包安装时毫无必要地拖入一整套构建链安装时间翻了数倍不说还有可能引发依赖版本冲突。在 workspace 语境下一条简单的判断标准是如果这个包是要被其他包引用的只把运行时真正需要的库放进 dependencies其他全部进 devDependencies。比如 shared-utils 如果只被编译产物消费它的构建依赖就该归入 devDependencies组件库对外暴露的类型和 React 运行时依赖则应该通过 dependencies 和 peerDependencies 妥善表达。peerDependencies 的坑我在 3.2 节提过这里再补一个亲历案例某次我在组件库中升级了 React 的类型版本但没有相应调整主应用的 React结果组件库的 peerDependencies 检查直接失败。开启auto-install-peerstrue后 pnpm 会尝试自动补装但更稳妥的做法是在组件库的 devDependencies 中显式安装一份 peer 依赖用于本地类型检查同时在 peerDependencies 中声明宿主版本范围。这样本地测试是完整的线上也不会有多余的重复安装。4. 日常开发中的高频命令与构建编排4.1 --filter 过滤语法monorepo 里的灵魂命令在 monorepo 中最常见的需求是我只想跑某几个包的命令而不是每次全量执行。pnpm 的--filter简写-F就是干这个的它支持的语法比大部分人想象中灵活# 只跑一个包 pnpm --filter admin-app build # 跑某个包及其所有依赖包上游包 pnpm --filter admin-app... build # 跑某个包及其所有被依赖包下游包 pnpm --filter ...shared-utils test # 按 scope 匹配 pnpm --filter my-repo/* lint # 按目录匹配 pnpm --filter ./packages/ui-components dev三个点的位置决定方向后缀三个点代表它依赖了谁前缀三个点代表谁依赖了它。这两个方向各有用途。比如工具库 shared-utils 改了 API你想确认所有下游包没有坏就可以pnpm --filter ...shared-utils build反过来如果 admin-app 要处理一个跨包问题你需要先把它的上游都构建好就可以pnpm --filter admin-app... build。我自己的使用习惯是尽量用目录路径或 scope 做过滤少用裸包名。因为迁移或改名时裸包名变化频次更高路径和 scope 相对稳定。另外--filter也支持./packages/**这种 glob适合批处理场景。4.2 并行与串行的选择dev 和 build 不能一个套路所有 workspace 级别的任务都要先想清楚到底要不要并行。pnpm 提供了两个维度-r代表递归执行--parallel代表并行执行--sort代表按拓扑顺序执行。这三者可以组合使用但组合逻辑不能凭感觉。对于dev我几乎一定会用pnpm --parallel --filter ./packages/** dev。因为每个 dev server 都是独立的长驻进程各自监听端口、各自热更新彼此之间没有依赖关系串行执行只会让后面的包干等前面的包把 server 拉起来白白浪费几十秒。对于build我坚持用pnpm -r --sort build。原因很简单构建通常有依赖关系比如 admin-app 要引用 ui-components 的产物必须先等 ui-components 构建完成。如果你贸然用--parallel并行 build大概率会出现模块找不到或读到了旧版本产物的间歇性问题排查起来极其痛苦。另外当并行任务太多导致内存吃紧时可以用--workspace-concurrency手动限制并发数pnpm -r --workspace-concurrency2 build这个参数在 CI 上尤其好用。默认情况下 pnpm 会按 CPU 核心数决定并发度但内存受限的容器里过高的并发反而会让构建时间变长甚至 OOM手动调低通常更稳。4.3 增量构建让大型 monorepo 的 CI 不再慢慢吞吞当 workspace 里的包数量超过十个全量构建的代价会越来越高。大多数情况下一次改动只涉及少数几个包其余包完全不需要重新构建。所以 CI 流水线里一定要做增量构建。pnpm 本身不提供任务级缓存但它提供了精确的过滤能力可以配合其他工具实现只处理变更包的效果。一种做法是用 changesets 管理发布版本它会生成哪些包发生了变化的信息你可以在 CI 中读取这些信息再喂给--filter。另一种做法是用 nx 或 turborepo 这类带任务缓存的构建编排工具它们对 pnpm workspace 有官方适配能记录每个任务的输入指纹并跳过未变更的任务。我在一个超过二十个包的项目里实践过把 CI 主流程从全量pnpm -r build改成只构建受影响的包及其下游包后流水线从接近二十分钟压到了五分钟以内。改动的成本并不高核心就是准确确定变更集合剩下的交给--filter ...changed去传播执行范围。5. 踩坑实录node_modules 结构、幽灵依赖与发布链路5.1 理解 pnpm 的 node_modules 与 store 布局切换到 pnpm 后你会发现项目根目录的 node_modules 不再是熟悉的平铺结构而是多了一个.pnpm目录里面是一堆类似react18.2.0的带版本号的子目录。这些目录里的文件并不是拷贝而是指向全局 store 的硬链接。各包声明依赖后pnpm 会在它们的 node_modules 里放软链接指向.pnpm中对应的真实位置。这种布局刚开始会让人不太习惯尤其是排错时你想直接打开 node_modules 里的某个包看源码路径会变得更长。但它带来的收益是实打实的同一个依赖版本在全局 store 里只存一份所有项目共享不同包可以安全地使用不同版本的同一依赖互不干扰由于只能访问声明的依赖幽灵依赖被从物理层面消灭了。如果你确实需要老式平铺布局来兼容某些旧工具链pnpm 也提供了--shamefully-hoist选项。但我不建议默认开启它会把 pnpm 引以为傲的隔离优势全部打回原形。更好的做法是找到那个不兼容的旧工具用替换或禁用 postinstall 等方式处理而不是牺牲整个依赖架构来迁就它。5.2 幽灵依赖的经典翻车与修复过程我在前面提到过一个幽灵依赖导致线上白屏的案例这里展开讲讲完整排查过程。某个业务包里没有声明 lodash但代码里用到了_.debouncenpm 时代因为依赖提升一直能跑。切到 pnpm 后构建直接报Cannot find module lodash但诡异的是本地跑又是好的——原因是本地 node_modules 里还残留着 npm 时代的旧目录pnpm 的链接被旧目录干扰了。这种问题修复本身很简单把 lodash 显式加入对应包的 dependencies 即可。但真正值得做的是从流程上防住它。我在迁移后给 ESLint 加了import/no-extraneous-dependencies规则并规定新增 import 的依赖必须先声明同时在 CI 里增加了一次全仓依赖扫描检查每个包是否引用了未声明的依赖。虽然初期会报出一堆存量问题但修完后整个项目的依赖关系就非常健康了。这里有一个值得注意的细节pnpm 的隔离性并不阻止你在代码里写未声明依赖的 import它只会在安装时不给你提供链接。也就是说错误不是安装期产生的而是编译期才暴露。所以不要把 pnpm 当成测试工具它只是诚实地让本来就不合法的依赖关系现出原形。5.3 循环依赖与后置脚本白名单monorepo 里出现循环依赖的典型场景是A 包引用了 B 包的类型B 包的测试工具又引用了 A 包的某个常量。pnpm 遇到循环依赖时不会直接拒绝而是尝试按拓扑排序处理其中一部分再处理剩下的但结果依赖解析顺序不保证每次一致所以隐患很大。我的处理顺序通常是先看能否通过调整代码结构切断环比如把公共类型抽到独立的 contracts 包如果暂时切不开再考虑在构建上做特殊处理比如禁止循环引用的包同时进入--sort的构建链路改成先分别构建再合并产物。但长期来看抽包是唯一治本的办法。另外一个很容易踩的坑是 postinstall 脚本。pnpm 较新版本出于安全考虑默认不执行依赖包自带的安装脚本如 esbuild、原生 Node 插件等只有在 package.json 的pnpm.onlyBuiltDependencies字段里显式列出白名单或者用pnpm approve-builds交互式放行后才会执行。如果你发现某个依赖装上了但用不了比如 esbuild 报二进制无法加载先检查它的安装脚本有没有被 pnpm 拦截。{ pnpm: { onlyBuiltDependencies: [ esbuild, sharp ] } }千万别为了方便把所有包的脚本都放开那会重新引入供应链安全风险。只放那些你确实依赖其构建行为的包白名单越短越安全。5.4 常见错误速查表把我在项目里高频遇到的报错整理成一张速查表方便对号入座报错/现象可能原因处理建议ERR_PNPM_NO_PACKAGE_MANIFESTglob 匹配到了缺少合法 package.json 的目录检查 pnpm-workspace.yaml补充排除规则Cannot find module xxx幽灵依赖包未显式声明 xxx把 xxx 加入对应包 dependenciesELIFECYCLE Command failed with exit code 1某个子包脚本执行失败用--filter 包名精确定位失败包ERR_PNPM_PEER_DEP_ISSUESpeerDependencies 缺失或版本不满足开启 auto-install-peers或显式补充宿主依赖UNMET PEER DEPENDENCY宿主未提供 peer 依赖在宿主包 devDependencies 中安装一份对应版本依赖装了但运行时报模块缺失postinstall 未被允许执行在 onlyBuiltDependencies 中声明白名单构建顺序错误、产物不一致构建时用了并行而未排序改用pnpm -r --sort build还有一个发布相关的坑当 workspace 内多个包需要一起发布时pnpm 会按依赖顺序逐个 publish。如果某个包引用的是尚未发布的本地包--no-git-checks也许能绕开 git 状态校验但依赖本身会标红。我建议发布前先本地跑一次pnpm -r publish --dry-run看完整流程再实际发布避免发出去一个孤儿包。6. 最后分享几点个人体会这些年的使用体验让我得到一个判断pnpm workspace 最大的价值不是快而是约束。它把依赖关系被迫变得透明让原本依赖约定俗成的隐性规则变成了系统强制保证的边界。团队里新人犯错的成本也因此低了很多至少不会因为一个没写进 package.json 的依赖而让整个项目在凌晨两点突然崩溃。如果让我给正在评估这套方案的人一个最直接的落地建议那就是先小规模试点。挑一个公共库加一个业务应用把pnpm-workspace.yaml、workspace:协议、--filter命令和--sort构建顺序跑顺稳定运行两三个迭代后再逐步并入其他包。一次迁移几十个包不仅排查成本高团队成员的学习曲线也会陡峭得多容易出现出了问题不知道该查依赖关系还是该查代码的两难状态。还有一个小技巧值得分享把根目录的 package.json 脚本当作团队的操作手册来写。每个子包的内部命令可能各不相同但只要根目录的编排命令足够清晰新人只要记住pnpm dev、pnpm build、pnpm test这几个入口就能快速上手整个 monorepo。好的架构不只是解决当下的技术问题也是在降低未来每个人接手时的认知负担。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。