1. 多包仓库的痛点与 lerna 的定位
前端项目一旦超过三个,代码复用就会变成一件让人头疼的事。你可能经历过这样的场景:A 项目里写了一个工具函数,B 项目想用,复制粘贴过去之后,A 改了 B 忘了改,线上跑出两个版本的行为差异。又或者公司内部有一套组件库、一套工具函数、一套请求封装,每个都单独建了 Git 仓库,改一个底层依赖要同时发三个包、升三次版本号、通知所有使用方更新,光是协调成本就够喝一壶。
lerna就是冲着这个场景来的。它是一个基于 Git 和 npm 的多包仓库管理工具,核心解决的问题是:把多个相互依赖的 npm 包放在同一个仓库里管理,让它们可以互相引用、统一发版、统一构建。你可以把它理解成一个“包编排器”——它不负责打包,也不负责依赖安装(这部分交给 npm、yarn 或 pnpm),它负责的是版本管理、依赖链接和发布流程这三件事。
我最初接触 lerna 是在一个中台项目里,当时仓库里有 12 个包:3 个基础工具包、4 个业务组件包、2 个 SDK 包、3 个应用壳。如果用多仓库模式,每次改一个基础工具,就要手动去 11 个仓库里升级依赖、跑测试、发版本。换成 lerna 之后,改完代码一条命令批量发版,依赖关系自动处理,效率提升非常明显。
这篇文章适合三类人看:一是正在维护多个相互依赖 npm 包、被版本协调折磨的开发者;二是准备搭建公司内部组件库或工具集、在选型阶段的技术负责人;三是对 monorepo 模式感兴趣、想搞清楚 lerna 到底能干什么、不能干什么的工程师。我会从整体设计思路讲到具体实操,把踩过的坑和验证过的方案都摊开来说。
2. 整体架构设计与方案选型思路
2.1 为什么是 monorepo 而不是 multirepo
在决定用 lerna 之前,得先想清楚一个问题:你到底需不需要 monorepo。monorepo 不是银弹,它解决了一部分问题,同时引入了新的复杂度。
多仓库模式的优势在于边界清晰、权限独立、CI 互不干扰。但它的代价是:跨仓库修改困难、依赖版本容易漂移、本地调试需要npm link或者yalc这类工具辅助。当你的包之间存在频繁的相互依赖时,多仓库的协调成本会指数级上升。
monorepo 模式把这些问题内部化:所有包在一个仓库里,改依赖就像改本地文件一样直接,lerna会自动把本地包软链到node_modules里,不需要发版就能调试。代价是仓库体积变大、CI 需要做增量构建、权限管理变复杂。
我的判断标准很简单:如果包之间的依赖关系是“树状”的(底层包被上层包依赖,上层包之间不互相依赖),且改动频繁,就适合 monorepo;如果包之间完全独立、由不同团队维护、发布节奏完全不同,就老老实实多仓库。
2.2 lerna 的两种工作模式
lerna 有两种运行模式,这是选型时最容易搞混的地方,必须说清楚。
Fixed 模式(固定模式):所有包共用一个版本号,发版时统一升级。比如仓库里有utils、ui、sdk三个包,当前都是1.2.0,你改动了utils,执行发版后三个包全部变成1.3.0。这种模式适合包之间耦合紧密、总是一起发布的场景,比如一个组件库拆出来的多个子包。
Independent 模式(独立模式):每个包独立维护版本号,发版时 lerna 会检测哪些包有改动,只给有改动的包升版本。utils可以是1.5.0,ui还是0.8.2,互不影响。这种模式适合包之间相对独立、各自有发布节奏的场景。
选哪种模式,取决于你的发布策略。我个人的经验是:内部基础库用 Fixed 模式,因为使用方通常希望所有包版本对齐;对外发布的 SDK 或工具集用 Independent 模式,因为不同包的成熟度不一样,强行对齐版本号反而会造成困扰。
2.3 lerna 与 npm/yarn/pnpm workspaces 的关系
很多人会问:现在 npm、yarn、pnpm 都支持 workspaces 了,还需要 lerna 吗?
答案是:lerna 不替代包管理器,它是在包管理器之上的编排层。workspaces 负责的是依赖提升和本地包链接,lerna 负责的是版本管理和发布流程。两者是互补关系。
从 lerna 6 开始,它已经不再自己处理依赖安装和链接,而是完全交给底层的包管理器(npm/yarn/pnpm)。lerna 只保留version和publish这两个核心命令,以及run命令来批量执行脚本。这个变化让 lerna 变得更轻、更专注。
我现在的组合是:pnpm workspaces + lerna。pnpm 负责依赖安装和链接(它的硬链接机制节省磁盘空间,且对 phantom dependencies 处理更严格),lerna 负责版本管理和发布。这个组合在多个项目里验证下来很稳。
3. 环境搭建与核心配置实操
3.1 初始化仓库与目录结构
先建一个空仓库,初始化 package.json,然后安装 lerna。
mkdir my-monorepo && cd my-monorepo git init npm init -y npm install lerna --save-dev npx lerna init执行lerna init后,会生成lerna.json和packages目录。默认是 Fixed 模式,如果要改成 Independent 模式,加--independent参数。
npx lerna init --independent生成的lerna.json长这样:
{ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "version": "0.0.0", "packages": ["packages/*"] }packages字段定义了包的搜索路径,默认是packages/*。你可以改成apps/*和libs/*分开管理,比如:
{ "packages": ["apps/*", "libs/*", "tools/*"] }目录结构建议这样组织:
my-monorepo/ ├── packages/ │ ├── utils/ # 基础工具包 │ │ ├── src/ │ │ ├── package.json │ │ └── tsconfig.json │ ├── ui/ # 组件包,依赖 utils │ │ ├── src/ │ │ └── package.json │ └── sdk/ # SDK 包,依赖 utils │ ├── src/ │ └── package.json ├── lerna.json ├── package.json └── pnpm-workspace.yaml3.2 配置 pnpm workspaces
在根目录建pnpm-workspace.yaml:
packages: - 'packages/*' - 'apps/*' - 'libs/*'然后在根package.json里加脚本:
{ "scripts": { "build": "lerna run build", "test": "lerna run test", "clean": "lerna run clean", "version": "lerna version", "publish": "lerna publish" } }这里有个关键点:lerna run默认会按照包的拓扑顺序执行脚本。也就是说,如果ui依赖utils,执行lerna run build时,utils的 build 会先跑完,再跑ui的 build。这个特性在多包构建时非常重要,避免了手动排序的麻烦。
3.3 包之间的依赖引用
在ui包的package.json里引用utils:
{ "name": "@my-scope/ui", "version": "1.0.0", "dependencies": { "@my-scope/utils": "workspace:*" } }workspace:*是 pnpm 的协议,表示引用工作区内的本地包。发布时 pnpm 会自动替换成实际版本号。如果你用的是 npm 或 yarn,需要写具体版本号,lerna 在发版时会自动更新。
注意:包名一定要加 scope(比如
@my-scope/),否则发布到公共 registry 时容易和别人的包名冲突。内部包如果不想发布,在 package.json 里加"private": true。
3.4 lerna.json 的完整配置
一个生产环境可用的lerna.json配置:
{ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "version": "independent", "npmClient": "pnpm", "packages": ["packages/*"], "command": { "version": { "allowBranch": ["main", "release/*"], "conventionalCommits": true, "message": "chore(release): publish %s", "ignoreChanges": ["**/*.md", "**/*.test.ts", "**/__tests__/**"] }, "publish": { "access": "public", "registry": "https://registry.npmjs.org/" } } }几个关键配置解释一下:
allowBranch:限制只有指定分支才能发版,防止在 feature 分支误操作。conventionalCommits:根据 commit message 自动决定版本号升级(feat 升 minor,fix 升 patch,BREAKING CHANGE 升 major)。ignoreChanges:这些文件改动不触发版本升级,避免改个 README 就发版。access: public:发布 scoped 包时默认是 private,需要显式声明 public。
4. 版本管理与发布流程详解
4.1 版本号升级的三种方式
lerna 的version命令支持三种版本升级方式:
方式一:手动指定版本号
npx lerna version 1.2.0所有包统一升到1.2.0(Fixed 模式),或者交互式选择每个包的版本(Independent 模式)。
方式二:语义化关键词
npx lerna version patch # 1.0.0 -> 1.0.1 npx lerna version minor # 1.0.0 -> 1.1.0 npx lerna version major # 1.0.0 -> 2.0.0方式三:根据 commit 自动判断
npx lerna version --conventional-commits这是我最推荐的方式。它会分析自上次发版以来的所有 commit,根据 conventional commits 规范自动决定每个包的版本升级幅度。feat:开头的 commit 触发 minor 升级,fix:触发 patch,带BREAKING CHANGE:的触发 major。
4.2 发布流程的完整步骤
一个标准的发布流程是这样的:
- 确保所有改动已提交,工作区干净。
- 跑一遍完整测试:
npx lerna run test。 - 执行版本升级:
npx lerna version --conventional-commits。 - lerna 会列出将要升级的包和版本号,确认后它会自动修改 package.json、生成 CHANGELOG、打 Git tag、提交。
- 推送到远程:
git push --follow-tags。 - 执行发布:
npx lerna publish from-git。
from-git表示从 Git tag 读取版本信息发布,避免重复计算。也可以用from-package,从 package.json 读取。
实操心得:发布前一定要先跑
npx lerna changed看看哪些包有改动。这个命令会列出所有自上次发版后有变更的包,避免漏发或误发。我有一次改了一个底层工具包,忘了它被三个上层包依赖,结果只发了一个包,导致线上依赖版本不一致,排查了半天。
4.3 依赖版本自动更新机制
lerna 在发版时有一个很重要的行为:当一个包升级时,所有依赖它的包,如果依赖版本范围不匹配新版本,lerna 会自动更新依赖声明并一并发版。
举个例子:ui依赖utils@^1.0.0,现在utils升到1.1.0,^1.0.0范围匹配,ui不需要发版。但如果utils升到2.0.0,^1.0.0不匹配,lerna 会自动把ui的依赖改成^2.0.0,并给ui也升一个 patch 版本一起发布。
这个机制保证了依赖链的一致性,但也意味着一次底层包的 major 升级可能触发大量上层包发版。所以底层包的 breaking change 要慎重,最好提前通知所有使用方。
4.4 CHANGELOG 自动生成
开启conventionalCommits后,lerna 会为每个包生成独立的 CHANGELOG.md,内容来自该包相关的 commit。这要求你的 commit message 规范,且 commit 时标注了影响的包。
如果 commit 没有明确标注包名,lerna 会根据文件改动路径来判断。比如改动packages/utils/src/index.ts,这个 commit 就会归到utils包的 CHANGELOG 里。
注意:CHANGELOG 的质量完全取决于 commit message 的质量。建议在项目里配置 commitlint + husky,强制规范 commit message。我见过太多项目 CHANGELOG 里全是 "update"、"fix bug" 这种无意义内容,等于白生成。
5. 常见问题与排查技巧实录
5.1 依赖链接失效问题
现象:本地改了utils的代码,ui里引用时还是旧版本。
原因:包管理器没有正确建立软链,或者node_modules里有缓存的旧版本。
排查步骤:
- 检查
ui/node_modules/@my-scope/utils是不是软链到packages/utils。用ls -la看。 - 如果不是软链,删掉所有
node_modules和 lock 文件,重新pnpm install。 - 如果是软链但代码没更新,检查
utils的main字段指向的是src还是dist。如果指向dist,需要先 build。
解决方案:在开发阶段,把utils的main指向src/index.ts,配合 tsconfig 的 paths 映射,实现源码级热更新。发布时再改成dist。
5.2 发版时提示 "No changed packages to version"
现象:明明改了代码,执行lerna version却说没有包需要升级。
原因:通常是ignoreChanges配置把改动过滤掉了,或者改动没有提交到 Git。
排查:
npx lerna changed --all加--all参数会列出所有包,不管有没有改动。对比一下就知道是哪个环节出了问题。
还有一种情况:改动只在未跟踪的文件里,lerna 只检测已提交的改动。先git add再试。
5.3 发布权限与 registry 配置
现象:lerna publish报 403 或 401 错误。
原因:npm token 没配置,或者 scope 的 registry 指向不对。
解决:
npm login npm whoami # 确认登录状态如果用的是私有 registry,在.npmrc里配置:
@my-scope:registry=https://your-registry.com/ //your-registry.com/:_authToken=${NPM_TOKEN}CI 环境里通过环境变量注入 token,不要硬编码在文件里。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| 本地包引用不到 | 软链未建立 | ls -la node_modules/@scope/ | 重装依赖 |
| 发版无变更 | 改动未提交或忽略规则 | lerna changed --all | 检查 git status 和 ignoreChanges |
| 发布 403 | token 失效或权限不足 | npm whoami | 重新登录或更新 token |
| 构建顺序错误 | 拓扑依赖未识别 | lerna run build --graph | 检查 package.json 依赖声明 |
| 版本号冲突 | 远程已有同版本 | npm view @scope/pkg versions | 手动指定更高版本 |
| CHANGELOG 为空 | commit 不规范 | git log --oneline | 配置 commitlint |
5.5 独家避坑技巧
技巧一:用--no-private跳过私有包发布。如果仓库里有不发布的包(比如应用壳),在 publish 时加这个参数,避免误发。
技巧二:发版前用--dry-run预演。lerna version --dry-run和lerna publish --dry-run会展示将要执行的操作但不实际执行,适合在 CI 里做校验。
技巧三:CI 里用--yes跳过交互。自动化发布时加--yes参数,避免卡在交互确认上。
技巧四:用lerna run --scope精准执行。只跑某个包及其依赖的脚本:lerna run build --scope=@my-scope/ui --include-dependencies。
技巧五:定期清理无用包。仓库里废弃的包要及时删掉,否则每次lerna run都会遍历它们,拖慢速度。
6. 构建优化与 CI 集成实践
6.1 增量构建与缓存
多包仓库的构建时间会随着包数量增加而线性增长。优化手段有两个方向:只构建有改动的包和缓存构建结果。
lerna 本身不提供缓存,但可以配合nx或turborepo使用。从 lerna 6 开始,它内置了对 nx 的支持,在lerna.json里加:
{ "useNx": true }然后安装 nx:npm install nx --save-dev。nx 会自动分析包的依赖图和文件改动,只重新构建受影响的包,并缓存构建产物。实测在 12 个包的仓库里,全量构建从 8 分钟降到 1 分半。
如果不想引入 nx,也可以用lerna run build --since=origin/main,只构建自 main 分支以来有改动的包。
6.2 CI 流水线配置
一个典型的 CI 配置(以 GitHub Actions 为例):
name: CI on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v2 with: version: 8 - uses: actions/setup-node@v4 with: node-version: 18 cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: npx lerna run build --since=origin/main - run: npx lerna run test --since=origin/main关键点:fetch-depth: 0必须加,否则 lerna 拿不到完整的 Git 历史,无法判断哪些包有改动。
6.3 发布流水线
发布单独建一个 workflow,只在打 tag 时触发:
name: Publish on: push: tags: - 'v*' jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 - uses: actions/setup-node@v4 with: node-version: 18 registry-url: 'https://registry.npmjs.org' - run: pnpm install --frozen-lockfile - run: npx lerna publish from-git --yes env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}from-git从 tag 读取版本,--yes跳过确认,NODE_AUTH_TOKEN从 secrets 注入。
6.4 性能对比数据
在一个 12 包仓库里的实测数据(仅供参考,具体因项目而异):
| 操作 | 无优化 | 加 --since | 加 nx 缓存 |
|---|---|---|---|
| 全量构建 | 8min | 3min | 1.5min |
| 全量测试 | 5min | 2min | 1min |
| 发版流程 | 2min | 2min | 2min |
构建和测试的优化效果明显,发版流程本身耗时不多,优化空间有限。
7. 迁移与扩展的实战建议
7.1 从多仓库迁移到 monorepo
迁移不是一蹴而就的,建议分三步走:
第一步:先合并工具包。把最底层、被依赖最多的工具包先迁进来,验证 monorepo 的构建和发布流程。这一步风险最小,收益也最直接。
第二步:迁移中间层包。组件库、SDK 这类依赖工具包的包,在工具包稳定后迁入。迁移时注意把dependencies里的版本号改成workspace:*。
第三步:迁移应用层。应用壳最后迁,因为它们通常不发布,只是消费方。迁入后可以享受本地调试的便利。
每一步之间留出观察期,确保线上稳定再继续。
7.2 仓库体积控制
monorepo 的 Git 仓库会越来越大,克隆速度变慢。几个控制手段:
- 构建产物(dist、lib)加进
.gitignore,不要提交。 - 大文件用 Git LFS 管理。
- 定期用
git gc清理。 - 如果历史包袱太重,考虑用
git filter-repo精简历史,或者干脆开新仓库。
7.3 权限与代码审查
monorepo 的一个挑战是权限管理。所有代码在一个仓库里,如何确保不同包的 owner 只审查自己包的改动?
可以用CODEOWNERS文件:
packages/utils/ @team-utils packages/ui/ @team-ui packages/sdk/ @team-sdk配合 CI 里的路径过滤,只跑受影响包的测试,减少无关团队的等待时间。
7.4 后续扩展方向
lerna 的生态还在演进。几个值得关注的方向:
- 与 turborepo 结合:turborepo 的远程缓存能力比 nx 更轻量,适合中小团队。
- changesets 替代方案:changesets 是另一个版本管理工具,交互更友好,适合 Independent 模式。lerna 和 changesets 可以共存,各取所长。
- 发布到私有 registry:如果公司有内部 registry,lerna 的
registry配置可以指向私有地址,配合 CI 的 token 注入实现自动化发布。
我在实际使用中的体会是:lerna 的价值不在于它有多强大,而在于它把多包管理的复杂度收敛到了一套可预测的流程里。你不需要记住每个包的版本号,不需要手动协调依赖关系,不需要担心发版顺序。这些事交给工具,你专注写代码就好。踩过的坑主要集中在依赖链接和发版配置上,把这两块搞明白,后面基本就是顺水推舟。最后分享一个小技巧:在根目录建一个scripts/目录放自定义脚本,比如批量检查包名规范、批量更新 license 文件,配合lerna run使用,能省下不少重复劳动。