关于我
Monorepo 工程范式导论:现代 Monorepo 的模样
从问题到能力

从问题到能力

现代 Monorepo 框架的能力清单长得吓人:构建缓存、受影响分析、依赖隔离、发布编排……这些能力究竟是从哪来的?每一项又在解决什么问题?这篇文章不罗列功能、不横向对比工具,而是从最原始的发包成本讲起,一步步推演出一个现代 Monorepo 到底该具备哪些能力。

关于 Monorepo,网上一直吵得很凶。支持的人把它当成大型项目的最佳组织方式,几十上百个包放一个仓库,依赖清晰、改动顺滑;反对的人则认为它会严重拖垮开发节奏——代码全堆在一起,仓库越长越大,装一次依赖、跑一次 CI 都慢得让人抓狂,规模越大,越可能被自己拖垮。

有意思的是,两边说的其实都对:Monorepo 确实带来了组织上的好处,也确实会随着仓库变大而拖慢开发节奏。而为了不被规模拖垮,现代 Monorepo 框架逐步衍生出了一长串能力——构建缓存受影响分析、依赖一致性、发布编排……正是靠这些能力,规模与性能之间的矛盾才被一定程度上缓解,大仓库才勉强跑得动。

那不妨顺着这条线往下问:这些能力究竟是从哪来的?每一项又是在解决什么问题?这篇文章不打算逐条罗列功能,也不拉工具横向对比,而是想还原一条推演链——从"把多个仓库合并成一个"这个最初的决定出发,看一连串问题怎么接踵而来:解决掉一个,又冒出下一个,每冒出一个,就逼出一项新能力。

1. 起点:多包分治,靠"发版本号"通信

设想一个正在长大的产品。它被拆成了好几个包:一个 UI 组件库、一个工具函数库、两三个业务应用。每个包一个独立仓库,各自在 npm 上独立发版。应用依赖组件库,靠的是 package.json 里那行 "@acme/ui": "^1.2.0"——一个版本号。

这套结构在包不多、改动不频繁时挺好。问题出在联调。假设你要给组件库加一个 prop,应用侧要同步用上。完整链路是这样的:改组件库源码 → 构建 → 发一个新版本到 npm → 回到应用仓库,把依赖 bump 到新版本 → 重新安装 → 才能验证效果。发现改错了?这一整套再来一遍。

这里的慢,不是网络慢或机器慢。慢在通信协议本身。版本号是一种跨仓库的通信协议:它把"组件库变了"这件事,编码成了一个必须人工发布、人工升级的离散事件。你没法让应用"实时看到"组件库的改动,因为两者之间强制隔着一次发布。改一处、验一处,每次都要绕这一整道发布流程。

版本号通信的发布环路

顺着这个问题往下想:能不能去掉这道强制的发布环节,让应用直接引用组件库的源码?

2. 第一反应:把代码放进一个仓库

最直接的办法,就是把这些包挪进同一个仓库。应用不再依赖 npm 上某个版本的组件库,而是直接引用仓库里那份组件库的源码。组件库一改,应用立刻看到,中间那次发布被省掉了——准确地说,是被推迟到了整体对外发布的时候

多仓合并成一个 Monorepo

到这里,可以给 Monorepo 下一个不那么表面的定义了。它的本质不是"一个仓库放多个项目"——那只是现象。它真正做的事情是:把模块之间的边界,从仓库层下沉到了依赖图层。原来两个包的边界是"两个 git 仓库",跨越边界靠版本号握手;现在边界变成了依赖图里的一条边,跨越它靠的是源码级的直接引用。用一句话概括,就是用源码级直接依赖,取代了版本号这个跨仓通信协议

合仓当然有代价:仓库变大、历史变长、权限和构建都得重新安排。不过,光把代码放到一起,还不算 Monorepo

3. 合仓之后:为什么还需要一个专门的框架

很多人到上一步就停了:用 npm 或 yarn 的 workspace 功能,在根目录声明一下,几个包就"在一起"了,不就完事了吗?

真正上手你会发现不对劲。以 yarn/npm 早期的 workspace 为例,它的做法是把所有包的依赖收集起来,统一提升(hoist)进根目录的一个 node_modules,让所有包共享。这一提升,埋下了两处隐患。

第一处叫幽灵依赖。假设你的应用代码里写了 import _ from 'lodash',但你从没在应用自己的 package.json 里声明过 lodash。按理说这应该报错,可它偏偏能跑——因为仓库里别的包依赖了 lodash,它被提升到了根 node_modules,于是所有包都能"蹭"到它。平时相安无事,可一旦这个应用要单独发布、或者被移动到别处,lodash 不在了,它当场崩溃。你依赖了一个你从没声明过的东西,这就是幽灵依赖——它像幽灵一样,你看不见,却真实地撑着你的代码。

