Turborepo Monorepo:真正能扩展的配置方案
我如何用 Turborepo 构建多应用共享包的 monorepo。工作区配置、流水线缓存、共享 TypeScript 配置,以及我第一个月踩过的坑。
我职业生涯的前三年一直在不同的仓库之间复制粘贴工具函数。真的,一点不夸张。我有一个 formatDate.ts 存在于七个不同的项目中,每个版本都有略微不同的 bug。当我在一个项目中修了时区问题后,另外六个完全忘了。最终某个澳大利亚用户提了个工单,我才发现修复根本没部署到那个特定的应用里。
Monorepo 解决了这个问题。不是因为它时髦,不是因为 Google 在用,而是因为我受够了当人肉 npm registry。一个仓库、共享代码、所有依赖它的应用一次原子性变更搞定。
但 monorepo 有它自己的坑。我试过三种不同的工具,在缓存问题上浪费了好几周,半夜和循环依赖错误搏斗,还明白了"把所有东西放一个仓库"这种建议如果没有细节,基本等于没说。
这是我实际在用的 Turborepo 配置。它跑着四个生产应用和十二个共享包。因为远程缓存,构建时间控制在 90 秒以内。新成员克隆下来跑 pnpm dev,两分钟内就能跑起所有东西。我大概花了一个月的踩坑才达到今天的状态。
为什么要用 Monorepo#
让我们坦诚谈谈这其中的权衡。Monorepo 不是免费的。你是在拿一组问题换另一组,必须清楚自己买的是什么。
你能获得什么#
无需发包即可共享代码。 这是最大的好处。你在 packages/ui 写了一个 React 组件库。你的 Web 应用、管理后台和文档站点全都直接导入。修改一个 Button 组件,所有应用立刻生效。不用改版本号,不用 npm publish,不用纠结"生产环境到底用的哪个版本?"
// In apps/web/src/components/Header.tsx
import { Button, Avatar, Dropdown } from "@acme/ui";
import { formatDate } from "@acme/utils";
import { SITE_CONFIG } from "@acme/config";这些 import 看起来像外部包,但实际上是通过 workspace 依赖解析的,它们指向的是同一个仓库中的源代码。
跨包原子性变更。 假设你需要给共享的 Button 组件加一个 isLoading 属性。在多仓库的世界里,你得先改组件库、发个新版本、在每个消费端更新版本号,然后在每个仓库分别提 PR。一个属性就是三到五个 PR。
在 monorepo 里,一个 commit 搞定:
feat: add isLoading prop to Button component
- packages/ui/src/Button.tsx (add prop, render spinner)
- apps/web/src/pages/checkout.tsx (use isLoading)
- apps/admin/src/pages/users.tsx (use isLoading)
一个 PR,一次 review,一次合并。一切保持同步,因为它物理上就不可能漂移。
统一的工具链。 一套 ESLint 配置,一套 Prettier 配置,一套 TypeScript 基础配置,一条 CI 流水线。升级 ESLint?一个下午搞定全部升级,不用花三个 sprint 跨八个仓库慢慢改。
你要付出什么#
根目录的复杂度。 根目录的 package.json 变成了基础设施。CI 流水线需要理解哪些包发生了变更。IDE 可能会被文件总量拖慢。Git 操作随着仓库增长会变慢(不过对大多数团队来说这得好几年才会感知到)。
CI 时间可能暴涨。 如果每次提交都全量构建,你会浪费大量计算资源。你需要一个理解依赖图的工具,只构建发生变化的部分。这就是 Turborepo 存在的全部意义。
新人上手摩擦。 新成员需要理解工作区、依赖提升、内部包、构建流水线等概念。不是简单的"克隆就跑"。虽然最终应该做到这样,但达到这个状态需要刻意的投入。
坦率评估:如果你只有一个应用而且没有共享代码,monorepo 就是额外开销。如果你有两个或更多应用需要共享任何东西——组件、工具函数、类型定义、配置——monorepo 在第一个月就能回本。
Turborepo vs Nx vs Lerna#
三个我都用过。简单说一下。
Lerna 是 JavaScript 最早的 monorepo 工具。它管理版本和发布。但它不理解构建流水线和缓存。曾经被弃维,后来被 Nx 复活,现在感觉更像一个兼容层而非独立工具。如果你要把包发到 npm,Lerna + Nx 能搞定。但对于内部 monorepo(只在自己的应用间共享代码),它的仪式感太重了。
Nx 功能非常强大。它有代码生成器、适配各种框架的插件、可视化依赖图、分布式任务执行。但学习曲线堪称悬崖。我见过团队花两周时间光配置 Nx 就没写一行业务代码。如果你的公司有 50+ 开发者和几百个包,Nx 可能是对的选择。但对我的场景来说,需要铲子的活你给了我一台推土机。
Turborepo 做好了三件事:理解依赖图、缓存构建产物、并行执行任务。就这些。全部配置就是一个 turbo.json 文件。两分钟能读完。它不生成代码,没有插件,也不试图替代你的构建工具。它就是一个任务运行器,但在判断"哪些需要跑、哪些可以跳过"这件事上非常非常出色。
我选 Turborepo 是因为我能在 15 分钟内把整套配置讲给新人听。用 Nx 的话,同样的对话要一个小时,讲完他们还有一堆问题。
Feature | Turborepo | Nx | Lerna
------------------+---------------+-----------------+----------------
Config complexity | Low (1 file) | High (multiple) | Medium
Learning curve | ~1 day | ~1 week | ~2 days
Build caching | Yes (remote) | Yes (remote) | No (native)
Task orchestration| Yes | Yes | Basic
Code generation | No | Yes (extensive) | No
Framework plugins | No | Yes | No
Best for | Small-medium | Large orgs | Publishing
仓库结构#
这是实际的目录树。不是"快速入门"示例——这是一个生产 monorepo 跑了半年后的真实样子。
acme-monorepo/
├── apps/
│ ├── web/ # Main customer-facing app
│ │ ├── src/
│ │ ├── public/
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── admin/ # Internal admin dashboard
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── docs/ # Documentation site
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── api/ # Express/Fastify API server
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ ├── ui/ # Shared React component library
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Input.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ # Shared utility functions
│ │ ├── src/
│ │ │ ├── date.ts
│ │ │ ├── string.ts
│ │ │ ├── validation.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── types/ # Shared TypeScript types
│ │ ├── src/
│ │ │ ├── user.ts
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config-typescript/ # Shared tsconfig presets
│ │ ├── base.json
│ │ ├── nextjs.json
│ │ ├── node.json
│ │ └── package.json
│ ├── config-eslint/ # Shared ESLint configurations
│ │ ├── base.js
│ │ ├── next.js
│ │ ├── node.js
│ │ └── package.json
│ └── config-tailwind/ # Shared Tailwind presets
│ ├── tailwind.config.ts
│ └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json
apps/ 与 packages/ 的划分#
这是根本的组织原则。apps/ 放可部署的东西——Web 应用、API 服务,任何有 dev 或 start 命令的。packages/ 放库——存在的意义就是被应用或其他包消费的代码。
规则很简单:应用消费包。包永远不从应用导入。包可以从其他包导入。
如果一个包开始从应用导入东西,说明依赖图方向反了,Turborepo 会把它当作循环依赖报错。
工作区配置#
根目录的 pnpm-workspace.yaml 告诉 pnpm 去哪找包:
packages:
- "apps/*"
- "packages/*"整个文件就这些。pnpm 扫描这些目录,读取每个 package.json,然后创建一个工作区映射。当 apps/web 声明 "@acme/ui": "workspace:*" 作为依赖时,pnpm 会把它链接到本地的 packages/ui,而不是去 npm 仓库查找。
根目录 package.json#
{
"name": "acme-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,md,json}\""
},
"devDependencies": {
"prettier": "^3.4.2",
"turbo": "^2.4.4",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=20"
}
}注意:根目录的 package.json 没有任何运行时依赖。它纯粹是编排用的。turbo 是任务运行器,prettier 处理格式化(因为它是唯一一个不需要每个包单独配置的工具),typescript 被提升以确保所有包使用相同版本。
命名规范#
每个包都用作用域名称:@acme/ui、@acme/utils、@acme/config-typescript。作用域可以防止与 npm 包冲突,在任何 import 语句中一眼就能看出用的是内部代码还是外部代码。
// External package - from npm
import { clsx } from "clsx";
// Internal package - from our monorepo
import { Button } from "@acme/ui";我给配置类的包加上 config- 前缀来归类:@acme/config-typescript、@acme/config-eslint、@acme/config-tailwind。有些团队用 @acme/tsconfig、@acme/eslint-config。哪种都行,保持一致就好。
turbo.json 流水线配置#
这是 Turborepo 真正体现价值的地方。turbo.json 文件定义了你的任务流水线——有哪些任务、它们依赖什么、它们产出什么。
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"inputs": ["src/**", "package.json", "tsconfig.json"]
},
"lint": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"dependsOn": ["^build"],
"cache": true,
"inputs": ["src/**", "test/**", "**/*.test.ts", "**/*.test.tsx"]
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
},
"type-check": {
"dependsOn": ["^build"],
"cache": true
}
}
}让我逐一拆解每个字段,因为大多数人都在这里搞不清楚。
dependsOn 和 ^ 符号#
"dependsOn": ["^build"] 的意思是:"在包 X 中运行这个任务之前,先在 X 依赖的每个包中运行 build。"
^ 符号表示"上游依赖"。没有它的话,"dependsOn": ["build"] 表示"先在同一个包里运行 build"——当你的 test 任务需要先在同包内完成 build 时很有用。
举个具体例子。你的依赖图长这样:
apps/web → @acme/ui → @acme/utils
→ @acme/types
当你运行 turbo build 时,Turborepo 解析这个图:
- 构建
@acme/types(无依赖) - 构建
@acme/utils(无依赖) - 构建
@acme/ui(依赖 types 和 utils——等它们完成) - 构建
apps/web(依赖 ui——等它完成)
第 1 步和第 2 步并行执行。第 3 步等前两步完成。第 4 步等第 3 步。Turborepo 根据你 package.json 中的依赖声明自动推断这个顺序,你不需要手动指定。
outputs#
这对缓存至关重要。Turborepo 缓存一次构建时,会存储 outputs 中列出的文件。命中缓存时,它恢复这些文件。如果你漏掉了某个输出目录,缓存会"成功"但文件不会出现。这是我第一周犯的最大错误——漏掉了 .next/**,怎么也想不通为什么 Next.js 应用总在重新构建。
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]!.next/cache/** 排除规则很重要。Next.js 在 .next/cache/ 里有自己的缓存。你不想缓存套缓存——那只会撑大远程缓存的存储而没有任何好处。
inputs#
默认情况下,Turborepo 会哈希一个包中的每个文件来判断它是否变化了。inputs 字段可以缩小这个范围。如果只有 src/ 中的文件影响构建产物,那改 README.md 不应该让缓存失效。
"inputs": ["src/**", "package.json", "tsconfig.json"]这里要小心。如果你加了一个影响构建的配置文件(比如 postcss.config.js)但没加到 inputs 中,你就会得到过期的缓存产物。拿不准的时候,别设置 inputs,让 Turborepo 哈希所有文件。
globalDependencies#
这里列出的文件一旦变更,会让所有包的缓存失效。环境变量文件是最典型的例子——如果你的 .env.local 变了,所有可能读 process.env 的东西都需要重新构建。
"globalDependencies": ["**/.env.*local"]我还会在这里加上根目录的 tsconfig.json,因为基础 TypeScript 配置影响所有包:
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
]persistent 和 dev#
dev 任务设置了 "cache": false 和 "persistent": true。缓存一个开发服务器没有意义——它是一个常驻进程。persistent 标志告诉 Turborepo 这个任务不会退出,所以不要等它完成再运行其他任务。
当你运行 turbo dev 时,Turborepo 会并行启动所有开发服务器——你的 Next.js 应用、API 服务器、文档站点——全在一个终端里交错输出。每行都带包名前缀,方便区分。
共享包详解#
packages/ui — 组件库#
这是每个团队最先构建的包。跨所有前端应用共享的 React 组件。
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./styles.css": "./src/styles.css"
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external react",
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch",
"clean": "rm -rf dist",
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"tsup": "^8.3.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}几个值得注意的地方:
"version": "0.0.0" — 内部包不需要真实的版本号。workspace:* 协议告诉 pnpm 始终使用本地副本。版本号毫无意义。
"private": true — 这个包永远不会发到 npm。如果将来想发布,去掉这个字段并建立正规的版本策略。
exports 字段 — 这是定义包入口点的现代方式。它替代了 main、module 和 types 字段。"." export 是默认导入路径。你可以加子路径导出实现细粒度导入:
{
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./Button": {
"types": "./src/components/Button.tsx",
"default": "./src/components/Button.tsx"
},
"./styles.css": "./src/styles.css"
}
}重要决策:要不要构建? 有些团队完全跳过内部包的构建步骤。不用 tsup 编译,直接把 exports 指向 TypeScript 源码,让消费端应用的打包器(Next.js、Vite)来处理编译。这在 Turborepo 术语中叫"内部包",更简洁:
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
}
}没有构建步骤。没有 dist 目录。消费端应用的 next.config.ts 需要 transpilePackages:
// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
};
export default nextConfig;我的大多数内部包都用无构建的方式。开发时更快,少一个出问题的环节。唯一的例外是需要支持非 TypeScript 消费端或有复杂构建需求的包。
packages/utils — 共享工具函数#
{
"name": "@acme/utils",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./date": "./src/date.ts",
"./string": "./src/string.ts",
"./validation": "./src/validation.ts"
},
"dependencies": {
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3"
}
}子路径导出让消费端应用按需导入:
// Only imports date utilities, not the entire package
import { formatRelativeDate, parseISO } from "@acme/utils/date";
// Imports everything (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";src/index.ts 中的桶导出重新导出所有内容:
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";关于桶导出有个注意点:它很方便,但在某些打包器中会影响 tree-shaking。如果 apps/web 只从 @acme/utils 导入一个函数,一个不够聪明的打包器可能会把所有东西都打进去。Next.js 内置的 tree-shaking 处理得很好,但如果你注意到包体积问题,子路径导出方案更安全。
packages/types — 共享 TypeScript 类型#
{
"name": "@acme/types",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./api": "./src/api.ts",
"./user": "./src/user.ts"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3"
}
}这个包零运行时依赖。它只包含 TypeScript 类型和接口。永远不需要构建,因为类型在编译时被擦除。
// packages/types/src/user.ts
export interface User {
id: string;
email: string;
name: string;
role: "admin" | "user" | "viewer";
createdAt: Date;
updatedAt: Date;
}
export interface CreateUserInput {
email: string;
name: string;
role?: User["role"];
}
export interface UserFilters {
role?: User["role"];
search?: string;
page?: number;
limit?: number;
}// packages/types/src/api.ts
export interface ApiResponse<T> {
data: T;
meta?: {
page: number;
limit: number;
total: number;
};
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
export type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError };packages/config-typescript — 共享 TSConfig#
这个包看似不起眼,实际上非常重要。monorepo 中的 TypeScript 配置是最容易出问题的地方。
{
"name": "@acme/config-typescript",
"version": "0.0.0",
"private": true,
"files": [
"base.json",
"nextjs.json",
"node.json",
"react-library.json"
]
}所有包都继承的基础配置:
// packages/config-typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true
},
"exclude": ["node_modules"]
}Next.js 专用配置:
// packages/config-typescript/nextjs.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"incremental": true,
"plugins": [
{ "name": "next" }
]
}
}Node.js 后端配置:
// packages/config-typescript/node.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src"
}
}然后每个应用或包继承对应的预设:
// apps/web/tsconfig.json
{
"extends": "@acme/config-typescript/nextjs",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}// apps/api/tsconfig.json
{
"extends": "@acme/config-typescript/node",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}// packages/ui/tsconfig.json
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}@/* 路径别名#
每个应用都有相同的 @/* 路径别名指向自己的 src/ 目录。也就是说 apps/web 里的 @/components/Header 解析到 apps/web/src/components/Header,而 apps/admin 里的 @/components/Header 解析到 apps/admin/src/components/Header。
这些是应用本地的路径,不跨包边界。跨包导入永远用包名:@acme/ui,而不是 @/../../packages/ui。如果你看到一个相对导入路径穿透了包的根目录(../../packages/something),那就是代码异味。请用 workspace 依赖。
composite 和项目引用#
TypeScript 项目引用(composite: true)允许 tsc 增量构建包并理解跨项目依赖。这对 Turborepo 来说是可选的——Turborepo 自己处理构建编排——但它能加速 IDE 中的类型检查。
// packages/ui/tsconfig.json (with composite)
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src"],
"references": [
{ "path": "../types" },
{ "path": "../utils" }
]
}坦白说:项目引用增加了复杂度,我在大多数配置中都去掉了。Turborepo 的 --filter 和 dependsOn 已经处理了构建顺序。我只在 IDE 性能成为问题时(通常是 15+ 个包)才加 composite: true。
packages/config-eslint — 共享 Lint 配置#
// packages/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" }
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports" }
],
},
}
);// packages/config-eslint/next.js
import baseConfig from "./base.js";
import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
export default [
...baseConfig,
{
plugins: {
"@next/next": nextPlugin,
"react": reactPlugin,
"react-hooks": hooksPlugin,
},
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
"react-hooks/exhaustive-deps": "warn",
},
},
];每个应用引用它的配置:
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
export default [
...nextConfig,
{
ignores: [".next/"],
},
];远程缓存#
本地缓存挺好的——它可以在开发时避免重新构建未变更的包。但远程缓存才是真正的变革。它意味着你的 CI 服务器可以受益于你本地的构建,反之亦然。
工作原理#
当 Turborepo 运行一个任务时,它基于以下内容计算哈希值:
- 源文件(内容哈希)
- 任务的
inputs配置 - 环境变量
- 上游依赖的哈希值
如果哈希值匹配之前缓存的结果,Turborepo 会从缓存恢复 outputs 而不是运行任务。有了远程缓存,这些缓存产物存储在一个共享位置,任何机器——你的笔记本、同事的笔记本、CI——都能访问。
Vercel 远程缓存#
最简单的方案。Vercel 维护着免费的缓存基础设施(有限额):
# Login to Vercel (one-time setup)
npx turbo login
# Link your repo to a Vercel project
npx turbo link搞定。Turborepo 现在会从 Vercel 的服务器推送和拉取缓存产物。在 CI 上,你设置环境变量:
# .github/workflows/ci.yml
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}从 Vercel 仪表板的团队设置中生成 token。TURBO_TEAM 是你的团队 slug。
自建远程缓存#
如果不能用 Vercel(离线环境、数据驻留要求),你可以自建。Turborepo 的远程缓存 API 文档很完善,也有多个开源实现:
- ducktors/turborepo-remote-cache — 一个 Node.js 服务器,产物存储在 S3、GCS 或本地文件系统
- fox1t/turborepo-remote-cache — 类似,支持 Azure Blob
# docker-compose.yml for self-hosted cache
services:
turbo-cache:
image: ducktors/turborepo-remote-cache:latest
ports:
- "3000:3000"
environment:
TURBO_TOKEN: "your-secret-token"
STORAGE_PROVIDER: "s3"
S3_ACCESS_KEY: "${AWS_ACCESS_KEY_ID}"
S3_SECRET_KEY: "${AWS_SECRET_ACCESS_KEY}"
S3_REGION: "eu-central-1"
S3_ENDPOINT: "https://s3.eu-central-1.amazonaws.com"
S3_BUCKET: "turbo-cache"然后配置你的 monorepo 使用它:
// .turbo/config.json
{
"teamId": "team_acme",
"apiUrl": "https://turbo-cache.internal.acme.com"
}缓存命中率#
根据我的经验,配置得当的 Turborepo 在 CI 中能达到 80-95% 的缓存命中率。这意味着任何给定的 CI 流水线中只有 5-20% 的任务真正执行。其余的几秒钟内从缓存恢复。
提高缓存命中率的要点:
- 收窄
inputs。 如果只有src/影响构建产物,别让 README 的改动使缓存失效。 - 不要把易变数据放到
globalDependencies中。 一个每次部署都变的.env文件会杀死你的缓存。 - 锁定依赖版本。 lockfile 变更会使所有下游缓存失效。
- 保持包小而专注。 一个有 200 个文件的巨型
packages/utils会频繁地缓存失效,因为任何工具函数的改动都会打破整个包的缓存。
CI/CD 与 Turborepo#
这是我用的 GitHub Actions 工作流。经过几十次迭代打磨。
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
ci:
name: Build, Lint, Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need parent commit for change detection
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build
- name: Lint
run: pnpm turbo lint
- name: Type Check
run: pnpm turbo type-check
- name: Test
run: pnpm turbo test--filter 标志#
--filter 标志是针对特定包或基于变更运行任务的方式。这对高效 CI 至关重要:
# Build only the web app and its dependencies
pnpm turbo build --filter=apps/web...
# Build only packages that changed since main
pnpm turbo build --filter=...[main]
# Build only @acme/ui and everything that depends on it
pnpm turbo build --filter=...@acme/ui
# Build only packages in the packages/ directory
pnpm turbo build --filter="./packages/*"三个点的语法很重要:
--filter=@acme/ui...— 这个包及其所有依赖(它需要的东西)--filter=...@acme/ui— 这个包及其所有被依赖方(需要它的东西)--filter=@acme/ui— 仅这个包本身
受影响包检测#
对于 PR,你大概不想构建所有东西。你想只构建受变更影响的部分:
# Only build/test what changed compared to main
- name: Build affected
run: pnpm turbo build --filter="...[origin/main]"
- name: Test affected
run: pnpm turbo test --filter="...[origin/main]"[origin/main] 语法告诉 Turborepo 将当前提交与 origin/main 对比,只对有变更的包运行任务。配合远程缓存,大多数 PR 流水线即使在大型 monorepo 中也能在 2 分钟内完成。
Matrix 策略部署#
如果每个应用独立部署,使用 matrix 策略:
deploy:
needs: ci
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
strategy:
matrix:
app: [web, admin, docs, api]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build --filter=apps/${{ matrix.app }}...
- name: Deploy ${{ matrix.app }}
run: ./scripts/deploy.sh ${{ matrix.app }}
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}每个 matrix 任务只构建一个应用及其依赖。得益于远程缓存,共享包只构建一次并被缓存——后续的 matrix 任务从缓存恢复。
常见踩坑点#
我专门拿出一整节来讲这个,因为第一个月每个坑我都踩了。从文档学是高效的。从我的错误学是免费的。
1. 循环依赖#
依赖图必须是 DAG(有向无环图)。如果 @acme/ui 导入了 @acme/utils,而 @acme/utils 又导入了 @acme/ui,你就有了循环。Turborepo 会报错并拒绝构建。
修复几乎总是把共享代码提取到第三个包里:
BEFORE (broken):
@acme/ui → @acme/utils → @acme/ui ← cycle!
AFTER (fixed):
@acme/ui → @acme/types
@acme/utils → @acme/types
把共享的类型或常量移到 @acme/types 里,让两个包都依赖它。
另一个常见原因:你在 @acme/utils 里放了一个 React hook,而这个 hook 导入了 @acme/ui 的组件。依赖 UI 组件的 hook 应该放在 @acme/ui 而不是 @acme/utils。工具函数应该尽可能与框架无关。
2. 遗漏 outputs 导致缓存失效#
这是最令人抓狂的 bug。本地一切正常。CI 构建通过。但每次构建都要跑满全部时间,因为根本没有缓存命中。
场景:你的包构建到 build/ 而不是 dist/,但 turbo.json 的 outputs 只列了 dist/**:
// turbo.json
"build": {
"outputs": ["dist/**"] // but your package builds to build/
}Turborepo 缓存了这个任务(因为它成功运行了),但没有存储任何输出文件。下次命中缓存时恢复出来的是空的。消费端应用找不到构建产物而失败,或者更糟——默默使用过期的文件。
解决方案: 审计每个包的构建输出目录,确保 turbo.json 覆盖了所有:
"build": {
"outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}或者在 turbo.json 中使用按包覆盖:
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
},
// Per-package overrides
"packages": {
"apps/web": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
},
"apps/api": {
"build": {
"outputs": ["build/**"]
}
}
}
}3. 开发服务器协调#
运行 turbo dev 会同时启动所有开发服务器。在它们尝试用同一个端口之前,一切都没问题。必须显式分配端口:
// apps/web/package.json
"scripts": {
"dev": "next dev --port 3000"
}
// apps/admin/package.json
"scripts": {
"dev": "next dev --port 3001"
}
// apps/docs/package.json
"scripts": {
"dev": "next dev --port 3002"
}
// apps/api/package.json
"scripts": {
"dev": "tsx watch src/index.ts" // Uses port 4000 from config
}另一个问题:共享包的热更新。当你编辑 packages/ui 中的文件时,变更需要传播到正在运行的 Next.js 开发服务器。如果你用的是"内部包"方式(无构建步骤,直接 TypeScript 导入),这是自动的——Next.js 通过软链接的 node_modules 监听源文件。
如果你的包有构建步骤,需要让包的 dev 脚本以 watch 模式运行:
// packages/ui/package.json
"scripts": {
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}Turborepo 并行运行所有 dev 任务,所以包在变更时重新构建,消费端应用就能拿到新的产物。
4. 包间版本漂移#
这个很隐蔽也很危险。如果 apps/web 用 React 19 而 apps/admin 意外用了 React 18,你可能要等到 @acme/ui 的组件在两个应用中表现不一致时才发现。
解决方案:在根目录统一管理共享依赖。 pnpm 根 package.json 的 overrides 字段可以强制所有地方用同一版本:
// Root package.json
{
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
}
}
}定期运行 pnpm ls react --recursive 来验证所有地方都用同一版本。
5. 共享包中的环境变量#
共享包不应该直接读 process.env。如果 @acme/utils 读 process.env.API_URL,它就和消费端应用的环境产生了一个看不见的耦合。应该显式传入配置:
// BAD: packages/utils/src/api.ts
const API_URL = process.env.API_URL; // What env? Which app?
export function fetchData(path: string) {
return fetch(`${API_URL}${path}`);
}
// GOOD: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
return {
fetch: (path: string) => fetch(`${baseUrl}${path}`),
};
}消费端应用提供配置:
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);这样包保持纯净且可测试。
6. 幽灵依赖#
pnpm 默认对依赖很严格——它不像 npm 那样提升包。这是特性而非 bug。这意味着如果 @acme/ui 导入了 clsx 但没在 package.json 中声明,pnpm 会报错。npm 则会静默地从父级 node_modules 中解析。
始终在消费包的 package.json 中声明每个 import。不要再依赖提升了。
7. IDE 性能下降#
当有 15+ 个包时,你的 TypeScript 语言服务器可能开始吃力。一些建议:
- 在根
tsconfig.json中加上"exclude": ["node_modules", "**/dist/**"] - 在 VS Code 的 "Files: Exclude" 中隐藏
dist/、.next/和.turbo/目录 - 如果"跳转到定义"把你带到了
node_modules深处的.d.ts文件,考虑在 tsconfig 中设置"disableSourceOfProjectReferenceRedirect": true
完整起步结构#
让我把所有内容串起来。这是引导一个 Turborepo monorepo(两个 Next.js 应用和三个共享包)所需的全部文件。
根目录#
// package.json
{
"name": "acme-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"type-check": "turbo type-check",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
},
"devDependencies": {
"prettier": "^3.4.2",
"turbo": "^2.4.4",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=20"
},
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
}# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"inputs": ["src/**", "package.json", "tsconfig.json"]
},
"lint": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"dependsOn": ["^build"],
"cache": true,
"inputs": [
"src/**",
"test/**",
"**/*.test.ts",
"**/*.test.tsx",
"vitest.config.ts"
]
},
"type-check": {
"dependsOn": ["^build"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}// tsconfig.json (root — for IDE only, not used by builds)
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}apps/web#
// apps/web/package.json
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf .next"
},
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*",
"next": "^15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.7.3",
"vitest": "^3.0.7"
}
}// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
reactStrictMode: true,
};
export default nextConfig;// apps/web/tsconfig.json
{
"extends": "@acme/config-typescript/nextjs",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}apps/admin#
// apps/admin/package.json
{
"name": "admin",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "next lint",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*",
"next": "^15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.7.3"
}
}packages/ui#
// packages/ui/package.json
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}// packages/ui/src/index.ts
export { Button } from "./components/Button";
export { Input } from "./components/Input";
export { Modal } from "./components/Modal";
export { Card } from "./components/Card";
export { Badge } from "./components/Badge";
// Re-export types
export type { ButtonProps } from "./components/Button";
export type { InputProps } from "./components/Input";
export type { ModalProps } from "./components/Modal";// packages/ui/src/components/Button.tsx
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "@acme/utils";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
{
"bg-blue-600 text-white hover:bg-blue-700": variant === "primary",
"bg-gray-100 text-gray-900 hover:bg-gray-200": variant === "secondary",
"border border-gray-300 bg-transparent hover:bg-gray-100": variant === "outline",
"bg-transparent hover:bg-gray-100": variant === "ghost",
"bg-red-600 text-white hover:bg-red-700": variant === "destructive",
},
{
"h-8 px-3 text-sm": size === "sm",
"h-10 px-4 text-sm": size === "md",
"h-12 px-6 text-base": size === "lg",
},
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<>
<svg
className="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{children}
</>
) : (
children
)}
</button>
);
}
);
Button.displayName = "Button";packages/utils#
// packages/utils/package.json
{
"name": "@acme/utils",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./date": "./src/date.ts",
"./string": "./src/string.ts",
"./cn": "./src/cn.ts"
},
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^3.0.1"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3",
"vitest": "^3.0.7"
}
}// packages/utils/src/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}// packages/utils/src/date.ts
export function formatRelativeDate(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSeconds < 60) return "just now";
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function formatDate(date: Date, locale = "en-US"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}// packages/utils/src/string.ts
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-");
}
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trimEnd() + "...";
}
export function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}// packages/utils/src/index.ts
export { cn } from "./cn";
export { formatRelativeDate, formatDate } from "./date";
export { slugify, truncate, capitalize } from "./string";packages/types#
// packages/types/package.json
{
"name": "@acme/types",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./user": "./src/user.ts",
"./api": "./src/api.ts"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3"
}
}.gitignore#
# Dependencies
node_modules/
# Build outputs
dist/
build/
.next/
out/
# Turborepo
.turbo/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/settings.json
.idea/
# OS
.DS_Store
Thumbs.db
从零开始#
如果你要从头开始,这是精确的命令序列:
# Create the repo
mkdir acme-monorepo && cd acme-monorepo
git init
# Initialize the root package
pnpm init
# Create the workspace file
echo 'packages:\n - "apps/*"\n - "packages/*"' > pnpm-workspace.yaml
# Install Turborepo
pnpm add -D turbo -w
# Create directories
mkdir -p apps/web apps/admin
mkdir -p packages/ui/src packages/utils/src packages/types/src
mkdir -p packages/config-typescript packages/config-eslint
# Initialize apps (from their directories)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
# Add workspace dependencies
cd packages/ui && pnpm add @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
cd apps/web && pnpm add @acme/ui@workspace:* @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
# Install everything
pnpm install
# Verify it works
pnpm turbo build
pnpm turbo dev第一次 pnpm turbo build 会比较慢——所有东西从头构建。第二次应该几乎是瞬间完成的(前提是没有改动)。这就是缓存在工作。
进阶扩展#
一旦过了初始配置阶段,随着 monorepo 的增长,一些模式会自然浮现。
包生成器#
当你创建了第十个包之后,靠复制粘贴再修改就很烦了。写个脚本:
// scripts/create-package.ts
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const name = process.argv[2];
if (!name) {
console.error("Usage: tsx scripts/create-package.ts <package-name>");
process.exit(1);
}
const dir = join(process.cwd(), "packages", name);
mkdirSync(join(dir, "src"), { recursive: true });
writeFileSync(
join(dir, "package.json"),
JSON.stringify(
{
name: `@acme/${name}`,
version: "0.0.0",
private: true,
exports: { ".": "./src/index.ts" },
scripts: {
lint: "eslint src/",
"type-check": "tsc --noEmit",
},
devDependencies: {
"@acme/config-typescript": "workspace:*",
typescript: "^5.7.3",
},
},
null,
2
)
);
writeFileSync(
join(dir, "tsconfig.json"),
JSON.stringify(
{
extends: "@acme/config-typescript/base",
compilerOptions: {
outDir: "./dist",
rootDir: "./src",
},
include: ["src"],
exclude: ["node_modules", "dist"],
},
null,
2
)
);
writeFileSync(join(dir, "src", "index.ts"), "// Entry point\n");
console.log(`Created packages/${name}`);
console.log("Run: pnpm install");npx tsx scripts/create-package.ts email
# Creates packages/email with all the boilerplate工作区依赖图可视化#
Turborepo 有一个内置的图命令:
pnpm turbo build --graph=graph.svg这会生成你依赖图的 SVG。我在大型重构前会跑一下,了解变更的影响范围。如果改 @acme/types 会导致 12 个包重新构建,也许该把它拆成 @acme/types-user 和 @acme/types-billing 了。
Docker 裁剪#
从 monorepo 部署单个应用时,你不想把整个仓库复制到 Docker 镜像里。Turborepo 有个 prune 命令:
pnpm turbo prune --scope=apps/web --docker这会生成一个 out/ 目录,只包含 apps/web 及其依赖所需的文件。你的 Dockerfile 用这个裁剪后的产物:
FROM node:20-alpine AS base
FROM base AS builder
WORKDIR /app
COPY out/json/ .
COPY out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY out/full/ .
RUN pnpm turbo build --filter=web...
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["node", "apps/web/server.js"]Docker 镜像只包含 Web 应用及其依赖。没有 admin 应用、没有 API 服务器、没有用不到的包。即使 monorepo 不断增长,镜像大小也能保持合理。
一个月后的回顾:如果重来我会怎么做#
如果让我重来一次,带着现在的认知:
-
从内部包开始(不加构建步骤)。 我浪费了两周时间为只有自己 Next.js 应用消费的包配置 tsup 构建。Next.js 配置中的
transpilePackages更简单更快。只在确实需要时才加构建步骤。 -
初期保持
packages/数量少。 我早期拆得太激进了。packages/utils、packages/types和packages/ui足够起步。等某个包变得臃肿了再拆。过早拆分意味着更多package.json要维护,依赖图也更复杂。 -
第一天就配好远程缓存。
npx turbo login && npx turbo link只需 30 秒。第一周省下的构建时间就足以证明值得。 -
把工作区命令写进文档。 新人不知道
pnpm --filter @acme/ui add lodash是装到特定包里,也不知道pnpm turbo build --filter=apps/web...只构建必要的部分。在贡献指南中加一个简单的"Monorepo 速查表"能省每个人的时间。 -
从第一天就强制依赖方向。 如果你允许哪怕一个从应用到包的导入,边界会迅速腐蚀。加一条 lint 规则或 CI 检查。方向永远是
apps → packages → packages。绝不逆向。
Monorepo 不是目的。目的是能顺利交付功能,不跟自己的代码库打架。Turborepo 是我找到的最轻量的工具,能把这件事做好。它处理任务图,它处理缓存,其他事情它都不干预。
从简单开始。仓库需要时再加复杂度。还有,记得固定端口号。