第十五章 Monorepo 工程实践:单仓库多项目协作

第十五章 Monorepo 工程实践:单仓库多项目协作

在前面的章节中,我们学习了如何在单个项目中使用 Git 进行版本管理,如何通过 Hooks 和规范化工具保证代码质量,如何使用分支策略进行团队协作。但当你的团队逐渐壮大,项目数量从一个变成五个、十个甚至更多时,你会发现之前的经验开始失效了。本章我们将探讨一个在现代前端工程中越来越重要的话题:如何在一个仓库中管理多个相互关联的项目。

15.1 从困境中寻找答案

想象这样一个场景:你所在的团队负责维护一个电商平台。最初只有一个面向用户的购物网站,代码存放在一个 Git 仓库中。随着业务发展,产品经理提出需要一个独立的管理后台,用于运营人员管理商品和订单。作为技术负责人,你很自然地创建了第二个 Git 仓库来存放后台代码。几个月后,公司决定推出移动端 H5 商城,于是第三个仓库诞生了。

这种按项目划分仓库的方式在软件工程中被称为 Multirepo 或 Polyrepo 架构,是最直观也是最传统的代码组织方式。

到这里一切看起来都很合理。每个项目有独立的代码库,团队成员可以专注在各自负责的项目上。但随着开发的深入,问题逐渐浮现。你发现三个项目中有大量重复的代码:格式化日期的工具函数、验证表单的逻辑、处理 HTTP 请求的封装、统一的错误提示组件。这些代码在三个项目里被复制粘贴了无数遍。

当你在某个项目中发现日期格式化函数有 bug 时,内心涌起一股不安。你知道这个 bug 在其他两个项目中也存在,必须逐个打开仓库,找到对应的文件,进行修改。这个过程不仅繁琐,而且容易遗漏。更令人沮丧的是,三个项目可能处于不同的开发阶段,有的正在进行重要功能开发,有的刚刚发布了新版本。你需要协调不同的发布节奏,确保每个项目都能及时修复这个 bug。

作为一个有追求的工程师,你开始思考解决方案。最直接的想法是把公共代码抽取出来,创建一个独立的工具库。你新建了第四个仓库,命名为 common-utils,把那些重复的函数都迁移进去,配置好 package.json,发布到公司的私有 npm 仓库。现在三个业务项目可以通过 npm install @company/common-utils 来使用这个工具库了。

最初这个方案运行良好,你甚至感到一丝成就感。但新的问题很快出现了。某天你需要在工具库中添加一个新的 API,用于处理商品价格的展示逻辑。这个功能比较复杂,你需要在添加代码的同时,在购物网站中验证它的效果。然而由于工具库和业务项目分属不同的仓库,你的开发流程变成了这样:

首先在工具库仓库中编写新的函数,提交代码,推送到远程。然后执行 npm publish 发布新版本,比如从 1.2.3 升级到 1.2.4。接着切换到购物网站的仓库,修改 package.json 中的版本号,执行 npm install。打开浏览器刷新页面,查看效果。如果发现问题,又要切回工具库仓库修改,重新发布 1.2.5 版本,再回到业务项目更新依赖。这个循环可能要重复十几次才能完成一个功能。

更糟糕的情况是进行 breaking change。假设你决定重构工具库的 API 设计,将某个广泛使用的函数从 formatPrice(price, 'CNY') 改为 formatPrice(price, { currency: 'CNY' })。这个改动影响了三个业务项目中的几十处调用。你需要先在工具库中完成重构并发布 2.0.0 版本,然后逐个打开三个业务项目的仓库,搜索所有调用该函数的地方,逐一修改参数格式,测试确保没有遗漏,分别提交、推送、部署。

整个过程可能需要一整天时间。期间你要在四个仓库之间频繁切换,每次切换都要重新理解上下文,容易出错。更关键的是,这次重构的影响范围被人为地割裂了。代码审查的同事需要分别查看四个 Pull Request 才能理解你的完整改动,无法在一次 Review 中看到全貌。如果某个业务项目忘记更新,那么当它下次升级工具库时,就会遇到 API 不兼容的错误。

在多仓库架构中,代码的 原子性 被破坏了。所谓原子性,是指一次修改要么全部生效,要么全部不生效,不应该存在部分更新的中间状态。当依赖关系被 npm 和版本号机制分割后,我们失去了这种保证。

除了开发体验的问题,多仓库架构还带来了管理成本。每个仓库都需要配置 CI/CD 流程、配置代码检查规则、配置 Issue 和 PR 模板、管理访问权限。当你想推广一个新的最佳实践时,比如引入 TypeScript 或更换打包工具,需要在每个仓库中分别实施。团队新人需要克隆四个仓库,分别安装依赖,理解每个仓库的构建流程。

