Skip to content

Vite+ 第四章:大规模项目下的 Monorepo、任务、缓存与 CI ​

译注:本文翻译自 dev.to 上关于 Vite+ 系列教程的第四章,原文作者为 Othmane Nemli,原文链接见文末。Vite+ 是围绕 Vite 构建的一体化工具链,本文聚焦其在大型 Monorepo 场景下的任务调度与缓存能力。

在上一章中,我们使用 Vite+ 处理了一个普通应用:

shell
vp dev
vp check
vp test
vp build

这套工作流已经足够实用。但当项目规模显著变大时,Vite+ 会变得更有意思。

想象一下:一个应用变成五个,随后你加入了共享 UI 组件、共享工具函数、设计系统,以及内部库。最终仓库结构可能长这样:

plaintext
company-project/
├── apps/
│   ├── web/
│   ├── admin/
│   └── docs/
│
├── packages/
│   ├── ui/
│   ├── utils/
│   ├── config/
│   └── api/
│
└── package.json

这就是 Monorepo。也正是在这种场景下,每次都运行全部任务会变得非常昂贵。Vite+ 内置了 Vite Task,专门用于处理 Monorepo 中依赖感知的任务执行与缓存。

1. 什么是 Monorepo? ​

在讨论 Vite+ 之前,先理解问题本身。Monorepo 就是一个包含多个项目或包的仓库。例如:

plaintext
my-company/
│
├── apps/
│   ├── web/
│   └── mobile/
│
└── packages/
    ├── ui/
    ├── utils/
    └── types/

这里可能有:web(主站)、mobile(移动应用)、ui(共享 UI 组件)、utils(共享工具)、types(共享 TypeScript 类型)。

好处是一切都放在一起,开发者修改共享包后可以立即测试使用它的应用。但这也带来一个新问题:到底哪些任务需要运行?

2. 依赖问题 ​

假设存在这样的依赖关系:

plaintext
web
 │
 └── ui
      │
      └── utils

即 web 依赖 ui,ui 依赖 utils。如果你修改了 packages/utils/,是否要重建所有东西?也许需要。但如果你只改了 apps/docs/,还需要重建 web 吗?大概率不需要。

一个大型 Monorepo 可能包含数百个任务,每次小改动后全部运行会浪费时间。这正是任务系统存在的意义。

3. 什么是任务? ​

任务就是项目需要执行的事情,例如 build、test、lint、typecheck。一个包可能在 package.json 中这样定义:

json
{
  "scripts": {
    "build": "vite build",
    "test": "vitest",
    "lint": "oxlint"
  }
}

在小应用里手动运行这些任务并不困难。但在 Monorepo 中,你可能有 20 个包 × 4 个任务 = 80 个潜在任务,任务运行器的负担就大得多。

4. 认识 vp run ​

Vite+ 提供了 vp run 命令,它可以在理解包之间依赖关系的前提下运行包脚本和 Monorepo 任务,同时支持任务缓存。例如:

shell
vp run build

可以理解为:“运行与当前项目相关的构建任务”,而不需要手动逐个进入每个包。

5. 任务依赖 ​

考虑如下依赖链:

plaintext
packages/utils
       ↓
packages/ui
       ↓
apps/web

如果 web 需要 ui,ui 需要 utils,那么构建顺序就很重要。你不能在 ui 之前构建 web,也不能在 utils 之前构建 ui。依赖图如下:

plaintext
utils
  │
  ▼
 ui
  │
  ▼
web

任务运行器可以利用这张图判断哪些任务需要先执行。

6. 为什么依赖感知执行很重要 ​

假设仓库包含 web、admin、docs 三个应用,以及 ui、utils、api、config 四个包,依赖关系为:

plaintext
web → ui → utils
admin → ui → utils
docs → ui

如果 utils 发生变化,web 和 admin 可能受影响,但 docs 可能并不直接依赖 utils。依赖感知的任务运行器可以利用这些关系,而不是盲目地运行所有任务。概念上:

plaintext
utils changed
     │
     ├── ui
     │    ├── web
     │    └── admin
     │
     └── unrelated packages
          ↓
        skip

这就是核心思路。

7. 缓存 ​

