我在金融科技公司用 Lerna Monorepo 一年,踩过的坑比代码还
背景
继上篇《React 企业级项目中,我为什么选择 MobX 而不是 Redux》之后,继续聊聊我们项目的工程化选型。我们的知识管理平台(KMS)前端是一个 Lerna Monorepo,包含三个 package:
- web:C 端知识门户,面向外部用户
- dashboard:B 端管理后台,配置知识库、权限、审核
- mobile:H5 移动端,轻量浏览和审批
技术栈是 React 18 + TypeScript + Ant Design + MobX,代码库运行了一年多,踩了不少坑,也沉淀了一些经验。
为什么选 Lerna Monorepo,而不是 Nx 或 Turborepo
说实话,选 Lerna 不是因为它最好,而是因为它最简单。2023 年我们立项时,Nx 的学习曲线太陡,Turborepo 还比较早期。Lerna 的 lerna bootstrap + lerna run 两个命令基本够用。
但现在回头看,Lerna 的包管理方式经历了两次重大变化,每一次都挺疼的:
- 初期:Lerna + Yarn Workspaces。bootstrap 慢、幽灵依赖问题频出。
- 中期:Lerna + pnpm Workspaces。切换到 pnpm 解决了幽灵依赖,但一些老包的
peerDependencies没声明清楚,迁移时炸了一轮。 - 当前:Lerna + pnpm +
workspace:*协议。相对稳定,但发布流程的坑后文会讲。
如果你现在新起项目,我会直接建议 pnpm workspace + Turborepo,跳过 Lerna 的坑。
三个真实踩坑
1. 共享 package 的边界怎么划:被 @kms/shared 毒打的一年
我们一开始很天真,建了一个 @kms/shared,把三个端都用的 TypeScript 类型、工具函数、常量全扔进去。三个月后这个包变成了垃圾桶——一百多个文件,大到连 IDE 都开始卡。
更致命的是,shared 的变更会把三个端全部炸掉。你改了一个工具函数的参数,dashboard 的 CI 红了,mobile 也红了,你在工位上一边修一边怀疑人生。
现在的做法:拆分共享层为三个粒度
packages/
├── shared-types/ # 纯类型定义,零运行时,import type 导入
├── shared-utils/ # 纯工具函数,无副作用,每个函数独立导出
└── shared-ui/ # 跨端复用的纯展示组件,不含业务逻辑
关键规则:
shared-types不引入任何依赖,编译后体积接近零。shared-utils的每个函数必须独立 ts 文件,禁止 barrel export 链。shared-ui严格遵守"无业务逻辑",入参出参就是 Ant Design 那一套。
效果:dashboard 改了一个类型,mobile 不会无辜重新构建。看起来是常识,但踩一遍才知道疼。
2. 构建顺序的隐形炸弹
Lerna 默认 lerna run build 是按 package 名字母序串行执行的,如果 A 依赖 B 的构建产物,字母序不对就挂了。
我们的解决方式没有用 --include-dependencies(那玩意会在每次 CI 把整个仓库重建一遍),而是手动维护了构建拓扑:
// lerna.json
{
"command": {
"run": {
"ignore": ["@kms/mobile"]
}
}
}
// 根目录 package.json scripts
{
"build": "pnpm --filter @kms/shared-types build && pnpm --filter @kms/shared-utils build && pnpm --filter @kms/shared-ui build && lerna run build --parallel --ignore @kms/shared-types --ignore @kms/shared-utils --ignore @kms/shared-ui"
}
不优雅,但稳。后来接了 Turborepo 的缓存机制(Lerna v7 之后支持通过 -- 透传给 Turborepo),构建时间从 4 分钟降到 40 秒。
3. 发版流程的血泪史
我们犯过最大的错是:三个端共用同一个版本号。
初期用了 lerna version --force-publish,每次发版把所有 package 版本号一起升。后果是 web 改了一个文案,dashboard 和 mobile 的 CHANGELOG 也被强制更新了一行 “no changes”,季度复盘的时候从 CHANGELOG 根本看不出什么东西改了。
现在的做法:
- 只有共享包(shared-types / shared-utils / shared-ui)的版本号统一管理
- web / dashboard / mobile 各自独立版本,用
lerna version --no-private排除 - CI 发布流程加入了 diff 检测:如果一个 package 的
src/没有 diff,跳过该 package 的构建和部署
还有一个教训:永远不要在一个 MR 里同时改 shared 包和消费端。先把 shared 改好、发版、跑通回归,再开新的 MR 升级消费端的依赖版本。拆成两步走,出问题才追得回来。
工程化收益:这些事没白做
跨端代码复用率:
| 类型 | 复用方式 | 实际复用比例 |
|---|---|---|
| TypeScript 类型 | @kms/shared-types |
95% |
| 工具函数 | @kms/shared-utils |
80% |
| UI 组件 | @kms/shared-ui |
60%(C端和B端交互差异大) |
| 业务逻辑 | 不复用,各端维护 | 0%(刻意不复用) |
业务逻辑刻意不复用这件事反而是最关键的决策。早期我们试图把知识库鉴权逻辑写成 @kms/shared-auth,结果 C 端和 B 端的权限模型差异越来越大,shared-auth 里面的 if-else 和配置项膨胀到不可维护。后来拆回各自 package,各自维护一套轻量 auth,团队反而更舒服了。
CI/CD 时间对比
| 阶段 | 优化前 | 优化后 | 手段 |
|---|---|---|---|
| 类型检查 | 90s | 25s | 项目引用(TypeScript Project References) |
| 单元测试 | 120s | 45s | 仅重跑变更包的测试 + vitest 缓存 |
| 构建 | 240s | 40s | Turborepo 远程缓存 + 增量构建 |
| 总计 | ~7.5 分钟 | ~2 分钟 | - |
Monorepo 不是银弹:这些场景你该三思
- 团队超过 15 人。Monorepo 的冲突成本指数增长。每周至少一次
pnpm-lock.yaml冲突,新人入职第一个月大概率会误改 shared 包。 - 包之间没有共享类型。如果你的几个项目完全独立、连类型都各自定义,Monorepo 只增加了构建耦合,没有好处。
- 没有专职 DevOps 人员。CI 管线的维护成本比想象中高,特别是多端的差异化部署流程。我们 dashborad 走 K8s,mobile 走 CDN 静态托管,部署脚本是两套。
如果可以重来,我会怎么做
- pnpm workspace + Turborepo 起手,跳过 Lerna。Lerna 的生态在 2025 年已经明显收缩,bug 修得越来越慢。
- 把 shared-ui 砍掉。C 端和 B 端的 UI 差异天然大,强行复用反而增加沟通成本。类型和工具函数复用就够了。
- 第一周就把 CI 缓存配好,而不是忍了半年慢构建才去搞。
前端工程化这件事,选型不难,难的是在正确的时间做正确的切割——什么该共用,什么该独立,什么时候该拆分,什么时候该忍着。这些判断没有银弹,只有踩过坑才知道。
如果这篇文章对你有帮助,欢迎一键三连。也欢迎在评论区聊聊你们团队的 Monorepo 实践。
更多推荐


所有评论(0)