这些问题的根源在于,虽然我们把代码物理上分散到了不同的仓库,但它们在逻辑上仍然是紧密耦合的。工具库的存在价值就是为业务项目服务,它们的生命周期是同步的。将它们分离管理,实际上是在对抗这种天然的耦合关系,而不是顺应它。

在软件工程领域,当我们发现多个模块之间存在强依赖关系时,通常有两种策略。一种是降低耦合度,让每个模块尽可能独立。另一种是承认耦合的存在,通过更好的组织方式来管理它。对于工具库和业务项目的关系,第一种策略很难实施,因为工具库本身就是为了服务业务而存在的。那么第二种策略就成为了更合理的选择。

这就是 Monorepo 的核心理念:将多个逻辑上相关的项目放在同一个仓库中管理,但保持它们在技术上的独立性。这个概念并不新鲜。Google 的主代码仓库包含超过二十亿行代码,几乎所有产品都在这个仓库中开发。Facebook 的 React、Jest、Babel 等开源项目也都采用了 Monorepo。这些成功案例表明,Monorepo 不仅可行,而且在特定场景下是最优解。

15.2 重新理解代码组织的本质

要真正理解 Monorepo,我们需要回到软件工程的基本原则。代码组织的目标是什么?不是为了让目录结构看起来整齐,也不是为了遵循某种流行的模式,而是为了让开发者更高效地完成工作。一个好的代码组织应该能够减少认知负担、降低出错概率、提高协作效率。

在传统的项目结构中,一个 package.json 文件定义了一个 npm 包的边界。当你执行 npm install 时,包管理器会读取 dependencies 字段,从 npm registry 下载对应的包到 node_modules 目录。这个机制有一个隐含的假设:所有依赖都是外部的、稳定的、已发布的。但在实际开发中,我们经常需要同时修改依赖库和使用它的项目,这种情况下上述假设就不成立了。

Monorepo 通过引入 workspace 概念来打破这个假设。Workspace 允许你在一个仓库中定义多个 package.json,每个都代表一个独立的包,但它们可以通过特殊的方式相互引用。这里的关键创新在于,包管理器不再把所有依赖都视为需要从远程下载的外部包,而是能够识别出哪些依赖实际上存在于同一个仓库中。

让我们通过一个具体的例子来理解这个机制。假设你的 Monorepo 结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
my-project/
├── package.json
├── packages/
│ ├── ui/
│ │ ├── package.json
│ │ └── src/
│ │ └── Button.tsx
│ └── utils/
│ ├── package.json
│ └── src/
│ └── format.ts
└── apps/
└── web/
├── package.json
└── src/
└── App.tsx

在这个结构中,packages/ui 是一个组件库,packages/utils 是一个工具库,apps/web 是使用它们的应用。在传统的多仓库架构中,你需要先把 ui 和 utils 发布到 npm,然后在 web 的 package.json 中这样声明依赖:

1
2
3
4
5
6
7
{
"name": "web",
"dependencies": {
"@my-company/ui": "^1.0.0",
"@my-company/utils": "^1.0.0"
}
}

执行 npm install 后,包管理器会从 npm registry 下载这两个包到 node_modules 目录。如果你想修改 ui 中的 Button 组件,必须在 ui 仓库中修改、提交、发布新版本,然后在 web 仓库中更新版本号、重新安装。

在 Monorepo 中,这个过程被彻底简化了。通过 workspace 机制,包管理器能够识别出 ui 和 utils 实际上就在本地,于是创建符号链接而不是下载。

在 web 的 package.json 中,你可以这样声明依赖:

1
2
3
4
5
6
7
{
"name": "web",
"dependencies": {
"@my-company/ui": "workspace:*",
"@my-company/utils": "workspace:*"
}
}

这里的 workspace:* 是一个特殊的协议标识,它告诉包管理器:“这个依赖不要去 npm 下载,直接链接到本地的 workspace”。当你执行安装命令后,包管理器会在 node_modules 中创建符号链接:

1
2
apps/web/node_modules/@my-company/ui -> ../../../packages/ui
apps/web/node_modules/@my-company/utils -> ../../../packages/utils

这意味着当你在 web 项目中写 import { Button } from '@my-company/ui' 时,实际导入的是本地 packages/ui 目录中的代码。你在 Button.tsx 中的任何修改都会立即反映到 web 应用中,无需发布、无需更新版本号、无需重新安装。