第二处叫 NPM 分身。当仓库里不同的包依赖了同一个库的不兼容版本(比如一个要 library-f@1.x,一个要 library-f@2.x),提升机制没法把它们合成一份,只能在 node_modules 里塞进多份物理拷贝。同一个库,磁盘上躺着好几个副本,运行时可能同时加载多份——体积、行为一致性,都出问题。

这两处隐患指向同一个根子:依赖被提升到了全局,"谁能用谁"这条边界糊掉了。回到上一节的定义——Monorepo 把边界下沉到了依赖图层,可朴素的 hoist 做法,恰恰在物理布局上把这层边界又抹平了。

幽灵依赖与 NPM 分身

这就指向了 Monorepo 第一项必备能力:严格的依赖布局与隔离。它的目标只有一句话——每个包只能访问自己显式声明过的依赖,没声明的一律不可见。做到这一点,幽灵依赖就没了土壤:你用什么,就必须先声明什么。pnpm 用一套基于硬链接的布局来实现它,把真实的包存进一个全局内容寻址的仓库,再按每个包声明过的依赖精确地链接过去;Rush 这类框架则更彻底,连根 node_modules 都不维护,依赖收敛后不暴露在全局,靠符号链接为每个子项目重建一份"只含声明依赖"的视图。

这项能力有它的代价:每个包都必须完整声明自己用到的依赖,不能再隐式复用根 node_modules 里别人装好的包,比如 @types/nodetslib。换来的是依赖边界的确定性——这也是 Monorepo 里反复出现的一种权衡:用显式声明的成本,换取可预测的依赖关系。

4. 仓库继续变大:效率问题浮上来了

前面几节解决的是正确性问题——边界糊了、依赖乱了。但仓库继续变大,还有一类和正确性无关的问题会浮上来:工具效率。这跟依赖布局做得好不好没关系,纯粹是规模导致的——原来三五个包,现在是几十个、上百个,包越多,每一次操作要处理的东西越多,整套工具链会以肉眼可见的速度变慢。

问题集中在一个此前一直够用的朴素做法上——每次改动,都把仓库里所有包从头处理一遍,也就是全量安装、全量构建、全量测试。

改了一行组件库的代码,CI 却要把全仓库上百个包重新安装、重新构建、重新测一遍。构建时间随仓库规模线性上涨,一次 CI 跑半小时起步。仓库越大,每个人每次提交要等的时间就越长。

围绕效率,有两件事可做:第一,别做无关的活;第二,别重复做已经做过的活。前者是受影响分析,后者是任务编排与缓存。

4.1 受影响分析:只处理改动波及的包

全量的反面,是"只处理这次改动真正波及的那部分"。可"哪些包被波及了",得先算得出来。

好在,前面下沉到依赖图层的边界,现在派上了大用场——既然包与包的依赖关系是一张明确的图,那么"改了包 B,哪些包会受影响"就是一道可以计算的题:从 B 出发,沿着依赖图反向遍历,凡是直接或间接依赖 B 的包,都在受影响范围内;其余的,这次可以完全跳过。

这就是受影响分析(affected)。有了它,CI 不再全量:只安装、只构建、只测试受这次改动波及的那些包。在一个上百包的仓库里,改一个叶子包,可能只波及三五个包,构建时间从"和仓库大小成正比"变成"只和这次改动的影响面成正比"。这是大仓库能把 CI 时间压回可接受范围的关键一招。这一层的门槛并不高,连 pnpm workspace 都内置了 --filter "...[origin/main]" 这类筛选,能选出变更包及其下游。

举个具体的例子。假设仓库里有这么几个包,依赖关系如下:@acme/app-web@acme/app-admin 都依赖组件库 @acme/ui,@acme/ui 又依赖工具库 @acme/utils;另外还有一个 @acme/theme-plugin,它没写进任何 package.json,而是在运行时被 @acme/app-web 反射加载。现在改了 @acme/utils,从它出发沿依赖图反向遍历,会圈出 @acme/ui@acme/app-web@acme/app-admin——它们直接或间接依赖 @acme/utils,确实都得重新处理:

一个具体例子:改了 @acme/utils,affected 圈出了谁

这一圈算得又快又准。但注意图里那个留在框外的 @acme/theme-plugin——它恰好暴露了受影响分析的第一个软肋。

