🏗️ Monorepo 与 workspaces

一句话定性

Monorepo 是”把所有包塞进一个 Git 仓库”的代码组织哲学,灵感来自 Google/Facebook 的巨型单仓实践。前端拥抱它,是因为组件库 + 多应用 + 设计系统有强烈的代码共享需求。workspaces 是包管理器对 Monorepo 的原生支持——把仓库里的多个包识别为”一体”,共享 node_modules、本地互相 import。它把 npm-registry与包生态 的”共享”从分发层推进到了组织层,代价是仓库膨胀和构建放大

边界说明

本篇讲 Monorepo 概念 + workspaces 机制 + polyrepo 对比workspaces 底层如何解析/提升依赖(node_modules 结构、幽灵依赖)属于包管理器内部机制,见 npm-Yarn-pnpm-包管理为什么pnpm解决了依赖问题——本篇不展开。


一、它是什么 & 出现的时代

Monorepo(单一代码仓库):多个项目/包共存于同一个版本控制仓库。与之相对的是 polyrepo(多仓库):一个包一个仓库。

  • 概念源头是大公司的工程实践:Google 把几乎全公司代码放进一个超巨型单仓(配 Bazel 构建);Facebook、微软也有类似实践。它们证明了”单仓 + 强工具”在超大规模下可行。
  • 前端在 2018-2023 工程化时代 大规模采纳 Monorepo:随着组件库、设计系统、多应用矩阵成为大型团队标配,跨包共享代码的痛点变得无法忍受。
  • workspaces 是包管理器把 Monorepo “原生化”的功能:Yarn 率先引入(Yarn Classic),随后 npm(v7)、pnpm 都内置支持。

二、为什么会出现(解决上一代什么痛点)

polyrepo 的"组织版依赖地狱"

假设你有一个组件库 @acme/ui 和三个应用 A/B/C 都依赖它。在 polyrepo 下改一个按钮组件:

  1. @acme/ui 仓改代码 → 提 PR → 发新版本到 npm-registry与包生态
  2. 去 A 仓 → 升级 @acme/ui 版本 → 测试 → 提 PR → 发版。
  3. B 仓、C 仓重复第 2 步。

一个改动 → 4 个仓 → 4 个 PR → 多次发版 → 版本随时可能漂移对不齐。 跨包重构更是噩梦:你永远不知道还有谁在用旧 API。

前端尤其痛,因为它的共享对象高度内聚:

前端为什么特别需要 Monorepo

  • 组件库 + 多应用共享代码:UI 组件、工具函数、类型定义需要被多个应用即时复用。
  • 设计系统(design system):design tokens、主题、图标、组件必须严格同步,版本漂移会直接导致 UI 不一致。
  • 统一工具链:一套 ESLint/Prettier/TypeScript/Vite/Webpack 配置,在 Monorepo 里可以集中管理,而非每个仓各抄一份。
  • 类型贯通:TypeScript 在 Monorepo 里能跨包直接跳转、即时类型检查,无需先发包。

三、核心机制 & 为什么流行

workspaces 做了什么

在仓库根 package.json 声明 workspaces:

{
  "name": "acme-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}
acme-monorepo/
├── package.json          ← 声明 workspaces
├── node_modules/         ← 【唯一一处】所有包的依赖统一安装在此
│   └── @acme/ui  ──► 软链接到 packages/ui(本地包,不走 registry)
├── packages/
│   ├── ui/               (@acme/ui)
│   └── utils/            (@acme/utils)
└── apps/
    ├── web/              依赖 @acme/ui → 直接指向本地源码
    └── admin/

workspaces 的三个关键能力:

能力说明解决的痛点
本地包互联包之间用软链接互相 import,不经过 registry 发版@acme/uiapps/web 立即生效
依赖统一安装/提升所有包的外部依赖装在根 node_modules,公共依赖只装一份省磁盘、版本统一
一条命令跨包操作npm install 一次装全仓;批量跑脚本不必逐仓操作

Monorepo 的三大红利

为什么大型团队选它

  1. 原子提交(atomic commit):改组件库 + 改所有调用方,在同一个 commit / PR 里完成。CI 要么全过要么全挂,绝不会出现”库发了新版但应用还没跟上”的中间态。
  2. 统一依赖:全仓共用一套依赖版本,从根上消灭”A 用 React 17、B 用 React 18”的漂移。
  3. 零成本代码共享:新建一个共享包,其他包立刻能 import,无需发版、无需安装。

为什么流行:它把 npm-registry与包生态 里”共享代码”的理念,从分发层(发包/装包) 推进到组织层(同一棵源码树)——共享的延迟从”一次发版”降到”零”。


四、带来的新问题 / 副作用

把所有鸡蛋放一个篮子的代价

  • 构建放大(build amplification):改一个底层包,理论上要重建/重测所有依赖它的包。仓库越大,一次改动牵动的 CI 越多,构建时间爆炸式增长。这是 Monorepo 最核心的痛点,也是 Lerna与TurborepoNx 存在的全部理由。
  • 仓库膨胀:几十上百个包挤一个仓,git clone、IDE 索引、CI checkout 全变慢(Google 级别甚至要 VFS、sparse-checkout)。
  • 权限难题:polyrepo 天然按仓隔离权限;Monorepo 里所有人都能看到所有代码,需要 CODEOWNERS、目录级权限等额外机制。
  • 工具门槛:朴素的 workspaces 只解决”装在一起”,不解决构建编排。一旦上规模,几乎必须叠加专门的 Monorepo 工具。
  • 发版协调:仓内有的包要发到 npm-registry与包生态、有的不发,版本号怎么管?(这正是 Lerna与Turborepo 里 Lerna 要解决的)

五、为什么会衰落 / 现状

Monorepo 不是衰落,而是成为大型前端的默认形态

现状(2026)

  • polyrepo vs Monorepo 不是非此即彼:小型/边界清晰的独立库仍适合 polyrepo;大型产品矩阵、组件库 + 多应用、设计系统几乎一致选 Monorepo。
  • pnpm workspaces 成为 Monorepo 首选底座:严格(无幽灵依赖)+ 省磁盘的特性在多包场景收益放大,详见 npm-Yarn-pnpm-包管理
  • 裸 workspaces 不够用:几乎总要叠加 Lerna与TurborepoNx 来解决构建放大。
  • 新组合范式:有团队探索 “polyrepo + 元仓库” 或 “Monorepo + 微前端” 的折中,但核心张力始终是”共享便利 vs 隔离可控”。

六、对后续技术的影响(因果链)

[[npm-registry与包生态]]:共享代码廉价化(但仍需发版/装包)
        │
        ▼
前端需求升级:组件库 + 多应用 + 设计系统 + 统一工具链
        │  polyrepo 下跨包改动 = N 个 PR + N 次发版 + 版本漂移
        ▼
Monorepo:所有包进一个仓 ── 受 Google/Facebook 单仓实践影响
        │  红利 = 原子提交 + 统一依赖 + 零成本共享
        ▼
workspaces:包管理器原生支持(本地包互联 + 依赖提升)
        │  但只解决"装在一起",不解决【构建放大】
        ▼
副作用 = 构建放大 + 仓库膨胀 + 权限
        │
        ├──► Lerna(2015):版本管理 + 发布 ──► [[Lerna与Turborepo]]
        ├──► Turborepo(2021):增量构建 + remote cache ──► [[Lerna与Turborepo]]
        └──► Nx(Nrwl):依赖图 + affected + 代码生成 ──► [[Nx]]
                                ▲
                                └── 重型参照:Bazel(Google)

历史地位

Monorepo 把”代码共享”这件事推到了组织结构的极致:不再是”发个包给你用”,而是”我们本来就在一棵树上”。它换来了原子提交和统一依赖的巨大红利,也亲手制造了”构建放大”这个新魔王——而消灭这个魔王的努力,直接催生了 Lerna与TurborepoNx 这一整代以增量 + 缓存 + 依赖图为核心的工具。Monorepo 不是终点,而是一个新工具品类的起点。


🔗 相关:包生态与Monorepo演进史 | npm-registry与包生态 | Lerna与Turborepo | Nx | npm-Yarn-pnpm-包管理 | 为什么pnpm解决了依赖问题 | TypeScript | Webpack | Vite | 2018-2023 工程化时代