这种机制带来的不仅是便利性的提升,更重要的是思维方式的转变。在多仓库架构中,你会潜意识地把依赖库视为 “外部的、稳定的”,倾向于一次性完成所有改动再发布。而在 Monorepo 中,所有代码都是 “内部的、可变的”,你可以随时在依赖库和业务代码之间切换,用更自然的方式开发。

举个实际的例子。假设你正在 web 应用中开发一个用户资料页面,需要展示用户的注册日期。你打算使用 utils 库中的 formatDate 函数,但发现它不支持相对时间格式(比如 “3 天前”)。在多仓库架构中,你可能会这样做:先在 utils 仓库中添加这个功能,写测试,提交,发布 1.1.0 版本,然后回到 web 仓库更新依赖,继续开发用户资料页面。

而在 Monorepo 中,你的工作流程是这样的:在 IDE 中打开整个 monorepo,同时看到 web 和 utils 的代码。直接在 formatDate 函数中添加相对时间格式的逻辑,保存文件。切换到用户资料页面,引入使用,实时看到效果。如果发现问题,立即回到 formatDate 修改,无需任何额外步骤。所有改动在一次提交中完成,在一个 Pull Request 中审查。

这种开发体验的提升是深刻的。你不再需要在不同仓库之间切换,不再需要记住四个不同的发布流程,不再需要协调多个项目的版本号。认知负担大幅降低,开发效率显著提升。更重要的是,代码的原子性得到了保证。当你提交一次修改时,它要么整体生效(所有相关的包都更新了),要么整体不生效(提交失败或被回滚),不会出现部分包已更新但其他包没更新的尴尬状态。

但 Monorepo 并非银弹。它解决了多仓库的痛点,同时也引入了新的挑战。最直观的问题是仓库体积。当你有十几个项目时,仓库可能包含几万个文件,大小达到几个 GB。克隆这样的仓库需要更多时间,在网络状况不佳时可能需要十几分钟。历史记录也会变得庞大,因为所有项目的提交都混在一起。其次是构建性能。在多仓库架构中,每个项目独立构建,当你修改某个项目时,只需要构建它自己。而在 Monorepo 中,如果不加优化,每次构建可能需要处理所有 packages。假设你有 10 个 packages,每个构建需要 1 分钟,那么完整构建就需要 10 分钟。这在本地开发时还能接受,但在 CI 环境中会严重拖慢交付速度。

权限管理也变得复杂。在多仓库架构中,你可以为不同的仓库设置不同的访问权限。比如核心基础库只允许资深工程师修改,而业务项目对所有团队成员开放。在 Monorepo 中,所有代码都在一个仓库里,很难实现这种细粒度的权限控制。虽然一些 Git 平台提供了 CODEOWNERS 功能,可以为不同目录指定审查者,但这仍然不如独立仓库的权限隔离来得彻底。

此外还有心理层面的挑战。许多开发者习惯了 “一个仓库一个项目” 的模式,第一次接触 Monorepo 时可能会感到不适应。他们会担心:如果所有项目都在一个仓库里,会不会很混乱?我修改一个文件会不会影响到其他项目?提交历史会不会变得难以追踪?这些担心是可以理解的,需要通过良好的工程实践来消除。

正因为存在这些挑战,Monorepo 需要配合专门的工具才能发挥最大价值。这些工具的核心任务是解决三个问题:第一,如何高效地管理 workspace 之间的依赖关系;第二,如何优化构建性能,避免不必要的重复构建;第三,如何处理版本发布,在需要时将 packages 发布到 npm。

15.3 工具演进与技术选型

在 Monorepo 概念刚兴起的年代,开发者主要依靠 npm 的 link 功能来模拟本地依赖。这个功能的原理很简单:在依赖包目录执行 npm link,npm 会在全局目录创建一个符号链接;然后在使用该依赖的项目中执行 npm link package-name,npm 会将全局链接复制到项目的 node_modules。但这种方式有明显的局限性:需要手动执行多次命令,链接关系容易丢失,而且没有工作空间的概念,每个包都是孤立的。

2015 年,Babel 团队在将 Babel 拆分为多个 npm 包时遇到了管理难题,于是开发了 Lerna 这个工具。Lerna 是第一个专门为 JavaScript Monorepo 设计的管理工具,它引入了几个重要概念。首先是 packages 的概念,通过 lerna.json 配置文件来声明仓库中有哪些包。其次是 bootstrap 命令,自动建立 packages 之间的链接关系。再次是 publish 命令,可以检测哪些包发生了改动,自动更新版本号并发布。

