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/foo@1.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 install,pnpm 会识别整个 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:自动提升 patch,feat:自动提升 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 pnpm@latest --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里这样写:
registry=https://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或者干脆重置 store:
pnpm store path这个命令会输出 store 的物理路径,如果你怀疑 store 数据损坏了,直接把目录备份后删除,再重新 install。强硬但有效。
4.3 lerna 发布失败的典型场景与处理方案
lerna 发布踩过的坑不少,挑三个高频的说。
第一个是“npm 包版本已存在”的报错。根源是上一次发布时 lerna 已经把版本号写到package.json了,但 npm 上这个版本的包也已经存在(可能是上次半途发布的残留),导致再次 publish 被 npm 拒绝。这种情况不要硬删 npm 上的版本(npm 不建议删),而是把package.json里的版本号手动 bump 一个新版本,再执行 publish,lerna 会以现有版本为基础继续。
第二个坑是发布顺序不对。如果包 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 提供了一个官方 action:pnpm/action-setup@v2,它会自动处理 pnpm 版本的安装和缓存。GitLab CI 的话需要在缓存目录里加上pnpm store path对应的路径,以及node_modules/.pnpm的相关缓存。缓存的语义是:lockfile 没变,直接用上次的安装结果,一旦 lockfile 变更则重新安装。这个策略能让 monorepo 的流水线稳定提速一半以上。
关于 lockfile,pnpm-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 场景,这是一个经得住验证的稳妥方案。