跳到主要内容

Monorepo:概念、优势、落地实践与常见坑

在前端工程化里,Monorepo(“单仓多包”)是一种非常常见的代码组织方式:把多个应用(App)与多个可复用的包(Package,例如组件库、工具库、配置包)放在同一个 Git 仓库里统一管理。

它不是银弹:Monorepo 能带来“统一与协同”,也会引入“规则与成本”。这篇文章会用尽量通俗的方式,带你把 Monorepo 的是什么、为什么、怎么做、容易踩哪些坑一次讲清楚,并给出可直接照着落地的示例。

一、Monorepo 到底是什么?

如果用一句话总结:

  • Multi-repo:一个应用一个仓库,一个库一个仓库,靠“发布版本 + 依赖安装”来协作。
  • Monorepo:所有相关项目在一个仓库里,靠“工作区(workspace)+ 统一工具链”来协作。

1.1 常见术语速查

术语你可以理解为常见例子
Workspace(工作区)“同一个仓库里有多个包”的管理机制pnpm/yarn/npm workspaces
Package(包)可被依赖、可被发布的模块单元packages/utilspackages/ui
App(应用)最终要运行/部署的项目apps/webapps/admin
Dependency Graph(依赖图)包与包之间的依赖关系网web -> ui -> utils
Task Runner(任务编排)按依赖顺序/并行地跑任务Turborepo、Nx
Affected(受影响范围)根据改动计算需要跑哪些包“只跑改动相关的 lint/test/build”

Monorepo 的关键不是“把代码放一起”这么简单,而是配套的工程能力:

  • Workspace(工作区):让多个 package 在同一仓库里能被安装、链接、引用。
  • 依赖图(dependency graph):明确 A 依赖 B,构建/测试要按顺序跑。
  • 任务编排与缓存:让“只改了某几个包”时能更快地跑完 CI。
  • 统一规范:统一 lint、tsconfig、测试、发布流程,降低团队协作成本。

二、什么时候适合用 Monorepo?

2.1 特别适合

  • 你们有多个前端应用,并且它们会复用同一套基础能力(UI 组件、hooks、utils、业务 SDK)。
  • 你们希望把“重复的工程配置”沉淀成共享配置包(例如 @acme/eslint-config@acme/tsconfig)。
  • 你们经常需要跨仓库联调,受“发版—安装—联调”折磨。
  • 你们希望在 CI 中实现“只跑受影响的包”,缩短流水线时间。

2.2 可能不适合

  • 仓库里只有一个应用,几乎没有共享包。
  • 团队规模很小、迭代很快,但没有人愿意维护工程规则(Monorepo 很吃“规则”)。
  • 代码和权限边界非常强(不同团队/供应商不希望互相看到代码),更适合拆仓做权限隔离。

三、Monorepo 的核心收益与代价

3.1 核心收益

  • 代码复用更自然:共享包改完,应用立刻可用,不必等发布/升级版本。
  • 统一工程规范:一次配置,多处复用;新项目接入成本更低。
  • 联调更顺滑:跨包改动可以一次提交、一次审查、一次回滚。
  • CI 更快:配合任务编排和缓存,只跑受影响部分。

3.2 主要代价

  • 工具复杂度上升:需要 workspace、任务编排、发布策略、依赖边界等配套。
  • 仓库变大:拉取/索引/CI 都需要更好的缓存与拆分策略。
  • 容易变“代码大杂烩”:没有边界约束时,包之间互相引用会越来越乱。

四、常见 Monorepo 工具选型(前端方向)

Monorepo 里通常会出现三类工具:

  1. 包管理器(提供 workspace 能力)
  • pnpm(常用):空间占用小、安装快、依赖隔离更严格。
  • yarn / npm:也支持 workspaces,但生态实践与细节略有差异。
  1. 任务编排/缓存
  • Turborepo:上手快,适合大多数前端 Monorepo。
  • Nx:能力强,适合更复杂的依赖图与大型团队协作。
  1. 版本与发布(可选)
  • changesets:常见的发版方案(生成变更集、自动更新版本、生成 changelog)。
  • lerna:历史悠久(现代项目常与 pnpm/yarn + changesets 搭配或替代)。

