1. 从单仓库泥潭到多包协作:lerna 到底在解决什么
如果你手里维护着超过三个互相依赖的前端或 Node.js 包,大概率经历过这种场景:A 包改了工具函数,B 包和 C 包都得跟着升级版本号,然后手动去各自的package.json里改依赖、跑测试、发 npm,最后还要在业务项目里一个个更新引用。一次两次还行,包一多、人一多,这套流程就会变成灾难——版本对不上、依赖装重复、发布漏包,全是坑。
lerna就是冲着这类问题来的。它本质上是一个多包仓库(monorepo)管理工具,核心能力可以拆成三块:依赖提升与本地链接、统一版本与发布流程、批量脚本执行。说白了,它让你把一堆原本散落在不同仓库的包塞进同一个仓库里,用一套命令统一管理它们的安装、构建、测试和发布。
我第一次接触 lerna 是在一个组件库项目里,当时仓库里有core、utils、icons、theme四个包,彼此有依赖关系。没用 lerna 之前,每次发版都要手动改四遍版本号,还得保证依赖顺序正确,稍不留神就发出去一个引用了不存在版本的包。用了 lerna 之后,一条lerna publish就能把版本号、依赖引用、git tag、npm 发布全部串起来,省下来的时间够我多写两个组件。
这篇文章适合谁看?如果你正在维护多个相互依赖的 npm 包,或者团队里有人抱怨“为什么改一个工具函数要发五个包”,那这篇内容就是写给你的。我会从 lerna 的核心机制讲起,把版本模式选型、依赖提升的坑、发布流程的细节、以及实际项目里踩过的雷,一条条拆开说清楚。即使你之前没用过 monorepo,跟着走一遍也能上手。
需要提前说明的是,lerna 本身在 2022 年之后经历了一次重要变化——Nx 团队接手维护,推出了lerna repair、lerna watch等新能力,同时和 Nx 的生态做了整合。但它的核心工作流没有变,老项目升级上来基本平滑。下面讲的内容以 lerna 6.x/7.x 为主,老版本 3.x/4.x 的差异我会在对应位置标注。
2. 两种版本模式的选择:Fixed 还是 Independent,这是个战略问题
lerna 最核心的一个决策点,就是版本模式。它决定了你仓库里所有包的版本号是“齐步走”还是“各自飞”。这个选择一旦定下来,后期想改成本极高,所以必须在项目初期就想清楚。
2.1 Fixed 模式:所有包共用一个版本号
Fixed 模式(也叫 Locked 模式)是 lerna 的默认行为。在这个模式下,仓库里所有包的版本号是统一的——只要有一个包需要发版,所有包都会跟着升到同一个版本号。
它的工作逻辑是这样的:lerna 会维护一个全局版本号,每次lerna publish时,你选择major、minor或patch,所有包的版本号一起变。即使某个包这次没有任何改动,它的版本号也会跟着涨。
这种模式适合什么场景?包之间耦合度高、对外作为一个整体发布的项目。比如一个 UI 组件库,core、button、form这些包通常是一起升级的,用户装的时候也是整套装,版本号统一反而清晰。再比如 Babel 这种工具链,所有插件包版本号一致,用户一看就知道兼容性。
但 Fixed 模式有个明显的副作用:版本号会虚高。假设utils包三个月没改过,但因为core发了五次版,utils的版本号也从 1.0.0 涨到了 1.5.0。用户看到版本号变化,以为有更新,结果装下来发现代码一模一样。这种“版本噪音”在包数量多的时候尤其烦人。
2.2 Independent 模式:每个包独立版本
Independent 模式(lerna init --independent)下,每个包维护自己的版本号。lerna publish时会逐个询问哪些包需要发版,或者根据 git 变更自动判断,只给有改动的包升版本。
这个模式适合包之间相对独立、可以单独使用的场景。比如一个工具集合仓库,里面有date-utils、string-utils、file-utils,用户可能只需要其中一个,那每个包独立版本就合理得多。
Independent 模式的代价是管理复杂度上升。你需要清楚每个包的变更历史,发布时也要更谨慎地选择哪些包需要发。如果团队协作不规范,容易出现“某个包改了但忘了发版”的情况。
2.3 怎么选:一张表帮你决策
| 判断维度 | Fixed 模式 | Independent 模式 |
|---|---|---|
| 包之间依赖关系 | 强耦合,经常一起改 | 弱耦合,各自独立演进 |
| 对外发布方式 | 作为整体发布 | 各包单独发布 |
| 版本号语义 | 统一版本,清晰 | 各自版本,精确 |
| 管理成本 | 低 | 中高 |
| 典型场景 | 组件库、工具链 | 工具集合、SDK 集合 |
我的经验是:如果你不确定选哪个,先选 Fixed。因为 Fixed 改 Independent 相对容易(改配置、调整发布流程),而 Independent 改 Fixed 会遇到版本号对齐的麻烦——你得把所有包的版本号统一到一个值,还要处理依赖引用。先简单后复杂,是更稳妥的路径。
还有一个折中方案:用 Fixed 模式起步,等某个包确实需要独立演进时,再迁移到 Independent。lerna 支持在lerna.json里通过version字段切换,但切换后第一次发布需要手动处理版本号对齐。
3. 依赖提升与本地链接:node_modules 里的隐形战场
多包仓库最容易被忽视、也最容易出问题的环节,就是依赖管理。lerna 在这块做了两件事:依赖提升(hoisting)和本地包链接(symlink)。理解这两件事的机制,能帮你避开一大半“为什么本地跑得好好的,CI 上就挂了”的问题。
3.1 依赖提升:省空间还是埋雷
默认情况下,lerna bootstrap会把所有包的依赖安装到各自的node_modules里。但这样会有大量重复——比如十个包都依赖lodash,就会装十份。--hoist参数的作用就是把这些公共依赖提升到仓库根目录的node_modules,各包通过 Node.js 的模块查找机制向上找到根目录的依赖。
听起来很美好,但提升有个经典坑:版本冲突。假设package-a依赖lodash@4.17.20,package-b依赖lodash@3.10.1,提升时 lerna 只能选一个版本放到根目录,另一个版本会被留在对应包的node_modules里。这时候如果package-b的代码里用了 lodash 3 特有的 API,而运行时实际加载的是根目录的 lodash 4,就会报错。
更隐蔽的情况是传递依赖冲突。你直接依赖的包版本一致,但它们依赖的底层包版本不同,提升后可能导致某个包的运行时行为变化。这类问题往往在测试阶段发现不了,上线后才暴露。
我的建议是:新项目可以开 hoist,但要配合nohoist白名单。在lerna.json里这样配置:
{ "command": { "bootstrap": { "hoist": true, "nohoist": ["**/webpack", "**/babel-loader"] } } }把那些对版本敏感、或者需要和宿主环境隔离的包(比如构建工具、babel 插件)排除在提升之外。老项目迁移时,建议先不开 hoist,跑通后再逐步开启,观察是否有异常。
3.2 本地链接:改了 A 包,B 包立刻生效
本地链接是 monorepo 的核心便利之一。当package-b依赖package-a时,lerna 不会去 npm 下载package-a,而是把本地的package-a目录软链到package-b/node_modules/package-a。这样你改了package-a的源码,package-b里立刻能看到变化,不需要重新安装。
这个机制依赖package.json里的版本范围匹配。比如package-b里写的是"package-a": "^1.0.0",而本地package-a的版本是1.2.0,lerna 就会建立链接。但如果本地版本是2.0.0,超出了^1.0.0的范围,lerna 就会去 npm 下载,而不是链接本地包。
这里有个实操技巧:在开发阶段,把包之间的依赖写成"package-a": "file:../package-a"或者用 workspace 协议。不过 lerna 本身推荐的方式是保持语义化版本范围,通过lerna bootstrap自动处理链接。如果你用的是 npm 7+ 或 yarn 的 workspaces,lerna 可以和它们配合,由包管理器负责链接,lerna 负责发布流程。
注意:本地链接在 Windows 上偶尔会有权限问题,尤其是用管理员权限跑过一次命令之后,软链可能失效。遇到“改了源码但依赖包没更新”的情况,先删掉
node_modules重新lerna bootstrap试试。
3.3 依赖提升与链接的配合顺序
这两个机制的执行顺序会影响最终结果。lerna bootstrap的流程大致是:先安装各包的外部依赖,然后根据 hoist 配置决定提升哪些,最后建立本地包之间的链接。如果链接建立时依赖还没装好,就会出现“链接指向的包缺少依赖”的情况。
实际项目中,我习惯把lerna bootstrap拆成两步:先lerna bootstrap --hoist装依赖,再lerna link单独建立链接。这样出问题时更容易定位是哪一步挂了。另外,在 CI 环境里,建议用npm ci或yarn install --frozen-lockfile配合 lerna,保证依赖版本锁定,避免“本地能跑、CI 挂掉”的经典问题。
4. 发布流程拆解:从改代码到 npm 上架的完整链路
发布是 lerna 最有价值、也最容易出错的环节。很多人第一次用lerna publish时,会被它一连串的交互提示搞懵——选版本、确认包列表、打 tag、推送到远程,每一步都有讲究。这一章我把整个链路拆开,讲清楚每个环节在做什么、哪里容易踩坑。
4.1 发布前的准备工作
在跑lerna publish之前,有几件事必须确认:
第一,工作区必须干净。lerna 会检查 git 状态,如果有未提交的改动,发布会中断。这是为了防止你把未完成的代码发出去。所以先git status确认没有遗漏,该提交的提交,该 stash 的 stash。
第二,远程仓库要配置好。lerna 发布时会创建 git tag 并推送到远程。如果远程没配或者没权限,发布会卡在推送环节。建议提前git remote -v确认,并且确保当前分支有推送权限。
第三,npm 登录状态要有效。npm whoami确认已登录,并且账号有对应包的发布权限。如果是 scoped 包(@scope/package),还要确认 scope 的权限配置。
第四,构建产物要就绪。如果包需要编译(TypeScript、Babel 等),确保prepare或prepublishOnly脚本配置正确,lerna 发布时会自动触发这些脚本。我见过有人忘了配prepare,结果发上去的是源码而不是编译后的产物,用户装下来直接报错。
4.2 版本号决策:patch、minor 还是 major
lerna publish会问你这次发布是什么类型的版本变更。这个选择直接影响所有包的版本号(Fixed 模式)或需要发版的包的版本号(Independent 模式)。
patch:修 bug、小改动,1.0.0 → 1.0.1minor:新增功能、向后兼容,1.0.0 → 1.1.0major:破坏性变更,1.0.0 → 2.0.0
在 Fixed 模式下,你选一次,所有包一起变。在 Independent 模式下,lerna 会根据 git commit 信息自动推荐哪些包需要发版,你也可以手动选择。
这里有个实用技巧:用 conventional commits 规范提交信息。lerna 可以配合conventional-changelog自动生成 CHANGELOG,并且根据 commit 类型(feat、fix、BREAKING CHANGE)自动推荐版本号。配置方式是在lerna.json里加:
{ "command": { "publish": { "conventionalCommits": true } } }这样feat:开头的提交会触发 minor,fix:触发 patch,带BREAKING CHANGE:的触发 major。省去手动判断的麻烦,也避免选错版本号。
4.3 发布过程中的关键节点
lerna publish的执行链路大致是这样的:
- 检查工作区状态和远程配置
- 根据版本模式确定要发布的包和版本号
- 更新各包
package.json里的版本号和相互依赖的版本范围 - 运行
prepublishOnly、prepare等生命周期脚本 - 创建 git commit 和 tag
- 推送到远程仓库
- 发布到 npm registry
其中第 3 步和第 6 步是最容易出问题的。第 3 步里,lerna 会自动更新包之间的依赖版本——比如package-b依赖package-a@^1.0.0,当package-a升到1.1.0时,lerna 会把package-b里的依赖改成^1.1.0。这个行为在 Fixed 模式下是必须的,但在 Independent 模式下可能不是你想要的。可以通过--no-push或--no-git-tag-version等参数控制。
第 6 步推送失败是常见问题。如果远程有新的提交,推送会被拒绝。这时候 lerna 已经本地创建了 commit 和 tag,你需要手动git pull --rebase后再git push --follow-tags。为了避免这种情况,发布前先git pull同步远程。
4.4 发布失败后的回滚
发布失败分两种情况:npm 发布失败和git 推送失败。
如果是 npm 发布失败(比如网络问题、权限问题),lerna 会停在中间状态,部分包可能已经发出去了。这时候不要慌,先看哪些包成功了、哪些失败了。对于失败的包,修复问题后可以单独npm publish,或者重新跑lerna publish from-package——这个命令会跳过版本号更新,直接发布当前package.json里版本号对应的包。
如果是 git 推送失败,本地已经有 commit 和 tag 了。先解决远程冲突(git pull --rebase),然后git push --follow-tags把 commit 和 tag 一起推上去。如果 npm 已经发布成功,就不需要重新跑了。
提示:发布前建议在本地跑一次
lerna publish --canary --no-push做预演。canary 模式会生成一个临时版本号(如1.0.1-alpha.0+abc123),不会影响正式版本,可以用来验证发布流程是否通畅。
5. 脚本编排与任务调度:让几十个包按顺序跑起来
多包仓库里,跑脚本是个高频操作。你可能需要“给所有包跑测试”“只给有改动的包跑构建”“按依赖顺序编译”。lerna 提供了lerna run和lerna exec两个命令来应对这些场景,但用不好就会变成“等半小时还没跑完”。
5.1 lerna run 与 lerna exec 的区别
lerna run <script>是在每个包含该 script 的包里执行npm run <script>。比如lerna run build会在所有定义了build脚本的包里跑构建。
lerna exec -- <command>是在每个包目录下执行任意 shell 命令。比如lerna exec -- rm -rf dist会删掉所有包的dist目录。
两者的关键区别是:run针对 npm scripts,exec针对任意命令。日常用run更多,exec适合做清理、文件操作这类杂活。
5.2 并发与拓扑排序:速度与顺序的平衡
默认情况下,lerna run是串行执行的——一个包跑完再跑下一个。包多了之后,这个速度无法接受。加--concurrency参数可以开并发:
lerna run build --concurrency 4但并发有个前提:包之间没有构建顺序依赖。如果package-b的构建依赖package-a的产物,并发跑就会出错。这时候需要用--sort参数,lerna 会根据包之间的依赖关系做拓扑排序,被依赖的包先跑:
lerna run build --sort--sort和--concurrency可以一起用,lerna 会在保证顺序的前提下尽量并发。实际项目中,我通常这样配置:
lerna run build --sort --concurrency 4 --stream--stream的作用是实时输出各包的日志,并且用包名做前缀,方便排查是哪个包出了问题。不加--stream的话,日志会等命令结束后才统一输出,出问题时很难定位。
5.3 只跑有改动的包:--since 的妙用
在 CI 环境里,每次都全量跑所有包的测试和构建,既慢又浪费资源。--since参数可以只跑自某个 git ref 以来有改动的包:
lerna run test --since main这表示只跑那些相对于main分支有文件改动的包。配合 CI 的分支策略,可以大幅缩短流水线时间。
但--since有个细节要注意:它判断的是包目录下的文件改动。如果改动发生在根目录的配置文件(比如tsconfig.base.json),所有包都不会被触发。这种情况下需要加--include-dependents或者手动指定包列表。
另一个相关参数是--include-dependencies,表示“跑某个包时,把它依赖的包也一起跑”。比如lerna run build --scope package-b --include-dependencies会先构建package-a,再构建package-b。这在单独调试某个包时很有用。
5.4 缓存与增量构建的取舍
lerna 本身不提供构建缓存,但可以和 Nx 配合实现。Nx 接手 lerna 后,lerna run底层可以走 Nx 的任务调度和缓存机制。配置方式是在nx.json里定义targetDefaults和cache:
{ "targetDefaults": { "build": { "cache": true, "dependsOn": ["^build"] } } }这样第二次跑lerna run build时,没有改动的包会直接命中缓存,几秒钟就能跑完。对于包数量多、构建耗时的项目,这个优化效果非常明显。
不过缓存也有坑:环境变量和外部依赖的变化不会自动触发缓存失效。比如你改了.env文件或者升级了某个全局工具,缓存可能还是旧的。遇到“明明改了东西但构建结果没变”的情况,先清缓存(nx reset)再试。
6. 实际项目里踩过的坑与排查思路
前面讲的都是机制和流程,这一章我把自己和团队在实际项目里踩过的坑整理出来。每个坑都附上排查思路,方便你遇到类似问题时快速定位。
6.1 依赖提升导致的“幽灵依赖”
现象:本地开发一切正常,CI 上跑测试时报Cannot find module 'xxx'。
原因:某个包在代码里import了一个它没有在package.json里声明的依赖,但因为 hoist 把其他包的依赖提升到了根目录,本地运行时恰好能找到。CI 环境如果没开 hoist 或者依赖安装顺序不同,就找不到了。
排查:用lerna exec -- npx depcheck检查每个包的未声明依赖。或者在 CI 里关掉 hoist,看是否复现。
解决:把缺失的依赖补到对应包的package.json里。不要依赖 hoist 的“顺带”能力,每个包该声明的依赖都要声明。
6.2 本地链接失效导致的“改了没生效”
现象:改了package-a的源码,package-b里引用的还是旧版本。
原因:可能是版本范围不匹配导致 lerna 没有建立链接,也可能是软链被破坏(Windows 上常见),还可能是构建产物没更新(如果package-b引用的是package-a的dist而不是src)。
排查:先看package-b/node_modules/package-a是不是软链(ls -la看箭头)。如果是实体目录,说明没链接上。再检查package-a的版本号和package-b里声明的范围是否匹配。
解决:版本不匹配就调整范围或升版本;软链坏了就lerna clean && lerna bootstrap;产物没更新就在package-a里跑一次构建,或者配置 watch 模式。
6.3 发布时版本号选错
现象:本来只想发个 patch,结果手滑选了 minor,所有包版本号跳了一个小版本。
原因:lerna publish的交互提示里,版本类型选择没有二次确认。
解决:如果还没推送到远程,可以git reset --hard HEAD~1回滚 commit,删掉本地 tag,重新发布。如果已经推到远程,只能再发一个 patch 版本覆盖,或者联系用户说明。预防措施是配置conventionalCommits,让版本号根据 commit 自动决定,减少手动选择的机会。
6.4 CI 上发布权限问题
现象:本地lerna publish正常,CI 上跑就报401 Unauthorized。
原因:CI 环境没有 npm 登录态。本地是交互式登录,CI 需要用 token。
解决:在 npm 账号里生成一个 Automation Token,配到 CI 的环境变量NPM_TOKEN里,然后在仓库根目录加.npmrc:
//registry.npmjs.org/:_authToken=${NPM_TOKEN}注意 token 不要提交到 git,用 CI 的 secret 管理功能注入。
6.5 包之间的循环依赖
现象:lerna run build --sort卡住不动,或者报循环依赖错误。
原因:package-a依赖package-b,package-b又依赖package-a,拓扑排序无法进行。
排查:lerna list --graph可以输出依赖关系图,找找有没有环。
解决:循环依赖通常意味着包拆分不合理。把公共部分抽到第三个包package-c,让 A 和 B 都依赖 C。如果实在拆不开,可以用--no-sort跳过排序,但要自己保证构建顺序。
7. 从 lerna 到现代 monorepo 工具链的演进思路
lerna 不是唯一的选择,也不是所有场景下的最优解。pnpm workspaces、yarn workspaces、Nx、Turborepo 都在不同维度上提供了替代或补充方案。这一章聊聊怎么根据项目情况做选型,以及 lerna 在新工具链里的位置。
7.1 lerna 与包管理器的分工
lerna 本身不负责安装依赖,它把安装交给 npm、yarn 或 pnpm。所以实际项目里常见的组合是:
- lerna + yarn workspaces:yarn 负责依赖安装和链接,lerna 负责发布和脚本编排
- lerna + pnpm workspaces:pnpm 的硬链接机制省空间,lerna 负责版本管理
- lerna + Nx:Nx 负责任务调度和缓存,lerna 负责发布
这种分工的好处是各司其职。包管理器在依赖处理上更成熟,lerna 在发布流程上更专注。配置方式是在根目录package.json里声明workspaces,然后lerna.json里设"useWorkspaces": true,lerna 就会把安装和链接交给包管理器。
7.2 什么情况下该考虑替代方案
如果你的项目满足以下条件,可以评估一下其他工具:
- 包数量少(少于 5 个)且依赖简单:直接用 npm/yarn workspaces 就够了,lerna 的发布流程可能用不上
- 构建任务复杂、对缓存要求高:Nx 或 Turborepo 的任务调度和缓存能力更强
- 只需要依赖管理,不需要统一发布:pnpm workspaces 更轻量
但如果你需要统一的版本管理和发布流程,尤其是 Fixed 模式下多包齐步走的场景,lerna 仍然是最成熟的选择。它的lerna publish在版本号更新、依赖引用同步、git tag 管理上的细节处理,是其他工具目前没有完全覆盖的。
7.3 迁移到 lerna 7 的注意事项
lerna 7 相比 6 有几个变化:默认使用 Nx 作为任务运行器,lerna bootstrap和lerna add被标记为废弃(推荐用包管理器的 workspaces 替代),新增了lerna repair用于自动修复配置。
迁移时的主要工作是把lerna bootstrap换成包管理器的install,把lerna add换成npm install -w或yarn workspace。发布流程基本不变,lerna publish的用法保持一致。
如果暂时不想迁移,lerna 6 仍然可以正常使用,只是新特性不会 backport。我的建议是:新项目直接用 lerna 7 + 包管理器 workspaces,老项目可以等下一次大版本升级时再迁移,不必急于一时。
8. 一套可复用的 lerna 项目初始化清单
最后分享一套我在新项目里用的初始化流程,从零搭一个 lerna 仓库,包含配置、目录结构和常用脚本。你可以直接照着做,也可以按需调整。
8.1 目录结构约定
my-monorepo/ ├── packages/ │ ├── package-a/ │ │ ├── src/ │ │ ├── package.json │ │ └── tsconfig.json │ └── package-b/ │ ├── src/ │ ├── package.json │ └── tsconfig.json ├── lerna.json ├── package.json ├── tsconfig.base.json └── .npmrcpackages/下放所有子包,根目录放公共配置。tsconfig.base.json存放共享的 TypeScript 配置,各包的tsconfig.json通过extends继承。
8.2 关键配置文件
根目录package.json:
{ "name": "my-monorepo", "private": true, "workspaces": ["packages/*"], "scripts": { "build": "lerna run build --sort --stream", "test": "lerna run test --since main --stream", "publish": "lerna publish" }, "devDependencies": { "lerna": "^7.0.0", "typescript": "^5.0.0" } }lerna.json:
{ "version": "independent", "npmClient": "npm", "useWorkspaces": true, "command": { "publish": { "conventionalCommits": true, "message": "chore(release): publish %s" }, "run": { "stream": true } } }.npmrc:
//registry.npmjs.org/:_authToken=${NPM_TOKEN}8.3 初始化步骤
npx lerna init --independent初始化仓库- 修改根
package.json,加workspaces字段和脚本 - 在
packages/下创建子包,每个包有独立的package.json - 跑
npm install安装依赖并建立链接 - 跑
lerna run build验证构建流程 - 配置 CI,注入
NPM_TOKEN,加发布流水线
8.4 日常开发常用命令
| 场景 | 命令 |
|---|---|
| 安装依赖 | npm install |
| 全量构建 | lerna run build --sort |
| 只构建有改动的包 | lerna run build --since main |
| 给某个包加依赖 | npm install lodash -w packages/package-a |
| 查看包列表 | lerna list |
| 查看依赖图 | lerna list --graph |
| 清理 node_modules | lerna clean |
| 发布 | lerna publish |
| 预演发布 | lerna publish --canary --no-push |
这套配置在几个项目里跑下来比较稳,包数量从 3 个到 20 个都验证过。核心思路是:依赖安装交给包管理器,版本发布交给 lerna,任务调度按需加 Nx。三者边界清晰,出问题时也容易定位是哪一层的问题。
我在实际使用中最大的体会是:lerna 的价值不在于它做了多少事,而在于它把多包协作里最容易出错的环节——版本同步和发布流程——标准化了。只要这两件事不出错,剩下的依赖管理和脚本编排,用包管理器原生能力也能应付。所以别把 lerna 当成万能工具,把它当成发布流程的守门人,心态会轻松很多。