Vite+ 第四章:大规模项目下的 Monorepo、任务、缓存与 CI
译注:本文翻译自 dev.to 上关于 Vite+ 系列教程的第四章,原文作者为 Othmane Nemli,原文链接见文末。Vite+ 是围绕 Vite 构建的一体化工具链,本文聚焦其在大型 Monorepo 场景下的任务调度与缓存能力。
在上一章中,我们使用 Vite+ 处理了一个普通应用:
vp dev
vp check
vp test
vp build这套工作流已经足够实用。但当项目规模显著变大时,Vite+ 会变得更有意思。
想象一下:一个应用变成五个,随后你加入了共享 UI 组件、共享工具函数、设计系统,以及内部库。最终仓库结构可能长这样:
company-project/
├── apps/
│ ├── web/
│ ├── admin/
│ └── docs/
│
├── packages/
│ ├── ui/
│ ├── utils/
│ ├── config/
│ └── api/
│
└── package.json这就是 Monorepo。也正是在这种场景下,每次都运行全部任务会变得非常昂贵。Vite+ 内置了 Vite Task,专门用于处理 Monorepo 中依赖感知的任务执行与缓存。
1. 什么是 Monorepo?
在讨论 Vite+ 之前,先理解问题本身。Monorepo 就是一个包含多个项目或包的仓库。例如:
my-company/
│
├── apps/
│ ├── web/
│ └── mobile/
│
└── packages/
├── ui/
├── utils/
└── types/这里可能有:web(主站)、mobile(移动应用)、ui(共享 UI 组件)、utils(共享工具)、types(共享 TypeScript 类型)。
好处是一切都放在一起,开发者修改共享包后可以立即测试使用它的应用。但这也带来一个新问题:到底哪些任务需要运行?
2. 依赖问题
假设存在这样的依赖关系:
web
│
└── ui
│
└── utils即 web 依赖 ui,ui 依赖 utils。如果你修改了 packages/utils/,是否要重建所有东西?也许需要。但如果你只改了 apps/docs/,还需要重建 web 吗?大概率不需要。
一个大型 Monorepo 可能包含数百个任务,每次小改动后全部运行会浪费时间。这正是任务系统存在的意义。
3. 什么是任务?
任务就是项目需要执行的事情,例如 build、test、lint、typecheck。一个包可能在 package.json 中这样定义:
{
"scripts": {
"build": "vite build",
"test": "vitest",
"lint": "oxlint"
}
}在小应用里手动运行这些任务并不困难。但在 Monorepo 中,你可能有 20 个包 × 4 个任务 = 80 个潜在任务,任务运行器的负担就大得多。
4. 认识 vp run
Vite+ 提供了 vp run 命令,它可以在理解包之间依赖关系的前提下运行包脚本和 Monorepo 任务,同时支持任务缓存。例如:
vp run build可以理解为:“运行与当前项目相关的构建任务”,而不需要手动逐个进入每个包。
5. 任务依赖
考虑如下依赖链:
packages/utils
↓
packages/ui
↓
apps/web如果 web 需要 ui,ui 需要 utils,那么构建顺序就很重要。你不能在 ui 之前构建 web,也不能在 utils 之前构建 ui。依赖图如下:
utils
│
▼
ui
│
▼
web任务运行器可以利用这张图判断哪些任务需要先执行。
6. 为什么依赖感知执行很重要
假设仓库包含 web、admin、docs 三个应用,以及 ui、utils、api、config 四个包,依赖关系为:
web → ui → utils
admin → ui → utils
docs → ui如果 utils 发生变化,web 和 admin 可能受影响,但 docs 可能并不直接依赖 utils。依赖感知的任务运行器可以利用这些关系,而不是盲目地运行所有任务。概念上:
utils changed
│
├── ui
│ ├── web
│ └── admin
│
└── unrelated packages
↓
skip这就是核心思路。
7. 缓存
假设你运行 vp run build 耗时 45 秒,然后再次运行完全相同的命令。如果相关输入没有变化,从头重建就没有必要,这正是缓存发挥作用的地方。
概念上,第一次运行:
Source code
↓
Build
↓
45 seconds
↓
Save result第二次运行:
Same inputs
↓
Cache lookup
↓
Reuse previous result结果可能远快于 45 秒。Vite+ 将 vp run 描述为为 Monorepo 任务提供缓存与依赖感知调度。
8. 缓存到底意味着什么?
一个常见误解是:“缓存意味着 Vite+ 永远不会再运行该命令。”这并不准确。关键问题在于任务的相关输入是否发生变化。可以这样理解:
Input
↓
Task
↓
Output例如输入包括 src/、package.json、tsconfig.json、环境等,输出是 dist/。如果输入未变,之前的结果可能可以复用;如果重要输入(如 src/Button.tsx)发生变化,任务可能需要重新运行。
Same inputs
↓
Cache hit
↓
Reuse result对比:
Changed inputs
↓
Cache miss
↓
Run task again这就是任务缓存的基本思路。
9. 缓存命中与缓存未命中
你常会听到两个术语:
缓存命中(cache hit):可以复用之前的结果。
web#build — cache hit缓存未命中(cache miss):任务需要重新运行。
web#build — cache miss例如首次运行:
utils#build → cache miss
ui#build → cache miss
web#build → cache miss所有任务都要运行。随后不做任何修改再次运行:
utils#build → cache hit
ui#build → cache hit
web#build → cache hit具体行为取决于任务输入和配置,但这是开发者应具备的心智模型。
10. 为什么这在 CI 中很重要
从本地转到 CI。典型流水线大致是:开发者推送代码 → GitHub → CI 启动 → 安装依赖 → 检查 → 测试 → 构建。
没有缓存时,每次 CI 运行都可能重复昂贵的工作。假设安装 30 秒、Lint 20 秒、测试 60 秒、构建 90 秒,这已经是好几分钟。如果仓库有几十个包,浪费的时间会迅速累积。
11. Vite+ 与 GitHub Actions
Vite+ 提供了官方 GitHub Action,名为 setup-vp,用于在 GitHub Actions 中安装 Vite+。当前仓库文档建议将 action 固定到确切的 release 或 commit,而不是使用旧的浮动 v1 标签。
一个简化示例如下:
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: voidzero-dev/setup-vp@<setup-vp-version>
with:
node-version: '22'
cache: true
- run: vp install
- run: vp check
- run: vp test
- run: vp build重点不在具体的 YAML,而在工作流:
GitHub Actions
↓
setup-vp
↓
vp install
↓
vp check
↓
vp test
↓
vp build你在本地使用的同一套 vp 命令可以用于 CI,这种一致性很有价值。
12. 本地开发与 CI 使用同一套接口
我特别喜欢的一点是,开发者不必为 CI 学习一套完全不同的系统。
本地:
vp check
vp test
vp buildCI:
- run: vp check
- run: vp test
- run: vp build命令完全相同。这意味着当 CI 失败时,你通常可以在本地复现同样的命令,例如 vp test,而不必去理解某个内部调用了多个其他工具的 some-custom-ci-script.sh。
13. 一个 Monorepo 示例
设想一个真实项目:
acme/
├── apps/
│ ├── storefront/
│ └── dashboard/
│
├── packages/
│ ├── ui/
│ ├── auth/
│ ├── api-client/
│ └── utils/
│
├── package.json
└── vite.config.ts依赖关系可能如下:
┌── ui ────────┐
│ │
storefront ──────┤ │
│ ▼
└── api-client
│
▼
utils以及:
dashboard
│
├── ui
│
└── auth
│
▼
utils如果你修改了 packages/utils/,多个项目可能受影响。任务运行器可以利用依赖图确定合适的执行顺序,而不是手动推算“先构建 utils,再构建 api-client,再构建 ui……”这样的顺序。
14. 并行执行
另一个重要观点是:并非所有任务都需要串行执行。
假设:
storefront
↓
ui
dashboard
↓
auth这两个分支相互独立,概念上可以并行处理:
ui ────────> storefront
/
Start ──
\
auth ──────> dashboard而不是:
ui
↓
storefront
↓
auth
↓
dashboard任务系统可以在合适的情况下并发执行相互独立的工作。随着仓库增长,这一点会越来越重要。目标很简单:如果两个任务之间没有依赖关系,就不要让一个任务等待另一个。
15. vp run 不只是 npm run
乍看之下,你可能会觉得 vp run build 和 npm run build 差不多。对于简单项目,差异可能不明显。但在 Monorepo 中,任务系统增加了这些概念:
- 依赖感知执行
- 任务图
- 缓存
- 过滤
- 并行执行
- 任务级配置
因此,npm run build 主要是“运行这个脚本”,而 vp run build 可以理解为“在整个项目中运行合适的构建任务,同时理解它们之间的关系与缓存结果”。这种区别在规模化时变得重要。
16. 过滤任务
大型 Monorepo 并不总是需要运行所有内容。有时你只想处理一个包,例如只针对 apps/storefront 运行任务,而不是整个仓库。Vite+ 在其任务工作流中支持过滤,例如:
vp run --filter storefront build具体使用哪些过滤器取决于你的工作区结构和任务配置。核心思路是:
Whole repository
↓
filter
↓
Relevant packages
↓
Relevant tasks这在本地开发时尤其有用。
17. 整个工具链共用一份配置
我们在第三章看到,Vite+ 可以使用 vite.config.ts 作为集中配置文件。对于更大的仓库,这一点更加有用。配置可以包含:
import { defineConfig } from 'vite-plus'
export default defineConfig({
plugins: [],
test: {
include: ['src/**/*.test.ts'],
},
lint: {
ignorePatterns: ['dist/**'],
},
fmt: {
semi: true,
singleQuote: true,
},
run: {
tasks: {
'generate:icons': ...(原文在此处截断。)