Lerna多包仓库管理实战:版本模式、依赖提升与发布流程详解 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依赖lodash4.17.20package-b依赖lodash3.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.0lerna 就会建立链接。但如果本地版本是2.0.0超出了^1.0.0的范围lerna 就会去 npm 下载而不是链接本地包。这里有个实操技巧在开发阶段把包之间的依赖写成package-a: file:../package-a或者用 workspace 协议。不过 lerna 本身推荐的方式是保持语义化版本范围通过lerna bootstrap自动处理链接。如果你用的是 npm 7 或 yarn 的 workspaceslerna 可以和它们配合由包管理器负责链接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 还是 majorlerna 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:开头的提交会触发 minorfix:触发 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.0abc123不会影响正式版本可以用来验证发布流程是否通畅。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 scriptsexec针对任意命令。日常用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-bpackage-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 workspacesyarn 负责依赖安装和链接lerna 负责发布和脚本编排lerna pnpm workspacespnpm 的硬链接机制省空间lerna 负责版本管理lerna NxNx 负责任务调度和缓存lerna 负责发布这种分工的好处是各司其职。包管理器在依赖处理上更成熟lerna 在发布流程上更专注。配置方式是在根目录package.json里声明workspaces然后lerna.json里设useWorkspaces: truelerna 就会把安装和链接交给包管理器。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_moduleslerna clean发布lerna publish预演发布lerna publish --canary --no-push这套配置在几个项目里跑下来比较稳包数量从 3 个到 20 个都验证过。核心思路是依赖安装交给包管理器版本发布交给 lerna任务调度按需加 Nx。三者边界清晰出问题时也容易定位是哪一层的问题。我在实际使用中最大的体会是lerna 的价值不在于它做了多少事而在于它把多包协作里最容易出错的环节——版本同步和发布流程——标准化了。只要这两件事不出错剩下的依赖管理和脚本编排用包管理器原生能力也能应付。所以别把 lerna 当成万能工具把它当成发布流程的守门人心态会轻松很多。