TypeScript和Turborepo构建Monorepo实战

摘要:一个仓库。多个应用。零混乱。这就是高级工程师真正使用的架构。你有一个 Next.js 应用、一个通用组件库、一个 REST API,以及一个 design token 包,全部分别位于不同的仓库中。

一个仓库。多个应用。零混乱。这就是高级工程师真正使用的架构。

你有一个 Next.js 应用、一个通用组件库、一个 REST API,以及一个 design token 包——全部分别位于不同的仓库中。因此,每次对共享按钮组件的修改都意味着 4 个 PR、3 条崩溃的 CI 流水线,以及一个非常愤怒的团队。其实有更好的方式。

Monorepo 并不新鲜。十多年来,Google 和 Meta 都在单一仓库中运行其整个产品代码库。对我们其他人来说,这似乎是新的——其中大多数技术已经存在一段时间,但工具链终于跟上了。结合 Turborepo 中的 TypeScript project references 和 pnpm workspaces,你可以获得真正可用于生产级别的 monorepo:快速构建、共享包、增量缓存,以及不会让你想去种草的 DX。

这是我第一次搭建时希望拥有的一份简短指南。让我们从零开始构建。

指标效果
CI 时间下降85%(使用 Turborepo 远程缓存后)
依赖管理单一版本,多应用共享
包管理pnpm 原生 monorepo 支持——workspace 协议

为什么选择 Turborepo 而不是 Nx 或 Lerna?

答案实际上取决于团队规模。

Nx 非常强大,但带有大量主观约束——它是一个完整的框架,而不仅仅是构建编排工具。Lerna 主要是一个包发布工具,是仓库中各个包之间的协调者。

Turborepo 处于中间位置:一个专注于任务运行和缓存的构建系统,同时在其他方面不过多干涉。

  • 本地 + 远程缓存与增量构建。Turborepo 通过对所有输入(源文件、环境变量、依赖等)进行哈希计算来判断是否需要重建,只有当某个包使用的输入发生修改时才会重新构建。在已预热的 CI 缓存中,这非常高效。

  • 基于依赖的任务编排。你声明哪个任务依赖哪个。turbo build 会在构建你的 Next.js 应用之前先构建 @repo/ui 包——在可能的情况下并行执行,自动完成,顺序正确。

  • 零锁定。Turborepo 只是构建在包管理器 workspace 之上的一层薄封装。移除它,一切仍然可以正常运行——你的 package.json scripts 仍会执行,只是没有缓存。这是一个良好的约束。


起作用的文件夹结构

在编写任何配置之前,先确定结构。社区形成的一种约定——也是 Turborepo 官方 starter 所采用的方式——是将 apps(可部署的内容)与 packages(内部库)分离:

my-monorepo/
├── apps/
│   ├── web/          ← Next.js 15 前端
│   ├── docs/         ← Next.js 文档站点
│   └── api/          ← Node/Express 或 tRPC API
├── packages/
│   ├── ui/           ← 共享 React 组件
│   ├── tsconfig/     ← 基础 TypeScript 配置
│   ├── eslint-config/← 共享 ESLint 规则
│   └── utils/        ← 共享工具函数、校验器
├── turbo.json
├── pnpm-workspace.yaml
└── package.json

重要规则:apps/ 中的内容不得被另一个 app 引用。如果两个应用都需要某段代码,那么它应该放在 packages/ 中。


连接配置:turbo.json + TypeScript References

下面是一个真实的 turbo.json,展示了具有正确依赖顺序的标准流水线——build、lint、test 和 type-check:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "lint": {},
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    }
  }
}

需要注意的关键是 ^build 语法:插入符号表示“首先在该任务所依赖的所有包中运行 build”。因此,当你在 Next.js 中运行 turbo build 时,由于我们使用的是 monorepo,当命令在 js 应用的根目录执行时,它会自动先构建 @repo/ui 和 @repo/utils。无需手动排序,也不会陷入循环依赖地狱。

现在,将其与可复用包中的 TypeScript project references 结合:

{
  "extends": "@repo/tsconfig/react-library.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "composite": true,
    "declaration": true,
    "declarationMap": true
  },
  "include": ["./src"],
  "exclude": ["node_modules", "dist"]
}
💡 Pro Tip
为每个公共包启用 declaration 和 declarationMap。这可以为消费应用提供准确的类型信息,并在无需运行完整构建的情况下,实现 VS Code 跨包跳转到定义。


正确共享组件库的方式

在 monorepo 中常见的错误是在第一天就过度设计共享 UI 包。先发布一个非常简单的起点,然后逐步演进。下面是一个带有实际 TypeScript 导出的共享组件:

import type { ButtonHTMLAttributes, ReactNode } from 'react';

type Variant = 'primary' | 'ghost' | 'destructive';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: Variant;
  isLoading?: boolean;
  children: ReactNode;
}

export function Button({
  variant = 'primary',
  isLoading = false,
  children,
  className,
  ...rest
}: ButtonProps) {
  return (
    <button
      >={variant}
      disabled={isLoading || rest.disabled}
      className={['btn', className].filter(Boolean).join(' ')}
      {...rest}
    >
      {isLoading ? 'Loading…' : children}
    </button>
  );
}

在你的 Next.js 应用中,它像任何 npm 包一样被消费——从 TypeScript 的角度来看,它就是一个包:

import { Button } from '@repo/ui';

export default function HomePage() {
  return (
    <main>
      <Button variant="primary" onClick={() => console.log('clicked')}>
        Get Started
      </Button>
    </main>
  );
}

Turborepo 支持渐进式采用。这意味着你不必重构整个仓库才能获得缓存收益——只需在一个流水线任务上启用,然后逐步扩展。


远程缓存:改变游戏规则(CI 流水线亦然)

最后一个组成部分——也是让团队真正关心的部分——是 CI 上的远程缓存。没有它,每次流水线运行都会从零开始重新编译所有内容。Turbo 的远程缓存(Vercel 或自托管)允许 CI 跳过自上次执行以来输入未发生变化的任务。

- name: Install dependencies
  run: pnpm install --frozen-lockfile

- name: Build, Lint, Test (with remote cache)
  run: pnpm turbo build lint test typecheck
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM:  ${{ secrets.TURBO_TEAM }}

两个环境变量。这就是远程缓存设置的全部内容。第一次运行会完整构建。之后,如果某个包未发生变化,则为缓存命中——不到一秒。


停止交付复杂性,开始交付功能

Monorepo 中最难的部分并不是工具本身——而是让团队信任它。当开发者第一次运行 pnpm turbo build,看到它因为没有变化而跳过 11 个包,看到整个过程在 4 秒而不是 4 分钟内完成——那一刻,他们就不会再问“我们为什么要这样做”。

从小开始。共享一个 tsconfig 包,一个 UI 组件包,一个 Turborepo 流水线。根据需要添加 packages。架构会随着你演进,而你的 git log 终于会成为一个由真正协作构建产品的人所书写的连贯故事。

原文地址:https://medium.com/@mernstackdevbykevin/monorepos-with-typescript-turborepo-setup-best-practices-aa94296a8dc3

本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

链接: https://shenqiku.cn/article/FLY_13411