不过,受影响分析算出来的这一圈,和"这次真正该处理的包"并不严格重合,而是在两头各错开一块:一头看不全、可能漏判,另一头圈得偏多、会有白干。

  1. 一头是它看不全,可能漏判。 就像图里的 @acme/theme-plugin:它和 @acme/app-web 的关系是运行时反射加载,这条边从没写进 package.json,依赖图上根本不存在。于是哪怕你改了 @acme/theme-plugin,affected 反向遍历也找不到任何依赖它的包,会判定"没人受影响、可以跳过"——可实际上 @acme/app-web 的行为已经变了。除了反射加载,读一个约定路径的配置文件、依赖某段代码生成的产物,都是这种图外的隐式依赖。所以受影响分析是一种基于依赖图的启发式剪枝,不是"绝对没有漏网"的正确性证明:它让你在绝大多数情况下安全地跳过无关的包,而不是向你保证"跳过的一定无关"。
  2. 另一头是它圈得偏多,会有白干。 还是上面那组包:假设这次改的不是 @acme/utils,而是 @acme/ui——但只动了 @acme/ui 里的一个图表组件,而 @acme/app-admin 只用到了 @acme/ui 的按钮,压根没碰过那个图表。对 @acme/app-admin 来说,它的输入其实一点没变,重新构建的产物会和上一次一模一样。可 affected 照样会把它圈进来,因为依赖图的粒度只到"包与包"——它只看到"app-admin 依赖 ui、ui 变了",看不清"ui 变的那部分 app-admin 到底用没用到"。

于是在缩小后的这批包里,仍有一部分是在做无用功——输入没变,却还要走一遍完整的构建。这是比"处理无关包"更细一层的浪费,能不能连它也省掉?

4.2 任务编排与缓存:连重复构建也省掉

要省掉重复构建,得先换一个视角看待"构建"这件事。

到目前为止,我们脑子里的那张图,是包与包的依赖图——它回答"谁依赖谁"。但构建、测试、打包这些动作之间,其实有另一张图:构建包 A 之前得先构建它依赖的包 B,跑测试之前得先完成构建……这是一张任务之间的依赖图,它的节点不是"包",而是"某个包的某个任务",边是任务间的先后约束。

这两张图必须分清,别当成一张。包依赖图告诉你模块边界,任务图告诉你执行顺序——它们形状相关,但不是同一个东西。现代 Monorepo 工具的调度核心,正是这张任务图:把所有要做的任务按拓扑序排开,能并行的并行,该串行的串行。

包依赖图与任务依赖图的区别

有了任务图,缓存才知道该缓存什么。做法是内容寻址缓存:对每个任务,把它的全部输入(源文件、依赖、配置、环境)算一个哈希,拿这个哈希当 key 去缓存里查。命中了,直接取出上次的产物,任务根本不用真跑;没命中,才真正执行,执行完把产物按哈希存回去。缓存还能跨机器共享——你本地构建过的产物,同事和 CI 可以直接复用,团队级别地省掉重复劳动。

这里的代价,藏在"全部输入"四个字里。缓存的正确性,完全取决于哈希有没有覆盖到任务真正依赖的所有输入。漏算一个输入,就会命中错误的缓存:比如某个任务的行为依赖一个环境变量,而哈希没把它算进去,那么环境变了、缓存 key 却没变,你会拿到一份用旧环境构建的、错误的产物,而且神不知鬼不觉。缓存命中给你的是"快照一致",不等于"工程正确"——这两者之间的缝隙,得靠把输入定义完整来填,而这件事出乎意料地难做对。

pnpm workspace 到这一层就不够用了:它只管安装和链接,不提供任务图,也不提供缓存。要有任务编排内容寻址缓存,得靠更高阶的 Monorepo 框架,比如 TurborepoNxRush

5. 发布编排:合仓没有消灭发布,只是把它挪到了对外那一刻

合仓省掉的是内部依赖间的版本号,却没省掉对外发布:仓库内部的包,靠 workspace:* 这类 workspace 协议直接指向本地那份源码,依赖解析不再经过 npm,自然也就不需要版本号;可一旦要把某个库发布到 npm 或私有仓库给外部用,版本号、changelog、发布顺序,一样都不能少。

而且合仓之后,发布反而更复杂了。原来一个包一个仓库,发布是各管各的;现在几十个包在一个仓库里,一次改动可能同时动了好几个包,它们的版本要怎么联动?依赖链上的包,谁先发谁后发?这些都得有人管。

