React 持续交付流程:在 React Monorepo 应用中实施增量构建与缓存(Remote Caching)

构建的艺术:在 React Monorepo 中征服“增量构建”与“远程缓存”

大家好,欢迎来到今天的讲座。我是你们的技术向导,今天我们要聊的东西可能会让你们感到既兴奋又害怕——构建

想象一下,你正在写代码,你修改了 utils.ts 里的一个函数,你觉得这改动就像是在平静的湖面上扔了一颗石子。你满怀期待地按下了“提交”,然后你就看到了那个让你心碎的画面:

> linting... (1m 30s)
> testing... (2m 45s)
> building... (5m 12s)
> deploying... (30s)

五分钟。 就为了那颗石子。你的咖啡都凉透了,而你的整个应用都在重新编译。

在 Monorepo 的世界里,这简直是家常便饭。你在一个仓库里管理着 10 个、20 个甚至 50 个 React 应用。当你修改了共享组件库时,整个 CI/CD 流水线可能会像多米诺骨牌一样,把所有依赖它的项目都推倒重来。

今天,我们不聊那些虚头巴脑的架构设计模式,我们来聊聊如何用增量构建远程缓存这两把利剑,把这头名为“构建速度”的怪兽驯服成一只温顺的小猫。

准备好了吗?我们要开始“偷懒”了。


第一章:为什么我们的构建这么慢?(且听我吐槽)

在深入解决方案之前,我们必须先理解痛点。为什么我们的构建这么慢?是因为我们代码写得烂吗?还是因为你的电脑是 2005 年的旧电脑?

不,纯粹是因为“全量构建”这个概念在 Monorepo 里就是一场灾难。

1.1 全量构建的“核平”逻辑

在传统的单体仓库(或者早期的 Monorepo)里,构建逻辑通常是这样的:

  1. 清空 node_modules
  2. 重新安装所有依赖。
  3. 遍历所有目录,运行 npm run build
  4. 如果有一个文件报错,停;如果全部成功,部署。

这就像是你去一家餐厅做菜,虽然你只做了一道“宫保鸡丁”,但为了做这道菜,你把整个厨房的锅碗瓢盆都洗了一遍,把冰箱里的所有食材都拿出来看了一遍,最后才切了鸡肉。

在 React Monorepo 中,这更是灾难。假设你有 App-AApp-BApp-A 依赖 Shared-UI。你改了 Shared-UI 里的一个按钮样式。在全量构建下,App-A 的 Webpack/Vite 编译器会重新扫描 Shared-UI 的所有源码,重新打包,重新生成哈希,甚至可能因为缓存策略的问题,重新生成 App-B 的代码(虽然 App-B 并没有变)。

这就是“无谓的重复劳动”。我们在浪费 CPU 时间,浪费磁盘 I/O,浪费电费。

1.2 依赖地狱与并发冲突

除了慢,还有乱。Monorepo 里经常出现版本冲突。App-A 需要 [email protected]App-B 需要 [email protected]。在单体仓库里,这没问题,npm 会自动处理。但在构建过程中,如果构建工具不够智能,它可能会因为这两个版本不同,导致两次独立的构建过程,或者因为缓存失效而反复构建。

我们要解决的核心问题只有两个:

  1. 不要重新构建没变的东西。(增量构建)
  2. 不要在每台 CI 机器上重新构建。(远程缓存)

第二章:增量构建——构建依赖的“地图”

增量构建的核心思想很简单:基于图进行构建

想象一下,你的 Monorepo 不是一个扁平的列表,而是一个复杂的有向无环图(DAG)。节点是项目,边是依赖关系。

Shared-UI -> App-A -> App-B
Shared-UI -> App-C

当你提交代码时,构建系统首先需要知道:“谁依赖了我?” 以及 “谁依赖了依赖我的人?”

2.1 依赖图是如何工作的?

现代的构建工具(如 Turborepo, Nx, Rspack)都会在构建开始前生成一个依赖图。当你修改了 Shared-UIButton.tsx,构建系统会:

  1. 标记变化: Shared-UI 被标记为“Dirty”(脏)。
  2. 向上回溯: 系统查找哪些项目依赖于 Shared-UI。结果:App-AApp-C
  3. 向下传播: 系统检查 App-AApp-C 是否还依赖了其他被修改的库。如果没有,它们也变成“Dirty”。
  4. 构建: 只有 Shared-UIApp-AApp-C 会进入构建队列。App-B(它只依赖 App-A,而 App-A 的代码没变)则被跳过。