当你执行 lerna bootstrap 时,Lerna 会扫描所有 packages,分析它们之间的依赖关系,然后为本地依赖创建符号链接,为外部依赖执行 npm install。这个过程是串行的,在包数量较多时会比较慢。

开发过程中,你可以使用 lerna run 来在所有或特定的 packages 中执行脚本。比如 lerna run test 会在每个 package 中运行测试命令。但 Lerna 不会跳过未改动的包,每次都会执行所有任务。

执行 lerna publish 时,Lerna 会检测自上次发布以来哪些包有新提交,询问你要如何更新版本号,然后自动提交版本改动,打 tag,发布到 npm。这个流程大大简化了多包发布的复杂度。

Lerna 在早期确实解决了很多痛点,但随着项目规模增大,它的性能问题逐渐暴露。串行的依赖安装、缺乏构建缓存、没有增量构建支持,这些都导致在大型 Monorepo 中 Lerna 变得难以使用。此外,Lerna 主要关注依赖管理和发布,对于构建优化、任务调度等高级需求缺乏支持。

2017 年,Yarn 团队推出了 Yarn Workspaces,标志着包管理器开始原生支持 Monorepo。与 Lerna 不同,Yarn Workspaces 将 workspace 机制直接集成到包管理器中,这带来了几个重要优势。首先是依赖提升机制,Yarn 会分析所有 packages 的依赖,将公共依赖提升到根目录的 node_modules,避免重复安装。其次是更快的安装速度,因为 workspace 是 npm 的原生功能,不需要额外的链接步骤。

在 Yarn Workspaces 的 package.json 中,你需要这样配置:

1
2
3
4
5
6
7
{
"private": true,
"workspaces": [
"packages/*",
"apps/*"
]
}

这个配置告诉 Yarn:packages 和 apps 目录下的所有子目录都是 workspace。当你在根目录执行 yarn install 时,Yarn 会自动处理所有 workspace 的依赖,建立必要的链接。这种方式比 Lerna 更加优雅和高效。

但 Yarn Workspaces 仍然没有解决构建性能问题。它只负责依赖管理,不关心你如何构建项目。如果你有 10 个 packages 需要构建,你仍然需要逐个执行 yarn build,或者使用 yarn workspaces foreach 这样的命令来批量执行。没有缓存机制意味着即使代码没有改动,每次也要重新构建。

2018 年到 2020 年间,前端构建工具经历了快速发展。Webpack、Rollup、Parcel 等工具都在优化构建速度,但它们都是针对单个项目设计的,没有考虑 Monorepo 的特殊需求。

这个时期出现了一个重要的认知转变:Monorepo 的核心挑战不是依赖管理,而是如何高效地执行任务。在一个包含数十个 packages 的 Monorepo 中,测试、构建、代码检查等任务如果每次都全量执行,时间成本是无法接受的。我们需要的是一个任务调度器,它能够:

第一,理解 packages 之间的依赖关系,知道构建的正确顺序。如果 package A 依赖 package B,那么必须先构建 B 再构建 A。

第二,实现增量构建。当某个 package 的代码没有改动时,应该跳过它的构建,直接使用上次的结果。

第三,支持并行执行。如果多个 packages 之间没有依赖关系,它们的构建可以同时进行,充分利用多核 CPU。

第四,提供缓存机制。不仅在本地缓存构建结果,还能在 CI 环境中复用其他分支或其他开发者的构建结果。

2019 年,Nrwl 团队推出了 Nx 框架,将这些想法付诸实践。Nx 的核心创新是引入了依赖图和任务缓存。它会扫描整个 Monorepo,分析文件之间的 import 关系,构建一个有向无环图。基于这个图,Nx 知道每个 package 依赖哪些其他 packages,以及哪些文件的改动会影响哪些包。

当你执行 nx build app 时,Nx 首先检查 app 及其所有依赖的代码是否有改动。如果没有改动且缓存中有之前的构建结果,就直接返回缓存,整个过程可能只需要几百毫秒。如果有改动,Nx 会确定哪些依赖需要重新构建,按照正确的顺序执行,并将结果缓存起来。这种增量构建机制使得大型 Monorepo 的开发体验接近于小型项目。

Nx 还引入了远程缓存的概念。通过配置 Nx Cloud,团队成员可以共享构建缓存。当开发者 A 构建了某个 package 后,开发者 B 在构建同样的代码时可以直接使用 A 的缓存结果。在 CI 环境中,这个特性尤其有价值。假设你提交了一个只修改文档的改动,CI 系统检测到代码没有变化,直接使用缓存的构建结果,几秒钟就完成了整个流程。