沿用前面那条依赖链看具体点:@acme/utils@acme/ui@acme/app-web。如果这次改的是最底层的 @acme/utils,它自己要发新版没有疑问,可 @acme/ui 呢?它依赖的 @acme/utils 换了版本,即便 @acme/ui 自己一行代码没动,是不是也得跟着发一版、把依赖指向新的 @acme/utils?顺着往上,@acme/app-web 要不要一起联动?这三个包该作为一组一起发,还是各自独立?反过来,如果改的是中间层的 @acme/ui,它的下游 @acme/app-web 通常得跟发,可它的上游 @acme/utils 根本没受影响,就不该被带上——同样一次改动,往上带谁、往下带谁,取决于改在了链条的哪一环。再叠上版本号:@acme/utils 升的是 major 还是 patch,直接决定了下游是被迫跟一个破坏性升级、还是只做一次无痛的版本对齐。每一个问题都是一个要决策的点,而这些点,一次改动里可能同时出现好几个。

对外发布这件事,于是需要一套专门的发布编排。典型的形态是三段式:先收集这一轮有哪些变更(以及每个变更该升哪一级版本),再据此生成 changelog、把相关包的版本号 bump 好,最后按依赖的拓扑顺序依次发布。社区的 Changesets 是 add → version → publish,Rush 是 change → version → publish,思路是同构的。

发布编排三段式流水线

这里有一点容易被误解:依赖图本身,算不出一份完整的发布计划。发布顺序看似只是依赖图的拓扑排序,但拓扑序通常不唯一;更关键的是,上面那些决策点——哪几个包一起发、版本号怎么联动、要不要带上没直接改动的下游——依赖图里根本没有答案,它只约束了"谁必须在谁之前发",没规定"这一轮到底发哪些、各升到什么版本"。这部分只能由团队定下的发布策略来补。以我的经验,这里没有一劳永逸的策略:策略越自动(比如下游一律跟发、版本一律对齐),越省心,但容易发出一堆没必要的版本;策略越由人把关,发出来的版本越干净,但每次都得有人盯着做判断——省心和干净,基本只能取一头。

合仓把内部联调从高频的发布-升级动作里解放了出来,但发布的复杂度并没有消失,只是收拢到了对外发布这一个低频的点上,交给一套统一的编排去管。复杂度不会凭空消失,只会换一种形态待在别处——这是 Monorepo 里更根本的一重权衡。

6. 给人重新标出边界

代码全放到一起之后,机器这一侧很顺:依赖图清晰,任务可调度,缓存能复用。人这一侧却多了个新问题——几百个包放在一个仓库里,我负责的这块,边界到底在哪?我能依赖谁,不该依赖谁?

这是边界下沉带来的副作用。边界还在仓库层时,不同仓库之间天然就是一道物理隔离:两个包分属不同仓库,想用另一个包只能走发布、安装这条正门,想随手越界引用都没有物理上的路径。合仓把这道物理隔离拆掉了——所有包的源码躺在同一棵目录树里,彼此之间随手就能 import。这一拆有它实打实的好处:复用变得极其顺滑,内部包之间不再隔着发布流程,这正是我们一开始合仓要的东西。但硬币的另一面是,约束依赖关系的那道物理墙也一并没了,依赖很容易长成一团谁也理不清的乱麻。

物理隔离消失,补上逻辑隔离

物理隔离消失了,就得在它原来的位置补上一层逻辑隔离——用规则和工具,把原本靠仓库边界自动拦住的越界行为重新拦回来。这里有个分工要先说清:规则本身(允许谁依赖谁、哪块归谁)是人定的,工具只负责把人定的规则在 CI 里自动执行、违反就拦下——工具不会凭空知道"UI 层不该反向依赖业务层",这条得你写出来。落到具体形态,常见的有三类:

  • 把依赖关系画出来,让人直接看到"谁连着谁"。Nxnx graph 会在浏览器里打开一张可交互的项目依赖图,Turborepoturbo run <task> --graph 能导出任务依赖图;pnpm 没有图形化视图,但 pnpm list -r 能输出文本依赖树,自己消费它的 JSON 也能画。
  • 把"谁能依赖谁"写成可检查的规则Nx 的做法是给每个项目打 tags,再用 ESLint 规则 @nx/enforce-module-boundaries(旧版 @nrwl 前缀)的 depConstraints 声明"打了某标签的项目只能依赖打了另一标签的项目",违反就在 lint / CI 阶段报错;不依赖 Nx 的仓库也可以用通用的 import/no-restricted-paths(来自 eslint-plugin-import)按目录路径禁止跨区 import。这类约束是 Turborepopnpm 自己不提供的,得靠专门的框架或 ESLint 插件补上。
  • 把归属写进配置,标清每个目录归谁维护。GitHub 的 CODEOWNERS 文件按路径映射到负责人或团队,改到谁的地盘,PR 就自动请求谁来 review,配合分支保护还能设成必须审批。