提示:如果你刚开始落地,建议先把“workspace + 统一脚本”跑通,再逐步引入 Turborepo/Nx、changesets 等能力,避免一上来就工具堆满导致团队失控。

五、实战:用 pnpm workspaces 搭一个最小 Monorepo

下面用一个最小的例子说明 Monorepo 的基本结构。假设我们要做:

  • 两个应用:apps/webapps/admin
  • 一个共享工具包:packages/utils
  • 一个共享组件包:packages/ui(依赖 utils

5.1 目录结构建议

my-monorepo/
apps/
web/
admin/
packages/
utils/
ui/
package.json
pnpm-workspace.yaml

5.2 pnpm-workspace.yaml

packages:
- 'apps/*'
- 'packages/*'

5.3 根目录 package.json(统一脚本的关键)

根目录一般会做三件事:

  1. 声明 private: true(避免把根目录误发布到 npm)
  2. 统一常用 scripts(lint/build/test/typecheck
  3. 统一工具链版本(比如 packageManager、Node 版本约束)
{
"name": "my-monorepo",
"private": true,
"scripts": {
"lint": "pnpm -r lint",
"test": "pnpm -r test",
"typecheck": "pnpm -r typecheck",
"build": "pnpm -r build"
}
}

5.4 包之间怎么“本地互相依赖”?

apps/webpackage.json 里,你会看到类似:

{
"name": "@acme/web",
"dependencies": {
"@acme/utils": "workspace:*"
}
}

workspace:* 的含义是:优先使用工作区内的同名包(也就是 Monorepo 里的 packages/utils),而不是去下载远程 npm 上的版本。这样就能做到“共享包改了,应用马上用到”。

5.5 一个典型共享包:packages/utils(最小可用形态)

packages/utils/package.json(示意):

{
"name": "@acme/utils",
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint ."
}
}

重点不是“用哪种打包命令”,而是每个包都应该有统一的 build/typecheck/lint/test 入口,根目录才能一键编排。

5.6 一个典型组件包:packages/ui(为什么需要 peerDependencies)

组件包(示意):

  • dependencies:放工具库、内部包(例如 @acme/utils
  • peerDependencies:放 React 这类“宿主决定版本”的基础库
{
"name": "@acme/ui",
"version": "0.1.0",
"dependencies": {
"@acme/utils": "workspace:*"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
}

5.7 常用命令(pnpm)

在 Monorepo 根目录执行:

# 安装整个工作区依赖(一次安装,所有包都就位)
pnpm install

# 递归执行每个包的 build(前提:每个包都有 scripts.build)
pnpm -r build

# 只在某个包里执行命令(按名称过滤)
pnpm -r --filter @acme/web dev

小提示:Monorepo 的“统一脚本”非常关键。你可以把常用任务(build/test/lint/typecheck)都约定成同名脚本,根目录再用 pnpm -r 一键跑全仓。

六、依赖管理:把“共享”做对的关键

6.1 workspace 协议(workspace:*)不止一种写法

在 Monorepo 里,依赖内部包通常会写成 workspace 协议(以 pnpm 为例):

  • workspace:*:总是使用工作区内版本(最常见,联调体验最好)
  • workspace:^:发布时更贴近“按 semver 主版本兼容”的习惯(适合独立发版)
  • workspace:~:发布时更贴近“只允许小版本/补丁”的习惯

实际选哪种,取决于你的版本策略(固定版本还是独立版本)以及发布流程是否成熟。

6.2 避免“幽灵依赖”

所谓幽灵依赖(Phantom Dependency),常见于:

  • A 包没声明依赖 lodash,但因为某种 hoist 或上层依赖存在,A 里 import lodash 仍然能跑;
  • 一旦安装策略变化或 CI 环境不同,就会突然报错。

建议:

  • 每个包必须在自己的 package.json 声明真实依赖(dependencies/devDependencies/peerDependencies)。
  • 在 CI 中加一道“干净安装 + 构建”校验(防止本地环境误导)。

6.3 什么时候用 peerDependencies?

典型场景:packages/ui 这种组件库通常会把 react 放到 peerDependencies

  • 避免 UI 包把 React “打包一份”带进应用,出现多份 React 的问题;
  • 让应用自己决定 React 的版本(同时由包声明可兼容范围)。

6.4 依赖关系要“单向清晰”

经验法则:

  • apps/* 可以依赖 packages/*
  • packages/* 尽量不要反向依赖 apps/*
  • packages/* 之间的依赖要控制层级,避免形成“环”

6.5 用 exports 保护“公共 API”,避免深层路径 import

Monorepo 里一个很常见的坏味道是:

  • 别人为了图省事,直接 import {x} from "@acme/utils/src/internal/x"
  • 结果你一改目录结构,全仓都崩。

更稳的做法是:共享包只暴露“对外入口”,并通过 package.json#exports 限制导入路径(示意):

{
"name": "@acme/utils",
"exports": {
".": "./dist/index.js"
}
}

这样大家只能从 @acme/utils 这个公共入口引用,包的内部重构就不会牵一发动全身。

七、构建与 CI:让大仓也能快起来

Monorepo 的构建核心是“按依赖顺序 + 可缓存 + 只跑受影响”:

落地建议(从易到难):

  1. 先做到“统一脚本 + 根目录一键跑全仓”。
  2. 再做到“按包过滤执行”(例如只构建 apps/web 相关的依赖链)。
  3. 最后引入 Turborepo/Nx 的缓存与 affected 能力,把 CI 时间压下去。

八、版本管理与发布策略(入门必懂)

Monorepo 常见两种版本策略:

8.1 固定版本(Lockedstep / Fixed)

特点:

  • 所有包共享一个版本号,例如全仓都是 1.8.0
  • 发版简单,但“只改了一个小包”也可能触发全仓版本变化。

适合:

  • 包之间耦合度高、需要强一致性的场景(比如框架/内核类仓库)。

8.2 独立版本(Independent)

特点:

  • 每个包有自己的版本号,按变更独立发布;
  • 更精细,但需要更成熟的发布流程(变更集、自动 bump、changelog)。

适合:

  • 组件库/工具库等可独立演进的包较多的场景。

8.3 changesets(常见的“独立发版”落地方案)

changesets 的核心思想是:每次改动先写一份“变更说明”,再由工具统一生成版本号与 changelog。

常见流程(示意):

# 1) 生成变更集(选择哪些包需要 bump,属于 major/minor/patch)
pnpm changeset

# 2) 根据变更集更新各包版本号,并生成 changelog
pnpm changeset version

# 3) 发布到 registry
pnpm changeset publish

九、常见坑与排雷清单

  • 包之间互相乱引用:没有边界就会“哪里都能 import”,最后难以拆分与复用。
    • 做法:明确分层(core -> shared -> feature -> apps),必要时加 lint 规则限制跨层依赖。
  • 依赖环:A 依赖 B,B 又依赖 A,构建顺序和运行时都会出问题。
    • 做法:定期检查依赖图;设计时保持单向依赖。
  • 多份 React/基础库:组件库把 React 装进 dependencies,应用也装一份,可能导致 hooks 失效等诡异问题。
    • 做法:把 React 放到 peerDependencies,并在根目录统一版本策略。
  • CI 太慢:全仓每次都全量 lint/test/build。
    • 做法:先“过滤执行”,再上“缓存 + affected”。
  • 共享配置没人维护:共享 eslint/tsconfig 一旦没人维护,就会变成“大家都不敢动”的黑盒。
    • 做法:把配置当产品维护,有 owner,有升级节奏,有变更记录。

十、面试高频问答

1)什么是 Monorepo?和 Multi-repo 有什么区别?

  • Monorepo:一个仓库内管理多个 package/app,通过 workspace 统一依赖与协作。
  • Multi-repo:每个项目独立仓库,通过“发版 + 依赖升级”协作。
  • 核心差异:协作方式不同(本地联调 vs 版本联动)、工具链不同(统一 vs 分散)。

2)Monorepo 的最大收益是什么?最大的风险是什么?

  • 最大收益:统一规范 + 共享代码更顺畅 + 跨包改动可一次提交 + CI 可做增量与缓存。
  • 最大风险:没有边界会导致依赖混乱;工具/规则成本上升;仓库变大导致效率下降。

3)workspace 是什么?workspace:* 的作用是什么?

  • workspace:包管理器提供的“同仓多包”能力,让包之间可以本地链接与依赖。
  • workspace:*:声明“优先使用工作区内包”,便于联调与统一升级。

4)Monorepo 里如何避免幽灵依赖?

  • 每个包完整声明依赖;
  • CI 做干净安装校验;
  • 尽量选择依赖隔离更严格的包管理器与策略(并在团队内统一)。

5)为什么组件库常把 React 放到 peerDependencies?

  • 避免应用中出现多份 React;
  • 由应用控制 React 版本,同时组件库声明兼容范围。

6)Monorepo 常见的版本策略有哪些?怎么选?

  • 固定版本:简单、强一致;适合高耦合仓库。
  • 独立版本:精细、灵活;适合可独立发布的包多的仓库。
  • 选择依据:包之间的耦合度、发布频率、团队发布流程成熟度。

7)CI 如何做到“只跑受影响的包”?

  • 计算改动影响范围(受影响包 + 依赖链);
  • 只对这些包执行 lint/test/build;
  • 配合任务编排工具(如 Turborepo/Nx)做缓存与增量。

8)Monorepo 如何控制包边界,避免互相乱引用?

  • 目录分层(core/shared/feature/apps);
  • 通过 lint 规则、导出入口(exports)、依赖图检查来限制跨层依赖;
  • 对关键包设置“只能从 public API 引用”,避免深层路径 import。

9)Monorepo 会不会让 Git 仓库太大、太慢?怎么优化?

  • 会有这个风险,优化手段包括:
    • CI 缓存(依赖缓存、构建缓存);
    • affected + 过滤执行;
    • 规范化提交与拆分(避免把不相关的大文件塞进仓库)。

10)一句话总结你会如何落地 Monorepo?

  • 先落地 workspace + 统一脚本;
  • 再引入任务编排与缓存(提高 CI/本地效率);
  • 最后完善边界约束与发版策略(保证长期可维护)。

11)Monorepo 和“多包但不共享工具链”有什么区别?

  • 仅仅“多包同仓”并不等于工程化 Monorepo;
  • 真正的 Monorepo 更强调:统一依赖管理、统一脚本、可观测的依赖图、可增量的 CI,以及清晰的包边界。

12)Monorepo 里为什么更强调“对外入口(public API)”?

  • 因为仓库内包很多,一旦出现大量深层路径引用,重构成本会指数级上升;
  • 通过 exports、集中入口文件、lint 规则,能把“可维护性”变成默认值。

13)Turborepo 和 Nx 的核心价值是什么?

  • 核心价值不是“能跑脚本”,而是:按依赖图编排 + 缓存 + 增量(affected)
  • 让大仓在本地与 CI 里依旧保持可接受的速度。

14)Monorepo 里如何处理跨包的破坏性变更(breaking change)?

  • 先在共享包里提供兼容层(deprecate 老 API);
  • 分批推动应用升级;
  • 最后再删除旧能力并发一个 major 版本(配合 changesets 更稳)。

15)什么时候你会坚定反对 Monorepo?

  • 项目之间几乎没有共享、没有协同价值;
  • 团队没有维护工程规则的意愿与资源;
  • 权限隔离是第一优先级(多团队强隔离、供应商协作等)。