这就是增量构建的魔法。它不是盲目地跑所有任务,而是像走迷宫一样,只走你需要走的路。

2.2 代码示例:Turborepo 的配置

让我们看看如何用 Turborepo 来配置这个图。Turborepo 是目前最流行的 Monorepo 构建工具之一,它的名字听起来就很 Turbo(涡轮增压)。

首先,你的 turbo.json 配置文件是核心:

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"], // 关键点:构建前,先构建依赖
      "outputs": [".next/**", ".cache/**", "!.next/cache/**"] // 缓存这些目录
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "dev": {
      "cache": false, // 开发模式通常不需要缓存,因为它是实时的
      "persistent": true
    }
  }
}

看这里,"dependsOn": ["^build"] 这一行是神来之笔。^ 符号告诉 Turborepo:“在构建当前项目之前,先构建所有直接依赖它的项目(如果有 ^,则包括间接依赖)”。

这就是增量构建的骨架。有了它,你的构建速度会从 5 分钟变成 30 秒。但这还不够,因为我们还有 CI/CD。


第三章:远程缓存——把构建结果“上传”到云端

增量构建解决了本地构建慢的问题,但它在 CI/CD 上依然有缺陷。

假设你修改了 Shared-UI。你的同事 Alice 在她的电脑上跑 CI,她本地有缓存,所以她用了增量构建,30 秒搞定。
但是,你的 CI 服务器呢?那是另一台机器,没有本地缓存。它得重新编译 Shared-UI,然后编译 App-A,编译 App-C。结果,你一个人的修改,导致整个流水线跑了 10 分钟。

这就是为什么我们需要远程缓存。

3.1 远程缓存的本质

远程缓存就像是一个“共享的构建冰箱”

  1. 构建时: 当你的 CI 机器完成构建后,构建工具(如 Turborepo)会计算当前构建任务的哈希值(基于源代码、配置文件、环境变量等)。然后,它把生成的构建产物(.next/, dist/, .cache/)打包,上传到一个云存储服务(如 AWS S3, Google Cloud Storage, Vercel Artifacts)。
  2. 下次构建时: 下一个 CI 任务开始。构建工具首先计算“我这次要构建的东西”的哈希值。然后,它去云存储里查:“嘿,有没有人已经生成过这个哈希值对应的构建结果?”

如果有,恭喜你!你直接从云存储下载那个已经打包好的 .zip 文件,解压,然后跳过构建步骤。
如果没有,那就乖乖跑构建流程,然后把自己构建的结果上传到云存储,供后人享用。

3.2 构建产物与哈希算法

为什么需要哈希?因为哈希是唯一的。
哪怕你只是把 Button.tsx 里的 padding10px 改成了 11px,文件的哈希值就会变。构建产物也会变。远程缓存系统会精确地匹配哈希值。

3.3 代码示例:接入远程缓存

Turborepo 接入远程缓存非常简单,因为它可以直接对接各种存储后端。

turbo.json 中,我们需要配置 globalCache(如果你使用 Nx Cloud,那是另一套,但原理一样)。

{
  "pipeline": {
    "build": {
      "outputs": [".next/**", "!.next/cache/**"],
      "cache": true
    }
  },
  "globalEnv": ["NODE_ENV"],
  "remoteCache": {
    "signature": "v1",
    "locations": ["s3://my-turbo-cache"]
  }
}

或者,如果你使用的是 Turborepo 的官方托管服务(或者 Vercel 的集成),你甚至不需要写配置,只需要安装一个包:

npm install -D @turbo/json

然后,在 CI 脚本中,Turborepo 会自动尝试从远程缓存读取。

场景模拟:

  1. 你提交了代码。
  2. CI 触发。Turborepo 运行 build
  3. 它发现 Shared-UI 的代码变了。
  4. 它计算 Shared-UI 的哈希。
  5. 它去 S3 查询。命中!(因为你的同事 Alice 刚刚构建过)。
  6. 它下载 Shared-UI 的构建结果。
  7. 它计算 App-A 的哈希。
  8. 它去 S3 查询。未命中!(因为 App-A 的代码没变,但 Shared-UI 的结果变了,依赖关系变了,所以 App-A 也需要重新构建)。
  9. App-A 开始构建。
  10. App-A 构建完成,上传结果到 S3。