这三类手段有一个共同的性质:它们都是软约束,补不回原来那道物理隔离的硬度,逻辑隔离是靠工具和团队约定共同维持的:你能让越界变得有代价、会被发现,但没法像仓库隔离那样让它在物理上根本无从发生。维持这层逻辑隔离要持续投入,这同样是合仓的一笔代价。

7. 最新一环:AI 放大了这套结构的价值

把前面所有能力连起来看,会发现它们其实在做同一件事:把仓库里原本隐含的结构,一项一项显式地表达出来。依赖关系显式成了图,执行顺序显式成了任务图,影响范围能被算出来,边界被写成了可检查的规则,归属被写进了配置。整个仓库,从一堆源码,变成了一张处处标注清楚的结构图。

这套显式结构,恰好是 AI 最需要的输入。让 AI 在一个大仓库里改代码,它最大的障碍是搞不清全局——这次改动会波及哪里、哪些依赖不能碰、改完该验证什么。而 Monorepo 沉淀下来的能力正好逐条对上:受影响分析帮它把关注范围收窄,依赖约束替它划清不能越的线,任务图让验证可以增量地跑而不必全量。原本 AI 得在一大片代码里靠猜,现在它是在一张标注好的图上作业。

AI 靠猜 vs AI 照图作业

更关键的是验证这一环。回到第 1 节那个起点:多仓之下,AI 改了底层包,想验证上层应用受没受影响,得等这个包发版、上层再升级安装,才能真正跑起来——验证被硬生生隔在了发布周期之后。合仓把这道墙拆了:包与包源码直连,AI 改完一处,立刻就能在整个仓库范围内跑构建、跑测试,当场看到改动波及的所有下游是不是还正常。对一个需要反复"改一版、验一版"收敛的 AI 来说,这种即时、整体的验证回路,比什么都重要——它把 AI 试错的代价从"一个发版周期"压到了"一次本地任务"。

把这些收益归拢一下,就能看清 Monorepo 为什么天然适合 AI agent。可以按四个维度来看:

  • 全局可见——AI 读到的是真实实现,而不是一个版本号背后的黑盒:真正的 API、真正的类型定义、真正的共享库源码都在同一棵目录树里,不用猜、也没有过时的 d.ts 来糊弄它。
  • 上下文自然流动——上下文是被 AI 主动发现的,而不是要人预先喂进去的。前面沉淀的依赖图、任务图、边界规则,本身就是一份现成的地图,AI 顺着它就能定位到相关代码。
  • 跨包改动一次完成——一个横跨多个包的改动,AI 可以一次改完、只跑受影响的那批测试、提交成一个原子 PR;不必像多仓那样拆成几个 PR、还要协调发布顺序。
  • 即时反馈——改了后端,前端的测试立刻失败,不必等发版、升级、再重装。这正是前一节说的"验证不跨发版周期"落到 AI 身上的样子。

这四个维度,恰好都是 AI agent 在陌生代码里最缺的东西。AI 不是 Monorepo 的终点,"先有问题、再有能力"这条链还会继续延伸,但至少眼下,它让这套结构的价值又被放大了一截。

8. 一个现代 Monorepo 该有的能力清单

回头看这条链,每一环都不是凭空设计出来的功能,而是对应着前一步留下的一个具体问题。把它们收拢一下,就是一个现代 Monorepo 该有的样子:

  • 严格的依赖布局——针对幽灵依赖NPM 分身,保证"你只能用你声明过的"。
  • 受影响分析——针对大仓库全量操作跑不动,让 CI 只处理这次改动真正波及的部分。
  • 任务编排与缓存——针对重复构建的浪费,把执行建成任务图、按内容寻址复用产物。
  • 发布编排——针对"对外发布没有消失、还更复杂了",收集变更、联动版本、按序发布。
  • 给人的导航与边界——针对"仓库隔离消失、人看不清边界",用可视化和软约束把边界重新标给人。

这五项之上还有一个总前提:规模。搭建和维护它们都要持续投入,只有当仓库和团队大到一定程度,省下的成本才盖得过这份投入。这也是为什么同样叫 Monorepo,小项目用它是负担,大仓库用它才划算。

下一篇就拿这份清单去对照具体工具。从 pnpm workspaceTurborepoNxRush,再到 Google 那套推到极致的密封构建,每个工具覆盖了清单上的哪几项、又在哪一项上做了取舍,我们一项一项对下来。

On this page