2021 年,Vercel 团队推出了 Turborepo,将构建优化推向了新的高度。Turborepo 的设计哲学是极致的性能和极简的配置。它的核心引擎用 Rust 编写,执行速度极快。配置文件 turbo.json 非常简洁,大多数项目只需要十几行配置就能运行。

Turborepo 的任务调度算法经过精心优化。它不仅考虑依赖关系,还考虑任务的输入输出。比如构建任务的输入是源代码和配置文件,输出是 dist 目录。Turborepo 会计算输入的哈希值,如果哈希值没变,就认为可以使用缓存。这种基于内容的缓存比基于时间戳的缓存更可靠。

Turborepo 还引入了 pipeline 的概念。你可以声明任务之间的依赖关系,比如 test 任务依赖 build 任务,deploy 任务依赖 test 任务。Turborepo 会自动安排执行顺序,确保依赖任务先完成。这种声明式的配置比命令式的脚本更清晰,也更容易维护。

与此同时,pnpm 作为第三代包管理器也加入了竞争。pnpm 的核心创新是使用内容寻址存储。所有 npm 包都存储在一个全局位置,项目的 node_modules 通过硬链接引用这些文件。这意味着即使你有 10 个项目都使用了 React,磁盘上也只有一份 React 的文件。更重要的是,pnpm 解决了幽灵依赖问题。

所谓幽灵依赖,是指你的代码引用了某个包,但这个包并没有在 package.json 中声明,只是因为它是某个依赖的依赖,被 npm 或 yarn 提升到了 node_modules 根目录。这种情况很危险,因为依赖的版本升级可能导致幽灵依赖消失,你的代码突然就无法运行了。pnpm 通过非扁平化的 node_modules 结构彻底避免了这个问题。每个包只能访问它明确声明的依赖,无法访问其他包的依赖。

pnpm 的 workspace 功能延续了 Yarn 的设计,但性能更好。它的安装速度是 npm 和 yarn 的两到三倍,磁盘占用也大幅减少。对于 Monorepo 场景,pnpm 特别适合,因为 workspace 之间的链接是通过硬链接实现的,比符号链接更高效。


15.4 实战:构建一个真实的 Monorepo 项目

理论学习到此为止,现在我们直接把 Turborepo 官方脚手架搭出来的 ecommerce-monorepo 拆开,做一轮源码级巡礼。你会发现脚手架可不是图省事,而是把工业界磨出来的最佳实践(配置解耦依赖隔离任务编排)都塞进了项目骨架里。

1. 初始化标准项目

在终端执行以下命令,脚手架会一次性生成 Web、Docs 两个 Next.js 应用和三个共享包:

1
pnpm dlx create-turbo@latest ecommerce-monorepo --package-manager pnpm

保持默认回车即可。生成的项目已经内置 TypeScript、ESLint、Prettier、Turbo,并且所有包都挂在 pnpm workspace 下,开箱就能跑。

2. 根层骨架:任务、中枢配置与 Workspace 包含范围

脚手架帮我们把根层“中枢神经”都布好了,分别负责命令编排、工作区边界、任务拓扑:

根脚本:所有动作统一交给 Turbo

1
2
3
4
5
6
7
8
// file: package.json
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types"
}
  • build:一次触发会让 Turbo 识别依赖图,先 build 依赖包再 build 应用。
  • dev:Turbo 在 watch 模式下帮我们并行启动所有 app(web/docs)。
  • lintcheck-types:同样通过 Turbo 逐层向上游广播,保证任何包的质量检查都不会漏掉依赖。
  • format 则直接调用 Prettier,对 Markdown 与 TS/TSX 统一格式化。

