背景

继上篇《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-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 不是银弹:这些场景你该三思

  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 实践。

Logo

一站式 AI 云服务平台

更多推荐