跳至内容
·16 分钟阅读

Turborepo Monorepo:真正能扩展的配置方案

我如何用 Turborepo 构建多应用共享包的 monorepo。工作区配置、流水线缓存、共享 TypeScript 配置,以及我第一个月踩过的坑。

分享:X / TwitterLinkedIn

我职业生涯的前三年一直在不同的仓库之间复制粘贴工具函数。真的,一点不夸张。我有一个 formatDate.ts 存在于七个不同的项目中,每个版本都有略微不同的 bug。当我在一个项目中修了时区问题后,另外六个完全忘了。最终某个澳大利亚用户提了个工单,我才发现修复根本没部署到那个特定的应用里。

Monorepo 解决了这个问题。不是因为它时髦,不是因为 Google 在用,而是因为我受够了当人肉 npm registry。一个仓库、共享代码、所有依赖它的应用一次原子性变更搞定。

但 monorepo 有它自己的坑。我试过三种不同的工具,在缓存问题上浪费了好几周,半夜和循环依赖错误搏斗,还明白了"把所有东西放一个仓库"这种建议如果没有细节,基本等于没说。

这是我实际在用的 Turborepo 配置。它跑着四个生产应用和十二个共享包。因为远程缓存,构建时间控制在 90 秒以内。新成员克隆下来跑 pnpm dev,两分钟内就能跑起所有东西。我大概花了一个月的踩坑才达到今天的状态。

为什么要用 Monorepo#

让我们坦诚谈谈这其中的权衡。Monorepo 不是免费的。你是在拿一组问题换另一组,必须清楚自己买的是什么。

你能获得什么#

无需发包即可共享代码。 这是最大的好处。你在 packages/ui 写了一个 React 组件库。你的 Web 应用、管理后台和文档站点全都直接导入。修改一个 Button 组件,所有应用立刻生效。不用改版本号,不用 npm publish,不用纠结"生产环境到底用的哪个版本?"

typescript
// 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 服务,任何有 devstart 命令的。packages/ 放库——存在的意义就是被应用或其他包消费的代码。

规则很简单:应用消费包。包永远不从应用导入。包可以从其他包导入。

如果一个包开始从应用导入东西,说明依赖图方向反了,Turborepo 会把它当作循环依赖报错。

工作区配置#

根目录的 pnpm-workspace.yaml 告诉 pnpm 去哪找包:

yaml
packages:
  - "apps/*"
  - "packages/*"

整个文件就这些。pnpm 扫描这些目录,读取每个 package.json,然后创建一个工作区映射。当 apps/web 声明 "@acme/ui": "workspace:*" 作为依赖时,pnpm 会把它链接到本地的 packages/ui,而不是去 npm 仓库查找。

根目录 package.json#

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 语句中一眼就能看出用的是内部代码还是外部代码。

typescript
// 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 文件定义了你的任务流水线——有哪些任务、它们依赖什么、它们产出什么。

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 解析这个图:

  1. 构建 @acme/types(无依赖)
  2. 构建 @acme/utils(无依赖)
  3. 构建 @acme/ui(依赖 types 和 utils——等它们完成)
  4. 构建 apps/web(依赖 ui——等它完成)

第 1 步和第 2 步并行执行。第 3 步等前两步完成。第 4 步等第 3 步。Turborepo 根据你 package.json 中的依赖声明自动推断这个顺序,你不需要手动指定。

outputs#

这对缓存至关重要。Turborepo 缓存一次构建时,会存储 outputs 中列出的文件。命中缓存时,它恢复这些文件。如果你漏掉了某个输出目录,缓存会"成功"但文件不会出现。这是我第一周犯的最大错误——漏掉了 .next/**,怎么也想不通为什么 Next.js 应用总在重新构建。

json
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]

!.next/cache/** 排除规则很重要。Next.js 在 .next/cache/ 里有自己的缓存。你不想缓存套缓存——那只会撑大远程缓存的存储而没有任何好处。

inputs#

默认情况下,Turborepo 会哈希一个包中的每个文件来判断它是否变化了。inputs 字段可以缩小这个范围。如果只有 src/ 中的文件影响构建产物,那改 README.md 不应该让缓存失效。

json
"inputs": ["src/**", "package.json", "tsconfig.json"]

这里要小心。如果你加了一个影响构建的配置文件(比如 postcss.config.js)但没加到 inputs 中,你就会得到过期的缓存产物。拿不准的时候,别设置 inputs,让 Turborepo 哈希所有文件。

globalDependencies#

这里列出的文件一旦变更,会让所有包的缓存失效。环境变量文件是最典型的例子——如果你的 .env.local 变了,所有可能读 process.env 的东西都需要重新构建。

json
"globalDependencies": ["**/.env.*local"]