Workspace 边界:只允许 apps/ + packages/**

1
2
3
4
# file: pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"

PNPM 只会在这两个模式下搜包,避免把临时脚本、文档示例误识别成 workspace 成员;同时它也确保 workspace:* 能在这两个目录内解析到对应包。

Turbo 任务拓扑:谁先跑、谁产出缓存都写死在 JSON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// file: turbo.json
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**"]
},
"lint": { "dependsOn": ["^lint"] },
"check-types": { "dependsOn": ["^check-types"] },
"dev": { "cache": false, "persistent": true }
}
}
  • dependsOn 中的 ^ 表示“所有直接依赖的包都跑完同名任务后再跑自己”。
  • inputs/outputs 定义了缓存命中策略:源码 + 环境变量作为输入,只要没变就直接复用 .next 目录。
  • dev 被设置成 cache: false,避免开发时被缓存迷惑;persistent: true 让 Turbo 长时间保持进程不退出。

配合根目录 pnpm-lock.yaml,整个仓库的依赖、脚本、缓存策略都在这里对齐,保证所有包共享同一套 Node/PNPM 版本和工具链。

3. 三大设计模式的源码落地

模式一:配置即独立产物

共享 TypeScript/ESLint 配置被直接打包进 packages/typescript-configpackages/eslint-config,可以像普通 npm 包一样 version/发布/被依赖。这样做的直接好处:一处升级严格模式,所有应用同时受益;一处 bug fix,也能统一 rollout。

TypeScript 配置链条

在 packages 内定义好了 ts 的全局基础校验检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// file: packages/typescript-config/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"isolatedModules": true,
"module": "NodeNext",
"moduleDetection": "force",
"noUncheckedIndexedAccess": true,
"strict": true,
"target": "ES2022"
}
}

在此基础上,Next.js 场景做了轻量配方:

1
2
3
4
5
6
7
8
9
10
11
12
// file: packages/typescript-config/nextjs.json
{
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"jsx": "preserve",
"noEmit": true
}
}

消费者侧只剩下伸手党写法,web 网站目录下的 tsconfig.json 只需要引入对应的仓库包即可获取所有的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// file: apps/web/tsconfig.json
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }]
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
"next.config.js",
".next/types/**/*.ts"
]
}

这行 extendsapps/webapps/docs 永远保持一致的类型规则,只需按需补充 include/paths 等局部设置。

ESLint 配置链条
1
2
3
4
// file: apps/web/eslint.config.js
import { nextJsConfig } from "@repo/eslint-config/next-js";

export default nextJsConfig;

应用层啥也不用配:共享包 @repo/eslint-config 已经把 React/Next/Turbo/Prettier 的组合拳搭好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// file: packages/eslint-config/next.js
export const nextJsConfig = [
...baseConfig,
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
pluginReact.configs.flat.recommended,
{
plugins: { "@next/next": pluginNext },
rules: {
...pluginNext.configs.recommended.rules,
...pluginNext.configs["core-web-vitals"].rules
}
},
{
plugins: { "react-hooks": pluginReactHooks },
settings: { react: { version: "detect" } },
rules: { ...pluginReactHooks.configs.recommended.rules }
}
];
  • baseConfig(在同目录 base.js)里已经包含 eslint:recommended + typescript-eslint + turbo plugin
  • globalIgnores 定义默认忽略 .next/out/build 等产物,避免 lint 浪费时间。
  • @next/next + react-hooks 插件把网页性能(core web vitals)与 hooks 规范一次性兜底。

模式二:Workspace 协议把产物“软链接”给应用

应用与共享包之间没有私下 npm publish,全靠 workspace:* 做本地软链接。以 apps/web 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// file: apps/web/package.json(依赖段落)
"dependencies": {
"@repo/ui": "workspace:*",
"next": "^16.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"eslint": "^9.39.1",
"typescript": "5.9.2"
}

workspace:* 是 pnpm 的“软链接协议”:

  1. 安装时不打 npm registry,直接把 packages/ui 链接进 apps/web/node_modules/@repo/ui
  2. 任何源码变动都能毫秒级传递给消费者(Next dev server 立刻热更新)。
  3. * 表示“总是使用当前工作区最新版本”,避免手工 bump 版本号。

这套协议在所有 app 与共享配置包之间全面启用,所以 lint、tsconfig、ui 组件都能做到“改一处,全仓生效”。

模式三:共享 UI 组件库的隔离与扩展性

