前端工程化:Monorepo 实践与 Turborepo 入门
随着项目规模增长,多项目共享代码的需求越来越强。Monorepo(单仓库多包)配合 Turborepo 的增量构建,是当前前端工程化的主流方案之一。本文从零搭建一个完整的 Monorepo 项目,覆盖实战中的关键决策点。
为什么选择 Monorepo
Multirepo 的痛点
在 Multirepo 模式下,每个项目一个仓库。当多个项目需要共享工具函数、UI 组件、类型定义时,你会遇到:
- 依赖版本不一致:A 项目用 Vue 3.3,B 项目用 Vue 3.4,共享组件难以兼容
- 跨仓库修改成本高:改一个共享工具函数,需要:提交到共享仓库 → 发版 → 在各项目仓库升级 → 测试
- 原子提交不可能:一个需求涉及共享包和业务项目,无法在一个 PR 中完整 review
- CI 配置重复:每个仓库都维护一套构建、部署、代码规范的配置
Monorepo 的优势
- 代码共享:多个项目直接引用同仓库的包,无需发包
- 统一版本:所有项目使用同一版本的 Vue、TypeScript 等
- 原子提交:跨项目的修改在一个 PR 中完成
- 统一工具链:ESLint、Prettier、tsconfig、CI 配置集中管理
什么时候不适合 Monorepo
- 团队只有 1-2 个项目,且不打算共享代码
- 项目技术栈差异巨大(如一个 React、一个 Vue)
- 没有合适的工具链(Turborepo / Nx / pnpm workspace)
项目结构设计
my-monorepo/
├── apps/ # 应用
│ ├── web/ # 官网(Nuxt)
│ │ ├── package.json
│ │ └── nuxt.config.ts
│ └── admin/ # 管理后台(Vue + Vite)
│ ├── package.json
│ └── vite.config.ts
├── packages/ # 共享包
│ ├── ui/ # 共享 UI 组件库
│ ├── utils/ # 工具函数
│ ├── types/ # 共享类型定义
│ └── config/ # 共享配置(eslint, tsconfig)
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── tsconfig.base.json
从零搭建
1. 初始化 pnpm workspace
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
// package.json (根目录)
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test",
"clean": "turbo clean && rm -rf node_modules"
},
"devDependencies": {
"turbo": "^2.0.0",
"typescript": "^5.4.0"
},
"packageManager": "pnpm@9.0.0"
}
2. Turborepo 配置
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".nuxt/**", ".output/**"],
"env": ["NODE_ENV", "DATABASE_URL"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"clean": {
"cache": false
}
}
}
关键配置说明:
dependsOn: ["^build"]:^表示先构建依赖的包,再构建自己outputs:声明构建产物路径,用于缓存命中判断env:声明影响构建的环境变量,变量变化时缓存自动失效persistent: true:dev 任务是长运行进程,不参与缓存
3. 共享包配置
// packages/utils/package.json
{
"name": "@my/utils",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./date": {
"types": "./dist/date.d.ts",
"import": "./dist/date.js"
}
},
"scripts": {
"build": "tsup src/index.ts src/date.ts --format esm --dts",
"dev": "tsup src/index.ts src/date.ts --format esm --dts --watch"
},
"devDependencies": {
"tsup": "^8.0.0"
}
}
// packages/utils/src/index.ts
export * from './date'
export * from './format'
// packages/utils/src/date.ts
export function formatDate(date: Date | string, format = 'YYYY-MM-DD'): string {
const d = typeof date === 'string' ? new Date(date) : date
// ...实现
return format
.replace('YYYY', String(d.getFullYear()))
.replace('MM', String(d.getMonth() + 1).padStart(2, '0'))
.replace('DD', String(d.getDate()).padStart(2, '0'))
}
4. 应用引用共享包
// apps/web/package.json
{
"name": "@my/web",
"dependencies": {
"@my/utils": "workspace:*",
"@my/ui": "workspace:*"
}
}
workspace:*是 pnpm 的语法,表示从当前 workspace 引用,不走 npm registry。
// apps/web/app.vue
import { formatDate } from '@my/utils'
const today = formatDate(new Date())
5. 共享 TypeScript 配置
// tsconfig.base.json (根目录)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
// packages/utils/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
Turborepo 缓存机制深入
本地缓存
Turborepo 会根据以下因素计算缓存 key:
- 源文件内容(hash)
- 依赖包的构建输出
- 环境变量
- 构建命令
缓存命中时,直接恢复 outputs 中声明的文件,跳过实际构建:
$ turbo build
• packages/utils:build: cache hit, outputs dist/**
• packages/ui:build: cache miss, building...
• apps/web:build: cache miss, building...
Tasks: 3 successful, 3 total
Cached: 1 cached, 3 total
Time: 2.3s → Full run would take 12s
远程缓存
在团队协作中,CI 上的构建结果可以缓存到远程,其他开发者(或 CI)直接复用:
# 使用 Vercel 远程缓存(免费)
turbo login
turbo link
# 之后 build 会自动同步远程缓存
也可以自建远程缓存服务器(兼容 Turborepo 缓存协议):
# .env
TURBO_API=https://my-cache-server.com
TURBO_TOKEN=xxx
TURBO_TEAM=my-team
增量构建实战
只构建变更的包
# 只构建相比 main 分支有变化的包及其依赖链
turbo build --filter=...[origin/main]
# 只构建某个包
turbo build --filter=@my/web
# 构建某个包及其依赖
turbo build --filter=@my/web...
# 排除某些包
turbo build --filter="!@my/admin"
CI 中的增量构建
# .github/workflows/ci.yml
name: CI
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # turbo 需要完整 git 历史来计算 diff
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# PR 中只构建变更的包
- run: pnpm build --filter=...[origin/main]
- run: pnpm test --filter=...[origin/main]
- run: pnpm lint --filter=...[origin/main]
常见问题与最佳实践
1. 循环依赖检测
$ turbo build
⚠ packages/ui imports @my/utils
⚠ packages/utils imports @my/ui
ERROR: circular dependency detected
解法:将循环依赖的公共部分提取到第三个包。
2. 版本管理
使用 Changesets 管理包版本和发布:
pnpm add -D @changesets/cli @changesets/changelog-github
npx changeset init
# 添加变更记录
npx changeset
# 发版(自动 bump 版本 + 生成 changelog + 发布)
npx changeset publish
3. Workspace 协议 vs 链接
workspace:* 在 pnpm 中会创建硬链接,不是 symlink。如果共享包需要热更新,确保 dev 脚本使用了 watch 模式,且应用配置了 transpileDependencies(Nuxt)或类似选项。
4. 跨包类型推导
确保每个包都生成了 .d.ts 文件(declaration: true),否则消费方无法获得类型提示。使用 tsup 或 tsc 生成类型声明文件。
与 Nx 的对比
| 特性 | Turborepo | Nx |
|---|---|---|
| 学习曲线 | 低 | 高 |
| 配置复杂度 | 简单(一个 turbo.json) | 丰富(project.json + nx.json) |
| 缓存 | ✅ 本地 + 远程 | ✅ 本地 + 远程 |
| 代码生成器 | ❌ | ✅ 丰富的 plugin 生态 |
| 依赖图可视化 | ❌ | ✅ nx graph |
| 适合场景 | 中小型 Monorepo | 大型企业级 Monorepo |
如果团队不超过 20 人,包数量不超过 30 个,Turborepo 完全够用。超过这个规模,Nx 的代码生成和依赖分析会更有价值。
总结
Monorepo + Turborepo 的组合带来了三个核心价值:
- 代码共享无摩擦:
workspace:*直接引用,无需发包 - 构建速度:增量构建 + 缓存命中,大型项目构建从分钟级降到秒级
- 统一工程规范:ESLint、tsconfig、CI 集中管理,一致性有保障
建议从一个小项目开始:先建立目录结构和 turbo.json,创建 1-2 个共享包(如 @my/utils),然后逐步迁移现有项目进来。渐进式迁移比一次到位更务实。
Comments | 0条评论