我还会在这里加上根目录的 tsconfig.json,因为基础 TypeScript 配置影响所有包:

json
"globalDependencies": [
  "**/.env.*local",
  "tsconfig.json"
]

persistentdev#

dev 任务设置了 "cache": false"persistent": true。缓存一个开发服务器没有意义——它是一个常驻进程。persistent 标志告诉 Turborepo 这个任务不会退出,所以不要等它完成再运行其他任务。

当你运行 turbo dev 时,Turborepo 会并行启动所有开发服务器——你的 Next.js 应用、API 服务器、文档站点——全在一个终端里交错输出。每行都带包名前缀,方便区分。

共享包详解#

packages/ui — 组件库#

这是每个团队最先构建的包。跨所有前端应用共享的 React 组件。

json
{
  "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 字段 — 这是定义包入口点的现代方式。它替代了 mainmoduletypes 字段。"." export 是默认导入路径。你可以加子路径导出实现细粒度导入:

json
{
  "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 术语中叫"内部包",更简洁:

json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts"
  }
}

没有构建步骤。没有 dist 目录。消费端应用的 next.config.ts 需要 transpilePackages

typescript
// apps/web/next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@acme/ui", "@acme/utils"],
};
 
export default nextConfig;

我的大多数内部包都用无构建的方式。开发时更快,少一个出问题的环节。唯一的例外是需要支持非 TypeScript 消费端或有复杂构建需求的包。

packages/utils — 共享工具函数#

json
{
  "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"
  }
}

子路径导出让消费端应用按需导入:

typescript
// 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 中的桶导出重新导出所有内容:

typescript
// 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 类型#

json
{
  "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 类型和接口。永远不需要构建,因为类型在编译时被擦除。

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;
}
typescript
// 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 配置是最容易出问题的地方。

json
{
  "name": "@acme/config-typescript",
  "version": "0.0.0",
  "private": true,
  "files": [
    "base.json",
    "nextjs.json",
    "node.json",
    "react-library.json"
  ]
}

所有包都继承的基础配置:

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 专用配置:

json
// 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 后端配置:

json
// 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"
  }
}

然后每个应用或包继承对应的预设:

json
// 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"]
}
json
// apps/api/tsconfig.json
{
  "extends": "@acme/config-typescript/node",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}
json
// 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 中的类型检查。

json
// 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 的 --filterdependsOn 已经处理了构建顺序。我只在 IDE 性能成为问题时(通常是 15+ 个包)才加 composite: true

packages/config-eslint — 共享 Lint 配置#

javascript
// 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" }
      ],
    },
  }
);
javascript
// 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",
    },
  },
];

每个应用引用它的配置:

javascript
// 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 维护着免费的缓存基础设施(有限额):

bash
# Login to Vercel (one-time setup)
npx turbo login
 
# Link your repo to a Vercel project
npx turbo link

搞定。Turborepo 现在会从 Vercel 的服务器推送和拉取缓存产物。在 CI 上,你设置环境变量:

yaml
# .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
bash
# 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 使用它:

json
// .turbo/config.json
{
  "teamId": "team_acme",
  "apiUrl": "https://turbo-cache.internal.acme.com"
}

缓存命中率#

根据我的经验,配置得当的 Turborepo 在 CI 中能达到 80-95% 的缓存命中率。这意味着任何给定的 CI 流水线中只有 5-20% 的任务真正执行。其余的几秒钟内从缓存恢复。

提高缓存命中率的要点:

  1. 收窄 inputs 如果只有 src/ 影响构建产物,别让 README 的改动使缓存失效。
  2. 不要把易变数据放到 globalDependencies 中。 一个每次部署都变的 .env 文件会杀死你的缓存。
  3. 锁定依赖版本。 lockfile 变更会使所有下游缓存失效。
  4. 保持包小而专注。 一个有 200 个文件的巨型 packages/utils 会频繁地缓存失效,因为任何工具函数的改动都会打破整个包的缓存。

CI/CD 与 Turborepo#

这是我用的 GitHub Actions 工作流。经过几十次迭代打磨。

yaml
# .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 至关重要:

bash
# 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,你大概不想构建所有东西。你想只构建受变更影响的部分:

yaml
# 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 策略:

yaml
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/**

json
// turbo.json
"build": {
  "outputs": ["dist/**"]  // but your package builds to build/
}

Turborepo 缓存了这个任务(因为它成功运行了),但没有存储任何输出文件。下次命中缓存时恢复出来的是空的。消费端应用找不到构建产物而失败,或者更糟——默默使用过期的文件。

解决方案: 审计每个包的构建输出目录,确保 turbo.json 覆盖了所有:

json
"build": {
  "outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}

或者在 turbo.json 中使用按包覆盖:

json
{
  "tasks": {
    "build": {
      "outputs": ["dist/**"]
    }
  },
  // Per-package overrides
  "packages": {
    "apps/web": {
      "build": {
        "outputs": [".next/**", "!.next/cache/**"]
      }
    },
    "apps/api": {
      "build": {
        "outputs": ["build/**"]
      }
    }
  }
}

3. 开发服务器协调#

运行 turbo dev 会同时启动所有开发服务器。在它们尝试用同一个端口之前,一切都没问题。必须显式分配端口:

json
// 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 模式运行:

json
// 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.jsonoverrides 字段可以强制所有地方用同一版本:

json
// 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/utilsprocess.env.API_URL,它就和消费端应用的环境产生了一个看不见的耦合。应该显式传入配置:

typescript
// 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}`),
  };
}

消费端应用提供配置:

typescript
// 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 应用和三个共享包)所需的全部文件。

根目录#

json
// 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"
    }
  }
}
yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
json
// 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
    }
  }
}
json
// tsconfig.json (root — for IDE only, not used by builds)
{
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}

apps/web#

json
// 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"
  }
}
typescript
// apps/web/next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@acme/ui", "@acme/utils"],
  reactStrictMode: true,
};
 
export default nextConfig;
json
// 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#

json
// 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#

json
// 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"
  }
}
typescript
// 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";
typescript
// 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#

json
// 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"
  }
}
typescript
// 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));
}
typescript
// 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);
}
typescript
// 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);
}
typescript
// packages/utils/src/index.ts
export { cn } from "./cn";
export { formatRelativeDate, formatDate } from "./date";
export { slugify, truncate, capitalize } from "./string";

packages/types#

json
// 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

从零开始#

如果你要从头开始,这是精确的命令序列:

bash
# 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 的增长,一些模式会自然浮现。

包生成器#

当你创建了第十个包之后,靠复制粘贴再修改就很烦了。写个脚本:

typescript
// 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");
bash
npx tsx scripts/create-package.ts email
# Creates packages/email with all the boilerplate

工作区依赖图可视化#

Turborepo 有一个内置的图命令:

bash
pnpm turbo build --graph=graph.svg

这会生成你依赖图的 SVG。我在大型重构前会跑一下,了解变更的影响范围。如果改 @acme/types 会导致 12 个包重新构建,也许该把它拆成 @acme/types-user@acme/types-billing 了。

Docker 裁剪#

从 monorepo 部署单个应用时,你不想把整个仓库复制到 Docker 镜像里。Turborepo 有个 prune 命令:

bash
pnpm turbo prune --scope=apps/web --docker

这会生成一个 out/ 目录,只包含 apps/web 及其依赖所需的文件。你的 Dockerfile 用这个裁剪后的产物:

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 不断增长,镜像大小也能保持合理。

一个月后的回顾:如果重来我会怎么做#

如果让我重来一次,带着现在的认知:

  1. 从内部包开始(不加构建步骤)。 我浪费了两周时间为只有自己 Next.js 应用消费的包配置 tsup 构建。Next.js 配置中的 transpilePackages 更简单更快。只在确实需要时才加构建步骤。

  2. 初期保持 packages/ 数量少。 我早期拆得太激进了。packages/utilspackages/typespackages/ui 足够起步。等某个包变得臃肿了再拆。过早拆分意味着更多 package.json 要维护,依赖图也更复杂。

  3. 第一天就配好远程缓存。 npx turbo login && npx turbo link 只需 30 秒。第一周省下的构建时间就足以证明值得。

  4. 把工作区命令写进文档。 新人不知道 pnpm --filter @acme/ui add lodash 是装到特定包里,也不知道 pnpm turbo build --filter=apps/web... 只构建必要的部分。在贡献指南中加一个简单的"Monorepo 速查表"能省每个人的时间。

  5. 从第一天就强制依赖方向。 如果你允许哪怕一个从应用到包的导入,边界会迅速腐蚀。加一条 lint 规则或 CI 检查。方向永远是 apps → packages → packages。绝不逆向。

Monorepo 不是目的。目的是能顺利交付功能,不跟自己的代码库打架。Turborepo 是我找到的最轻量的工具,能把这件事做好。它处理任务图,它处理缓存,其他事情它都不干预。

从简单开始。仓库需要时再加复杂度。还有,记得固定端口号。

相关文章