packages/ui 以 React Library 的规范搭建,自己的 tsconfig、eslint、依赖都封装在包内,对外只通过 exports 暴露组件入口,并提供脚本来生成新组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
// file: packages/ui/package.json
{
"name": "@repo/ui",
"private": true,
"exports": {
"./*": "./src/*.tsx"
},
"scripts": {
"lint": "eslint . --max-warnings 0",
"generate:component": "turbo gen react-component",
"check-types": "tsc --noEmit"
}
}
  • exports 采用通配符 ./*,外部可以按需引入 @repo/ui/button@repo/ui/card
  • generate:component 借助 Turbo 的代码生成器,帮团队统一组件模板。

TypeScript 配置同样继承于共享 react-library 版本,只额外声明产物目录:

1
2
3
4
5
6
7
8
9
// file: packages/ui/tsconfig.json
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

Button 为例,它保持“无状态 + 仅依赖 React”的最小形态;"use client" 则保证该组件在 Next 19 App Router 环境下可以安全用于 Client Component。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// file: packages/ui/src/button.tsx
"use client";

import { ReactNode } from "react";

interface ButtonProps {
children: ReactNode;
className?: string;
appName: string;
}

export const Button = ({ children, className, appName }: ButtonProps) => {
return (
<button
className={className}
onClick={() => alert(`Hello from your ${appName} app!`)}
>
{children}
</button>
);
};

一旦组件库导出完成,应用侧只需要常规 import,完全没有多余胶水代码:

1
2
3
4
5
6
// file: apps/web/app/page.tsx(片段)
import { Button } from "@repo/ui/button";

<Button appName="web" className={styles.secondary}>
Open alert
</Button>

4. Turbo 任务编排的真实路径

有了 turbo.json 的依赖图,任何 turbo run <task> 都会:

  1. 先执行依赖包的同名任务("dependsOn": ["^build"] 表示上游包的 build 先跑)。
  2. 根据 inputs(源码、环境变量)和 outputs(如 .next/**)做缓存命中。
  3. dev 模式下关闭缓存、保持长连接,方便 watch/热更新。

实测流程:

1
2
3
4
5
6
7
8
# 1. 开发模式:一次命令并行启动 apps/web(3000) 与 apps/docs(3001)
pnpm dev

# 2. 首次构建:Turbo 识别 ui -> web/docs 的拓扑,先构建 packages/ui 再构建两个应用
pnpm build

# 3. 第二次构建:如无源码改动,将直接命中 `.turbo` 缓存,终端会出现 `>>> FULL TURBO`
pnpm build

在这个过程中,可以随手在 packages/ui/src/button.tsx 改动一行文案,两个 Next.js 应用会立刻热更新;同样地,若新增一个共享配置或组件包,只要把目录放进 packages/*,workspace 立刻识别。

这才是真正可扩展的现代 Monorepo 起点——脚手架给的是骨架,源码告诉我们每块骨头如何和肌肉(应用)、神经(任务)、血液(依赖)一起工作


15.5 基于 Git 差异的高效协作

在单体仓库(Monolith)中,哪怕只改了一行代码,通常也需要运行全量的 Lint 和测试。但在 Monorepo 中,结合 Turborepo 和 Git 的差异分析能力,我们可以实现“只对修改负责”。

15.5.1 理解 --filter 与 Git 历史

Turborepo 的核心杀手锏是它的过滤(Filter)参数,它能理解 Git 的提交历史。

假设你在 packages/ui 中修改了一个按钮组件,在提交前,你不需要测试整个商城的后台。你可以使用以下命令:

1
2
# 检查当前分支(相对于 main)受影响的包
pnpm turbo run build --filter="...[origin/main]"

这里的语法含义深刻:

  • [origin/main]:让 Turbo 找出当前分支与 origin/main 之间的所有差异文件。
  • ...(三个点):表示“以及它们的依赖者”。

Git 的视角:
如果 Git 告诉你 packages/ui/button.tsx 变了,Turbo 会推导出:

  1. packages/ui 变了(需要构建)。
  2. apps/web 依赖 ui,所以 apps/web 也可能坏了(需要构建)。
  3. apps/admin 没有用到 button跳过构建

这种基于 Git 拓扑的过滤,让 Monorepo 随着项目膨胀而不会变慢,是其能扩展的关键。

15.5.2 利用缓存优化 git checkout

在不同分支间切换是 Git 的日常操作。在传统项目中,切回旧分支通常意味着重新编译。

但在配置了 Turbo 的 Monorepo 中,构建产物被标记了“哈希指纹”(基于源码内容的 Hash)。

  1. 你在 feature-A 分支构建了一次。
  2. 切到 feature-B 工作。
  3. 切回 feature-A
  4. 执行 pnpm build

Turbo 会检测到当前 Git 状态下的源码 Hash 之前已经计算过,直接瞬间恢复 .nextdist 目录。对开发者来说,这意味着 切分支后的构建几乎是瞬时的

15.6 Monorepo 的 Git 工作流规范

架构变了,Git 的提交策略也必须随之改变。不再是一个项目一个 Repo,而是多个项目共存,这时候“原子性提交”和“Scope”变得尤为重要。

15.6.1 提交信息的 Scope 规范

在 Monorepo 中,使用 Conventional Commits(约定式提交)几乎是强制性的。我们需要在 Commit Message 中明确指出当前修改影响的范围(Scope)。

推荐格式: type(scope): description

  • 修改了组件库:
    feat(ui): add secondary variant to Button component
  • 修改了商城前台:
    fix(web): correct pricing display on product page
  • 同时修改(原子提交):
    refactor(auth): update login logic in ui and apps

为什么要这样写?
当未来你想查看 packages/ui 的变更历史时,可以通过 git log --grep="ui):" 快速过滤出相关记录,而不必被业务代码的提交刷屏。

15.6.2 原子性提交

Monorepo 最大的优势之一就是 原子性

场景:你需要修改 formatPrice 函数的签名(Utils 包),同时更新 Web 端和 Admin 端的调用方式。

  • 在 Polyrepo(多仓库)中:你需要先改 Utils 库 -> 发版 -> 改 Web 端 -> 升级依赖 -> 改 Admin 端 -> 升级依赖。中间任何一步失败,项目就会处于“依赖地狱”中。
  • 在 Monorepo 中:你可以在 同一个 Commit 中包含 packages/utils 的修改和 apps/* 的适配代码。
1
2
git add packages/utils apps/web apps/admin
git commit -m "refactor(utils): change formatPrice signature and update consumers"

这个 Commit 保证了仓库在任何时刻都是“可构建、可运行”的,不存在版本不一致的时间窗口。

15.6.3 使用 CODEOWNERS 划分权限

虽然代码都在一个仓库里,但不代表每个人都能随意修改所有代码。我们可以利用 GitHub/GitLab 支持的 CODEOWNERS 文件来定义 Git 层面的“领地”。

在根目录创建 .github/CODEOWNERS

1
2
3
4
5
6
7
8
9
10
11
# 核心架构组负责基础设施
/package.json @arch-team
/pnpm-lock.yaml @arch-team
/turbo.json @arch-team

# 组件库由前端基础组负责
/packages/ui/ @ui-team

# 业务线各自负责
/apps/web/ @shop-team
/apps/admin/ @internal-tool-team

这样,当有人发起 PR 修改了 packages/ui 下的文件时,Git 平台会自动强制要求 @ui-team 的成员进行 Review,确保了 Monorepo 的代码质量和边界感。

15.7 版本管理:Changesets 与 Git 标签

在 Monorepo 中,我们通常不再手动修改 package.json 的 version 字段,因为依赖关系太复杂了。社区标准方案是使用 Changesets

15.7.1 变更意图的提交

Changesets 引入了一个独特的概念:将“变更意图”也作为一个文件提交到 Git 中

当你完成开发准备提交时:

  1. 运行 pnpm changeset
  2. 交互式选择你修改了哪些包(如 @ecommerce/ui)。
  3. 选择变更类型(Patch/Minor/Major)。
  4. 输入变更描述。

这会在 .changeset 目录下生成一个随机命名的 Markdown 文件(例如 hollow-cats-dance.md),你需要将这个文件 一同加入 Git 提交

1
2
git add .changeset
git commit -m "docs: add changeset for ui update"

这样做的好处是:变更日志(Changelog)随着代码一起并在 Pull Request 中,Reviewer 可以同时审查代码和变更说明。

15.7.2 自动发版与 Git Tags

当代码合并到主干后,发版流程会消耗掉这些 Markdown 文件:

  1. 运行 pnpm changeset version
  2. 工具会自动读取 .changeset 目录下的文件,计算出新的版本号。
  3. 自动修改 packages/ui/package.json 的版本。
  4. 自动更新 apps/web/package.json 中的依赖版本(如果是 workspace 协议则可能不更新,视配置而定)。
  5. 自动生成 packages/ui/CHANGELOG.md

最后,你需要提交这些更改并打上 Git Tag:

1
2
3
4
git add .
git commit -m "chore(release): version packages"
git tag v1.0.0
git push --follow-tags

这一套流程将 Git 提交历史、版本号变更和变更日志完美地串联在了一起。


15.8 总结:Monorepo 开发自查清单

最后,让我们用一个清单来总结在 Monorepo 模式下的日常开发习惯:

  1. 新建功能时
    • [ ] 思考是放在 apps(业务逻辑)还是 packages(复用逻辑)。
    • [ ] 优先复用:先看 packages/uiutils 里有没有现成的。
  2. 提交代码时
    • [ ] 验证影响范围:pnpm build --filter=...[HEAD]
    • [ ] 规范 Commit:确保 Scope 清晰(如 feat(ui): ...)。
    • [ ] 包含变更集:如果是库的修改,记得运行 pnpm changeset
  3. 协作 Review 时
    • [ ] 检查是否破坏了原子性(只改了库没改应用)。
    • [ ] 关注 pnpm-lock.yaml 的变动,防止意外引入幽灵依赖。

掌握了这些,你就跨越了单纯的“会用 Git”,进入了“利用 Git 驾驭大型复杂项目”的高级阶段。接下来,我们将进入自动化与持续集成的领域,看看机器如何帮我们完成这些繁琐的工作。