我在金融科技公司用 Lerna Monorepo 一年,踩过的坑比代码还
2026/6/10 3:00:58 网站建设 项目流程

背景

继上篇《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 的包管理方式经历了两次重大变化,每一次都挺疼的:

  1. 初期:Lerna + Yarn Workspaces。bootstrap 慢、幽灵依赖问题频出。
  2. 中期:Lerna + pnpm Workspaces。切换到 pnpm 解决了幽灵依赖,但一些老包的peerDependencies没声明清楚,迁移时炸了一轮。
  3. 当前: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-types95%
工具函数@kms/shared-utils80%
UI 组件@kms/shared-ui60%(C端和B端交互差异大)
业务逻辑不复用,各端维护0%(刻意不复用)

业务逻辑刻意不复用这件事反而是最关键的决策。早期我们试图把知识库鉴权逻辑写成@kms/shared-auth,结果 C 端和 B 端的权限模型差异越来越大,shared-auth 里面的 if-else 和配置项膨胀到不可维护。后来拆回各自 package,各自维护一套轻量 auth,团队反而更舒服了。

CI/CD 时间对比

阶段优化前优化后手段
类型检查90s25s项目引用(TypeScript Project References)
单元测试120s45s仅重跑变更包的测试 + vitest 缓存
构建240s40sTurborepo 远程缓存 + 增量构建
总计~7.5 分钟~2 分钟-

Monorepo 不是银弹:这些场景你该三思

  1. 团队超过 15 人。Monorepo 的冲突成本指数增长。每周至少一次pnpm-lock.yaml冲突,新人入职第一个月大概率会误改 shared 包。
  2. 包之间没有共享类型。如果你的几个项目完全独立、连类型都各自定义,Monorepo 只增加了构建耦合,没有好处。
  3. 没有专职 DevOps 人员。CI 管线的维护成本比想象中高,特别是多端的差异化部署流程。我们 dashborad 走 K8s,mobile 走 CDN 静态托管,部署脚本是两套。

如果可以重来,我会怎么做

  1. pnpm workspace + Turborepo 起手,跳过 Lerna。Lerna 的生态在 2025 年已经明显收缩,bug 修得越来越慢。
  2. 把 shared-ui 砍掉。C 端和 B 端的 UI 差异天然大,强行复用反而增加沟通成本。类型和工具函数复用就够了。
  3. 第一周就把 CI 缓存配好,而不是忍了半年慢构建才去搞。

前端工程化这件事,选型不难,难的是在正确的时间做正确的切割——什么该共用,什么该独立,什么时候该拆分,什么时候该忍着。这些判断没有银弹,只有踩过坑才知道。


如果这篇文章对你有帮助,欢迎一键三连。也欢迎在评论区聊聊你们团队的 Monorepo 实践。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询