假设你运行 vp run build 耗时 45 秒,然后再次运行完全相同的命令。如果相关输入没有变化,从头重建就没有必要,这正是缓存发挥作用的地方。

概念上,第一次运行:

plaintext
Source code
    ↓
Build
    ↓
45 seconds
    ↓
Save result

第二次运行:

plaintext
Same inputs
    ↓
Cache lookup
    ↓
Reuse previous result

结果可能远快于 45 秒。Vite+ 将 vp run 描述为为 Monorepo 任务提供缓存与依赖感知调度。

8. 缓存到底意味着什么? ​

一个常见误解是:“缓存意味着 Vite+ 永远不会再运行该命令。”这并不准确。关键问题在于任务的相关输入是否发生变化。可以这样理解:

plaintext
Input
 ↓
Task
 ↓
Output

例如输入包括 src/、package.json、tsconfig.json、环境等,输出是 dist/。如果输入未变,之前的结果可能可以复用;如果重要输入(如 src/Button.tsx)发生变化,任务可能需要重新运行。

plaintext
Same inputs
    ↓
Cache hit
    ↓
Reuse result

对比:

plaintext
Changed inputs
    ↓
Cache miss
    ↓
Run task again

这就是任务缓存的基本思路。

9. 缓存命中与缓存未命中 ​

你常会听到两个术语:

缓存命中(cache hit):可以复用之前的结果。

plaintext
web#build — cache hit

缓存未命中(cache miss):任务需要重新运行。

plaintext
web#build — cache miss

例如首次运行:

plaintext
utils#build   → cache miss
ui#build      → cache miss
web#build     → cache miss

所有任务都要运行。随后不做任何修改再次运行:

plaintext
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 标签。

一个简化示例如下:

yaml
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,而在工作流:

plaintext
GitHub Actions
      ↓
   setup-vp
      ↓
   vp install
      ↓
   vp check
      ↓
   vp test
      ↓
   vp build

你在本地使用的同一套 vp 命令可以用于 CI,这种一致性很有价值。

12. 本地开发与 CI 使用同一套接口 ​

我特别喜欢的一点是,开发者不必为 CI 学习一套完全不同的系统。

本地:

shell
vp check
vp test
vp build

CI:

yaml
- run: vp check
- run: vp test
- run: vp build

命令完全相同。这意味着当 CI 失败时,你通常可以在本地复现同样的命令,例如 vp test,而不必去理解某个内部调用了多个其他工具的 some-custom-ci-script.sh。

13. 一个 Monorepo 示例 ​

设想一个真实项目:

plaintext
acme/
├── apps/
│   ├── storefront/
│   └── dashboard/
│
├── packages/
│   ├── ui/
│   ├── auth/
│   ├── api-client/
│   └── utils/
│
├── package.json
└── vite.config.ts

依赖关系可能如下:

plaintext
                 ┌── ui ────────┐
                 │              │
storefront ──────┤              │
                 │              ▼
                 └── api-client
                       │
                       ▼
                     utils

以及:

plaintext
dashboard
    │
    ├── ui
    │
    └── auth
          │
          ▼
        utils

如果你修改了 packages/utils/,多个项目可能受影响。任务运行器可以利用依赖图确定合适的执行顺序,而不是手动推算“先构建 utils,再构建 api-client,再构建 ui……”这样的顺序。

14. 并行执行 ​

另一个重要观点是:并非所有任务都需要串行执行。

假设:

plaintext
storefront
    ↓
   ui

dashboard
    ↓
   auth

这两个分支相互独立,概念上可以并行处理:

plaintext
          ui ────────> storefront
         /
Start ──
         \
          auth ──────> dashboard

而不是:

plaintext
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+ 在其任务工作流中支持过滤,例如:

shell
vp run --filter storefront build

具体使用哪些过滤器取决于你的工作区结构和任务配置。核心思路是:

plaintext
Whole repository
       ↓
     filter
       ↓
Relevant packages
       ↓
Relevant tasks

这在本地开发时尤其有用。

17. 整个工具链共用一份配置 ​

我们在第三章看到,Vite+ 可以使用 vite.config.ts 作为集中配置文件。对于更大的仓库,这一点更加有用。配置可以包含:

typescript
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': ...

(原文在此处截断。)

原文链接 ​