资讯动态

pnpm + lerna 构建高效 Monorepo 工程化实践与依赖管理避坑指南

发布时间:2026/10/9 18:28:01 来源:尧图企业网站定制
1. Monorepo 的整体思路与选型分析先说结论如果你的团队正在维护超过两个前端/Node.js 项目并且这些项目之间有公共代码需要复用那 Monorepo 基本是绕不开的坑也是性价比最高的解法。我自己在多个中大型项目里吃过单仓库多项目的苦头也踩过 Polyrepo 的坑最后稳定在 pnpm lerna 这套组合上这篇文章就把整个实践经验完整写一遍。Monorepo 说白了就是把多个项目放进同一个 Git 仓库里管理而不是每个项目单独开一个仓库。这个思路本身不新鲜Google、Meta 内部早就是这么干的只不过以前是巨石代码库现在是更轻量的多包仓库Multi-package Repository。前端领域比较熟悉的形式是一个仓库下面有packages/*目录每个子目录是一个独立的包package可以单独发布到 npm也可以互相引用。为什么 Monorepo 在这几年突然火起来核心原因有三个代码复用、原子提交、统一依赖管理。代码复用很好理解多个项目共享的组件、工具函数、类型定义直接通过 workspace 引用不需要发版到 npm 再安装一遍改完代码立刻生效。原子提交指的是跨项目的改动可以在一个 commit 里完成比如你改了公共组件的接口同时修正了调用的业务项目代码一次提交全部搞定不会出现“组件改了但调用方还没跟上”的中间态。统一依赖管理则是把 node_modules 和锁文件集中起来避免每个项目各装各的、版本漂移、依赖冲突这些问题。先讲讲我为什么不推荐纯 lerna 不用 pnpm也不推荐纯 pnpm 不用 lerna。lerna 擅长的是多包版本管理和发布流程它有一套成熟的版本号计算、CHANGELOG 生成、npm publish 流水线但它的依赖安装和管理能力并不强早期版本甚至会让每个包都安装一份自己的依赖磁盘占用巨大。pnpm 恰好相反它在依赖安装上做了极致优化通过硬链接 符号链接的方式让磁盘占用降到最低安装速度快还能从根本上杜绝“幽灵依赖”但它不负责版本发布也没有 lerna 那种全生命周期管理能力。所以这两个工具不是二选一的关系而是互补关系pnpm 管依赖lerna 管版本与发布这就是我用 pnpm lerna 组合的核心理由。从团队协作角度来说Monorepo 还有一个隐形优点新成员上手成本低。一个仓库git clone完执行一次安装命令所有项目的代码都在本地改一个包、跑一个测试、看一个 PR 的完整改动都不需要跨仓库跳来跳去。代码审查的效率也会提升因为改动范围清晰可见。缺点当然也有比如仓库体积变大、git 操作变慢、CI 需要更聪明的缓存策略但这些问题在 monorepo 工具链的成熟下基本都有对应解法后面会逐一讲到。2. pnpm 核心机制解析与落地实操2.1 pnpm 的硬链接与符号链接机制pnpm 最核心的优势在于它用了“内容寻址”的方式来管理依赖。简单打个比方如果用 npm你装同一个依赖到十个项目里磁盘上就有十份物理文件如果用 pnpm每个依赖只存一份在全局 store 里比如~/.pnpm-store然后通过硬链接把文件“链接”到各个项目的 node_modules 里。硬链接的特点是多个路径指向同一份物理数据不额外占空间读取速度也一样快。符号链接则用于解决依赖之间的层级关系。pnpm 在项目的node_modules下只保留.pnpm这个隐藏目录里面是按照依赖树展开的真实目录结构然后通过符号链接把每个包按需暴露给项目。比如你的项目node_modules/foo实际上是一个符号链接指向.pnpm/foo1.0.0/node_modules/foo。这样做的最大好处是依赖隔离项目只能访问package.json里明确声明的依赖不会因为你依赖的某个库又依赖了另一个库你的代码就能顺带require到那个传递依赖。这就是所谓“幽灵依赖”的根治方案。这套机制带来的实际收益非常直观。我在一个接近 30 个包的中型 monorepo 里做过对比npm 安装占用 2.1GB切换 pnpm 后整个仓库的 node_modules 加上全局 store 总共只占 800MB 左右因为很多公共依赖是共享的安装时间从 npm 的 4 分钟降到了 pnpm 的 50 秒第二次开始因为有缓存基本 10 秒内能完成。这在 CI 上的感受尤其明显流水线从 10 分钟缩短到了 4 分钟。需要注意的一点Windows 上可能因为文件系统和权限问题导致硬链接失败pnpm 会自动回退到拷贝模式但如果你用的是 NTFS 分区的普通目录一般没问题。如果发现 store 目录越来越大可以定期执行pnpm store prune清理不再被引用的包。2.2 pnpm workspace 配置步骤要在一个仓库里启用 pnpm 的 workspace 能力核心就是创建pnpm-workspace.yaml内容非常简单packages: - packages/* - apps/* - !packages/legacy/**第一行声明哪些目录是独立包apps/*通常放应用比如前端项目、Node 服务packages/*放可发布的库或共享模块。!开头的行是排除规则把不需要纳入 workspace 的目录排除掉比如旧代码或者需要单独处理的遗留项目。配置好之后你就不需要再手动逐个安装依赖。直接在仓库根目录执行pnpm installpnpm 会识别整个 workspace给每个子包安装自己的依赖同时把公共依赖提升到根目录的node_modules/.pnpm中。子包之间如果要互相引用也不需要发版或者写file:协议直接在package.json里这样声明依赖{ name: yourcompany/components, version: 1.0.0 }另一个包引用它{ dependencies: { yourcompany/components: workspace:* } }workspace:*的意思是“这个依赖来自当前 workspace版本以实际包版本为准”。这比写死1.0.0强得多因为 build 的时候它会解析到本地 packages 目录下的源码而不是去 npm registry 拉一份。发布的时候pnpm 会把workspace:*自动替换成对应的真实版本号不会把 workspace 协议留到线上。如果你想让多个子包共享同一个 Vue 或 React 版本不需要每个子包单独声明依赖可以加一个字段设置公共依赖在根目录安装pnpm add -w typescript-w表示安装在 workspace 根目录这样根目录的package.json会多出一个 devDependencies各个子包可以直接使用这个 TypeScript 版本而不需要各自声明避免出现“子包 A 用 TS 4.9子包 B 用 TS 5.0两边的类型定义对不上”这种问题。2.3 核心命令速查日常开发高频使用的 pnpm 命令整理在这里都是实测过没坑的命令作用与场景pnpm install安装整个 workspace 的所有依赖pnpm add -w pkg给 workspace 根目录添加依赖pnpm add pkg --filter pkg-name给指定子包添加依赖pnpm -r run build按拓扑顺序依次运行所有子包的 build 脚本pnpm --filter pkg-name run dev只运行某个子包的命令pnpm --filter pkg-a --filter pkg-b run test同时运行多个子包的相同命令pnpm store prune清理全局 store 中不再被引用的依赖pnpm dlx command临时执行某个命令行工具类似 npx关键的一点是pnpm -r run build是按依赖关系排序的自动化构建时不用自己手动排顺序。比如你的admin-app依赖ui-components那ui-components一定会在admin-app之前完成构建这就可以直接省掉一份人工分析的精力。3. lerna 与 pnpm 的协作模式3.1 为什么还需要 lerna如果 pnpm 已经把依赖管理做到这份上了lerna 还能干什么答案是版本发布和变更管理。pnpm 虽然能安装和管理 workspace 依赖但不会帮你算版本号不会帮你维护 CHANGELOG也不会帮你把每个包发布到 npm。lerna 的核心能力就是这些。在没有 lerna 的 monorepo 里你要发布一个子包的新版本通常得手动改package.json里的 version手动写 CHANGELOG手动npm publish但用了 workspace 之后得小心发布路径对不对手动打 git tag。一两个包还好如果子包数量上了 10 个而且包之间有依赖链条这套人工操作基本是灾难。lerna 可以一次性分析出哪些包发生了变更根据语义化版本规则帮你计算下一个版本号自动生成 CHANGELOG按依赖顺序逐个发布最后统一打 tag 提交。所以我的实践结论是pnpm 负责“装得好”lerna 负责“发得好”两者各管一摊组合起来才闭环。3.2 从独立版本模式到固定版本模式lerna 支持两种版本管理模式固定模式Fixed mode和独立模式Independent mode。固定模式是默认值也就是整个 monorepo 共享一个版本号只要你改了任何一个子包所有子包都会同时发一个新版本。这种模式适合强耦合的组件库比如一套 UI 组件库Button 改了Tabs 没改但发包时两个都发 2.1.0保证对外永远是一个整体版本。独立模式下每个包自己维护版本号lerna 单独分析变更范围只发布有变动的包。这种模式适合弱耦合的多项目仓库比如一个仓库里既有工具库、又有业务应用或者独立的中间层服务各自的版本节奏差异很大。我的建议是如果仓库里包含业务应用比如一个中后台管理系统、一个 H5 应用尽量用独立模式否则发版会被不相关的包拖累产生大量空版本。切换模式只需在lerna.json里配置{ version: independent, npmClient: pnpm, useWorkspaces: true }useWorkspaces是让 lerna 感知 pnpm workspace 的关键。如果没有这个配置lerna 还是按自己的老逻辑分析 packages导致依赖关系和 pnpm 实际解析结果不一致。3.3 发行流程与命令实操使用 lerna 的发布流程大概是这样的# 1. 备份当前状态务必 git checkout -b release/v1.2.0 # 2. 登录 npm如果还没登录 npm login # 3. 执行版本更新 pnpm lerna version --conventional-commits # 4. 确认无误后执行发布 pnpm lerna publish from-packagelerna version会根据 commit message 识别变更类型fix:自动提升 patchfeat:自动提升 minor同时生成 CHANGELOG。你不需要手动思考这个包应该升到几版只需要保证 commit message 规范。--conventional-commits是开启这个行为的标志建议任何时候都不要漏掉。from-package是 lerna 较新版本的功能意思是跳过npm publish包的版本检测直接用package.json里的版本号发布。为什么这么做因为lerna publish默认还会做一套版本 bump 的流程如果已经有 CI 和人工 review 双重把关了直接从现有版本发布更干净避免重复计算。发布过程中常见的一个问题是某次发布只成功了部分包剩下几个包因为网络或者权限原因失败了。lerna 的推荐是不用重跑整个 publish直接再执行一次pnpm lerna publish from-package它会检查 npm 上已经有哪些版本把未发布的包自动补发。这个幂等性设计是我最欣赏 lerna 的地方。4. 常见问题排查与避坑实录4.1 Windows 系统命令行不识别 pnpm网络热词里很大一部分都是安装 pnpm 后命令行提示“无法将‘pnpm’项识别为 cmdlet、函数、脚本文件或可运行程序的名称”或者“pnpm 不是内部或外部命令”。这个问题的根源通常不是 pnpm 没装上而是环境变量没生效。npm 全局安装的包默认放在一个目录这个目录如果不在 PATH 里shell 自然找不到。解决步骤很简单# 第一步查看 npm 全局安装目录 npm prefix -g默认情况下 Windows 上会输出C:\Users\你的用户名\AppData\Roaming\npm。如果不对可能是设置了自定义 prefix注意看下路径。然后把这个路径加入系统环境变量 PATH方法不赘述控制面板打开“编辑系统环境变量”即可。加入 PATH 之后如果还是提示找不到可能存在一种情况pnpm 的入口文件是以.cmd结尾的有些终端工具比如 Git Bash 或者 CMD 的某些场景解析不到可以尝试执行pnpm.cmd -v验证是否可用或者用corepack的方式安装。Node.js 16.9 自带 corepack可以用这个路线能省不少兼容性问题corepack enable corepack prepare pnpmlatest --activate无论用哪种方式安装重启终端是很多问题的隐藏答案。Windows 修改 PATH 后不会自动刷新到已打开的 shell 里把终端全部关掉再重新打开。4.2 pnpm 安装下载失败与镜像源配置另外一大批热词是关于 pnpm 下载失败的比如安装到一半报网络超时、403、证书校验失败。这类问题绝大多数是 npm 默认官方源在国内不够快或者不稳定导致的。解法是给 pnpm 配镜像源效果立竿见影pnpm config set registry https://registry.npmmirror.com注意这里我用的pnpm config set如果你之前一直用 npm 配过源pnpm 不一定会读 npm 的配置文件它有自己的配置存储。不过 pnpm 会读取.npmrc文件所以你也可以在项目根目录的.npmrc里这样写registryhttps://registry.npmmirror.com这样还能保证项目的镜像源配置随仓库走团队成员 clone 后不用自己配置。对于公司内部如果自建了 npm 私服写法一样把地址替换成私服地址即可。关于“pnpm 离线安装”的热词提一个场景有些内网环境的服务器不能访问外网此时 pnpm 无法使用常规方式安装包。一种可行的方法是在能联网的开发机上安装一个同版本 pnpm用pnpm install --lockfile-only生成锁文件然后再到内网环境用pnpm install --offline从局域网的缓存里安装。更常见的做法是内网搭建 npm 私服开发机连外网下载包后pnpm store export把 store 里的包导出为离线包再到内网机器执行pnpm store import导入最后正常 install。还有一个扎实的故障排查方法如果 pnpm 安装任何包都失败先试试清缓存pnpm store prune或者干脆重置 storepnpm store path这个命令会输出 store 的物理路径如果你怀疑 store 数据损坏了直接把目录备份后删除再重新 install。强硬但有效。4.3 lerna 发布失败的典型场景与处理方案lerna 发布踩过的坑不少挑三个高频的说。第一个是“npm 包版本已存在”的报错。根源是上一次发布时 lerna 已经把版本号写到package.json了但 npm 上这个版本的包也已经存在可能是上次半途发布的残留导致再次 publish 被 npm 拒绝。这种情况不要硬删 npm 上的版本npm 不建议删而是把package.json里的版本号手动 bump 一个新版本再执行 publishlerna 会以现有版本为基础继续。第二个坑是发布顺序不对。如果包 A 依赖包 B那么 B 必须比 A 先发布。lerna 默认会按拓扑顺序处理但如果某个包的package.json里dependencies声明不规范比如手动写死了版本号而不是 workspace 协议lerna 可能无法正确推断依赖关系。解法是统一使用workspace:*并保证 package.json 的依赖声明完整、准确。第三个坑是 CHANGELOG 生成为空。这通常是因为 commit message 不规范比如所有人都用“改了 bug”这种话术。lerna 的 conventional-commits 模式要求 commit message 符合 Conventional Commits 规范feat:、fix:、docs:这种前缀否则直接跳过生成记录。所以团队约定一个 commit 规范是绕不过去的功课最好在根目录用一个commitlint校验强制所有提交遵循规范。不要小看这一步一旦团队人多了commit message 五花八门parsing 不到有效信息版本记录就废了。4.4 幽灵依赖问题的实际反转这里想专门补充一下“幽灵依赖”。使用 npm 或 yarn 的 monorepo 里子包 A 没有声明依赖 lodash但因为别的包声明了A 的代码里照样能require(lodash)这就是幽灵依赖。pnpm 的严格 node_modules 结构能杜绝这个问题第一次迁移的时候团队可能会有点不适应因为某些代码一直在无声无息地用“顺带依赖”pnpm 下直接报错Cannot find module了。我第一次迁移 monorepo 到 pnpm 时就遇到过一个内部包引用了tslib但 package.json 里没写npm 的扁平化 node_modules 让它在开发环境一直没事切到 pnpm 就炸了。排查方法很简单把报错模块的名字逐个去根目录的node_modules/.pnpm里核对然后把依赖补到正确的package.json中。长远来看这不但不是问题反而是一笔财富——它逼你把所有依赖关系显式化线上部署或给别人复用时踩的坑会少很多。5. 关于 monorepo 的落地建议与经验总结最后给想要尝试这套组合的团队几个落地层面的建议每一句都是从实际项目中总结出来的。先从仓库结构说起不用搞得太花哨建议如下apps/ web-app/ # 对外的用户端应用 admin-app/ # 内部管理后台 packages/ ui/ # UI 组件库 utils/ # 纯工具函数 api-client/ # 接口请求封装 types/ # 公共 TypeScript 类型定义 docs/ # 文档可选 package.json pnpm-workspace.yaml lerna.json tsconfig.base.json # 公共 TS 配置apps下的应用不需要发布到 npm所以 package.json 里private: true要设好否则 lerna publish 可能误发布。packages下的库如果只在内部使用也建议private: true防止误操作把内部代码传到公网 npm。CI 配置方面建议利用 pnpm 的缓存。GitHub Actions 里直接 pnpm 提供了一个官方 actionpnpm/action-setupv2它会自动处理 pnpm 版本的安装和缓存。GitLab CI 的话需要在缓存目录里加上pnpm store path对应的路径以及node_modules/.pnpm的相关缓存。缓存的语义是lockfile 没变直接用上次的安装结果一旦 lockfile 变更则重新安装。这个策略能让 monorepo 的流水线稳定提速一半以上。关于 lockfilepnpm-lock.yaml一定要提交到 Git 仓库。这是整个 monorepo 依赖树的唯一可信来源不提交的话团队成员各自装出不同的依赖版本问题排查起来非常痛苦。升级依赖的时候也不要手动改 lockfile而是执行pnpm update xxx或pnpm up -r -i-i是交互式选择包版本让 pnpm 自行维护依赖关系。如果你问我现在还会不会选其他方案比如纯 pnpm 不带 lerna或者更重型的 Nx/Turborepo我会说取决于团队规模和场景。两三个子包的轻量仓库纯 pnpm workspace 完全够用不需要 lerna因为版本管理本身不是痛点。大型团队追求构建缓存和任务编排的极致效率可以上 Nx 或 Turborepo它们依赖图分析和增量构建做得更好但学习成本也高。我们这个实践探索选定了 pnpm lerna 的路子核心原因就是简单可靠pnpm 把依赖装对lerna 把版本发对各自做自己最擅长的事情。当前环境下工具链迭代飞快但底层要解决的本质问题——多包之间如何共享、如何隔离、如何有序变更——这套组合一直能覆盖得住而且迁移成本很低。对于大多数中后台、组件库、Node.js 服务类的 monorepo 场景这是一个经得住验证的稳妥方案。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