结果: 你一个人的代码修改,只触发了两个项目的构建,而不是所有项目。速度提升是指数级的。


第四章:实战演练——打造你的极速 Monorepo

理论讲完了,我们来点硬核的。我们将使用 Turborepo 搭建一个包含 React、Next.js 和共享库的 Monorepo。

4.1 项目初始化

首先,我们要初始化一个 Turborepo 项目。

npx create-turbo@latest my-awesome-app
cd my-awesome-app

这个命令会帮你生成一个标准结构:

my-awesome-app/
├── apps/
│   ├── web/          # Next.js 应用
│   └── docs/         # 静态站点生成器
├── packages/
│   ├── ui/           # React 共享组件库
│   └── eslint-config/ # ESLint 配置
├── turbo.json
└── package.json

4.2 配置 turbo.json 深度解析

让我们看看生成的 turbo.json,并进行深度优化。

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "globalEnv": ["NODE_ENV", "NEXT_PUBLIC_*"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
      "cache": true
    },
    "lint": {
      "dependsOn": ["^lint"],
      "cache": true
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"],
      "cache": true
    }
  }
}

这里有几个关键点解释:

  • globalEnv: 我们把 NEXT_PUBLIC_* 加进去了。为什么?因为 Next.js 的环境变量会被硬编码到客户端代码中。如果你改了环境变量,构建结果必须改变。如果这里没写,远程缓存可能会在环境变量没变的情况下,错误地复用旧的构建结果。
  • outputs: 我们把 !.next/cache/** 排除掉了。为什么?因为 Next.js 的缓存是针对文件变更的,它很智能,但我们不需要把它的缓存文件上传到远程,那太大了,而且没必要。
  • cache: true: 默认开启。但在 dev 模式下,必须关掉,否则 Turborepo 会试图缓存一个正在运行的进程,这会导致死锁。

4.3 package.json 脚本协同

apps/web/package.json 中:

{
  "scripts": {
    "dev": "turbo run dev --parallel",
    "build": "turbo run build",
    "lint": "turbo run lint",
    "clean": "turbo run clean && rm -rf node_modules"
  }
}

注意那个 --parallel 参数。在开发模式下,我们希望 apps/webpackages/ui 同时启动,互不阻塞。但在 build 模式下,我们希望严格遵守依赖顺序。

4.4 处理共享依赖

在 Monorepo 中,我们通常使用 pnpmnpm workspace 来管理依赖。使用 pnpm 是最佳实践,因为它有硬链接和严格的依赖隔离。

pnpm-workspace.yaml 中:

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

packages/ui/package.json 中:

{
  "name": "ui",
  "version": "0.1.0",
  "main": "./index.tsx",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}

apps/web/package.json 中:

{
  "name": "web",
  "version": "0.1.0",
  "scripts": {
    "dev": "next dev",
    "build": "next build"
  },
  "dependencies": {
    "ui": "workspace:*"
  }
}

"ui": "workspace:*" 这一行告诉 Web 应用去本地的 ui 包里拿代码。这意味着 ui 的代码不需要发布到 npm registry,直接在本地被引用。这使得开发速度极快,因为不需要 npm install


第五章:高级技巧——让缓存更聪明

光有配置还不够,我们需要理解缓存失效的边界。

5.1 依赖哈希与文件哈希

Turborepo 使用的哈希算法结合了依赖哈希文件哈希

  1. 依赖哈希:基于 package.json。如果你在 packages/ui 里把 lodash 换成了 lodash-es,依赖哈希变了,构建系统会认为需要重新构建。
  2. 文件哈希:基于源代码文件的修改时间戳或内容。这比较直观。

5.2 排除 .gitnode_modules

turbo.json 中,默认配置通常会排除 node_modules。因为我们不需要缓存 node_modules(它通常很大,且通过 pnpm/hardlink 共享,不需要重新下载)。

但有时候,我们需要缓存 .nextdist。这就需要我们在 outputs 中明确指定。

5.3 处理 TypeScript 编译

TypeScript 的增量编译(tsconfig.tsbuildinfo)有时会和 Turborepo 冲突。如果你发现修改了类型定义文件,但 TypeScript 没有重新编译,可能是因为 tsconfig.tsbuildinfo 文件本身没有被正确处理。

Turborepo 默认会处理它,但如果你发现缓存失效,可以尝试清理 tsconfig.tsbuildinfo 文件。

5.4 并行与串行的博弈

这是构建系统的哲学问题。
并行:快,但资源消耗大。适合 lint, type-check。
串行:慢,但资源占用低。适合 build, test。

turbo.json 中,默认是并行的。你可以通过配置 outputs 来控制。

"pipeline": {
  "build": {
    "dependsOn": ["^build"],
    "outputs": ["dist/**", ".next/**"],
    "cache": true
  }
}

第六章:Nx 与 Nx Cloud —— 极致的奢华

如果你觉得 Turborepo 已经很快了,那么 Nx 就是法拉利,而 Nx Cloud 就是带副驾的火箭。

Nx 的智能图比 Turborepo 更强。它不仅能告诉你谁依赖谁,还能通过智能缓存远程执行,实现毫秒级的 CI 响应。

6.1 Nx 的“黑盒”魔法

Nx 的核心是一个基于 JSON 的图数据库。它知道每一个文件的依赖关系,比你的 IDE 还清楚。当你修改一个文件时,Nx 可以在几毫秒内计算出受影响的任务。

6.2 Nx Cloud

Nx Cloud 是远程缓存的终极形态。它不仅缓存构建产物,还缓存任务执行

场景:
你的 Monorepo 有 50 个项目。你修改了 shared-utils

  1. 本地 Nx 运行任务。
  2. Nx Cloud 检查这个任务是否在云端有缓存。
  3. 如果有: 直接从云端下载结果。
  4. 如果否: 本地运行任务,并将结果上传到云端。
  5. 关键点:云端会自动把这个结果广播给所有其他的 CI 机器。

这意味着,一旦你的第一个 CI 机器构建了 shared-utils,其他 9 个 CI 机器在跑 shared-utils 相关任务时,都会瞬间命中缓存。

6.3 成本与收益

使用 Nx Cloud 是付费的。但对于中大型团队,它是值得的。它能节省大量的 CI 分钟数,从而节省云服务器成本。


第七章:故障排除与最佳实践

再好的系统也会出问题。当你的远程缓存失效时,该怎么办?

7.1 缓存未命中排查

当你看到 CI 报错:“Cache not found” 时,不要慌。

  1. 检查环境变量: 确保你在本地和 CI 机器上的环境变量一致(如 NODE_ENV, API_KEY)。
  2. 检查依赖版本: 确保本地 package.json 和 CI 上的 package.json 完全一致。
  3. 检查文件权限: 某些云存储桶的权限设置可能导致无法读取缓存。
  4. 手动触发: 在 CI 脚本中添加一个 turbo run build --force 参数,强制重新构建,通常这能解决偶发性的缓存损坏问题。

7.2 缓存大小控制

远程缓存不是无限的。如果构建产物(比如 .next 文件夹)变得非常大,上传和下载会变慢,甚至导致存储超限。

最佳实践:

  • 只缓存编译后的产物(dist, .next, build)。
  • 不要缓存 node_modules
  • 不要缓存 coverage(测试报告)。
  • 定期清理旧的缓存(Turborepo 和 Nx 都有清理命令)。

7.3 避免缓存“副作用”

有些脚本是有副作用的,不应该被缓存。
例如,生成 README.md 的脚本,或者生成随机数的脚本。Turborepo 默认会缓存所有成功的命令,你需要通过 cache: false 来禁用特定脚本的缓存。

"pipeline": {
  "generate-readme": {
    "cache": false
  }
}

第八章:总结——拥抱“懒惰”的工程化

说了这么多,我们到底在追求什么?

我们追求的是确定性速度

在一个 React Monorepo 中,代码变更的频率很高。如果我们每次都要全量构建,那么开发者会在等待构建完成的过程中失去耐心,甚至开始刷手机,导致反馈循环变长。

通过增量构建,我们让构建系统变得聪明,它只做它必须做的事。
通过远程缓存,我们让构建结果变得可共享,让每台机器都变成幸存者。

这不仅仅是技术优化,更是一种工程文化的转变。它鼓励我们编写模块化的、可组合的代码,鼓励我们关注依赖关系,而不是盲目地执行命令。

所以,下次当你按下提交按钮,看着 CI 日志飞速滚动,几秒钟内就完成了构建和部署时,你会感谢自己今天听取了这堂讲座。

记住,真正的工程师是懒惰的。 我们努力工作是为了以后可以更少地工作。我们要用最少的代码,构建最快的应用。

现在,去优化你的 turbo.json 吧!让构建跑起来!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注