我在金融科技公司用 Lerna Monorepo 一年,踩过的坑比代码还 背景继上篇《React 企业级项目中我为什么选择 MobX 而不是 Redux》之后继续聊聊我们项目的工程化选型。我们的知识管理平台KMS前端是一个 Lerna Monorepo包含三个 packagewebC 端知识门户面向外部用户dashboardB 端管理后台配置知识库、权限、审核mobileH5 移动端轻量浏览和审批技术栈是 React 18 TypeScript Ant Design MobX代码库运行了一年多踩了不少坑也沉淀了一些经验。为什么选 Lerna Monorepo而不是 Nx 或 Turborepo说实话选 Lerna 不是因为它最好而是因为它最简单。2023 年我们立项时Nx 的学习曲线太陡Turborepo 还比较早期。Lerna 的lerna bootstraplerna 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-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 不是银弹这些场景你该三思团队超过 15 人。Monorepo 的冲突成本指数增长。每周至少一次pnpm-lock.yaml冲突新人入职第一个月大概率会误改 shared 包。包之间没有共享类型。如果你的几个项目完全独立、连类型都各自定义Monorepo 只增加了构建耦合没有好处。没有专职 DevOps 人员。CI 管线的维护成本比想象中高特别是多端的差异化部署流程。我们 dashborad 走 K8smobile 走 CDN 静态托管部署脚本是两套。如果可以重来我会怎么做pnpm workspace Turborepo 起手跳过 Lerna。Lerna 的生态在 2025 年已经明显收缩bug 修得越来越慢。把 shared-ui 砍掉。C 端和 B 端的 UI 差异天然大强行复用反而增加沟通成本。类型和工具函数复用就够了。第一周就把 CI 缓存配好而不是忍了半年慢构建才去搞。前端工程化这件事选型不难难的是在正确的时间做正确的切割——什么该共用什么该独立什么时候该拆分什么时候该忍着。这些判断没有银弹只有踩过坑才知道。如果这篇文章对你有帮助欢迎一键三连。也欢迎在评论区聊聊你们团队的 Monorepo 实践。