Nx/Turborepo 下的 React 与 NestJS 协同:构建支持共享代码(Shared Libs)的全栈 Monorepo 架构

讲座:如何用 Nx/Turborepo 构建一个全栈 React + NestJS 的共享代码地狱(哦不,是天堂)Monorepo

各位好,我是你们今天的讲师。我知道,你们可能刚从另一个讲座——那个教你怎么把所有东西塞进一个名为 main 的文件里的讲座——那里逃出来。你们一脸茫然,眼神中透露出对“单体仓库”的恐惧。

今天,我们要聊的是如何用 NxTurborepo 这种像瑞士军刀一样的工具,构建一个让技术总监看了想哭、让高级工程师看了想跳、让产品经理看了想打人(因为需求变了他们没法快速迭代)的全栈 Monorepo。

别担心,我们不只是要堆砌代码,我们要建立的是一座共享代码的巴别塔,只不过这次我们用 TypeScript 的接口把语言障碍给抹平了。


第一章:为什么你的文件系统已经变成了达利的画作?

让我们先直面现实。想象一下,你的项目里有一个 models/user.ts,另一个叫 types/user.d.ts,第三个叫 interfaces/IUser.ts。更糟糕的是,你在一个项目里定义了一个 id: number,在另一个项目里却定义成了 id: string。当你试图把数据从前端传给后端时,浏览器控制台弹出了一个红得发紫的错误框,仿佛在嘲笑你的构造函数设计。

这就是“缺乏共享代码”的后果。在单体仓库里,共享代码应该是什么?它应该像空气一样,无处不在,又无处不在地保证一致性。

Nx 和 Turborepo 的核心哲学很简单:把所有相关的项目放在同一个仓库里,让它们互相认识,互相依赖,互相“吵架”(报错)。

1.1 Nx:比 npm 更聪明的包管理器

Nx 不仅仅是一个包管理器,它是一个依赖图构建器。在传统的 Monorepo(比如用 Lerna)里,你需要手动维护这种关系。但在 Nx 里,你只要创建文件,Nx 就能“猜”出你的意图。

想象一下,如果你在 apps/web 里引用了一个 libs/shared-ui 里的组件,Nx 会自动检测到这一点。如果你不小心改了那个组件的 API,Nx 会立刻告诉你 apps/web 哪里挂了。这就好比你安装了一个自动报警的保姆,但这个保姆比你的亲生母亲还关心你的代码健康。

1.2 Turborepo:那些不想重新编译一切的人类的朋友

这是 Turborepo 的魔法。当你运行 nx build 时,它不会傻傻地重新编译每一个文件。它首先会检查你的“缓存”。

这就像是你在厨房做饭。你做了一盘红烧肉,吃了,很饱。第二天你又想做红烧肉,如果你不用 Turborepo,你可能又做了一遍,虽然味道一样,但你浪费了时间。有了 Turborepo,它会说:“嘿,红烧肉的配方(代码)没变,食材(依赖)也没变,虽然我做了新菜(其他文件改动了),但红烧肉不用重做!”

这种智能缓存增量构建,能让你在 Monorepo 里的开发体验快得离谱。即使你有 100 个应用,构建时间可能也就几秒钟。


第二章:构建共享代码的基石

在开始之前,我们需要先定义一下我们的“货币”。在 Monorepo 里,这个货币就是共享库

2.1 目录结构:我们要盖什么样的房子?

标准的 Nx/Turborepo 结构通常是这样的:

workspace-root/
├── apps/
│   ├── web/           # React 前端应用
│   └── api/           # NestJS 后端应用
├── libs/
│   ├── shared-types/  # 共享类型定义(绝对核心)
│   ├── shared-utils/  # 工具函数
│   └── shared-ui/     # 共享 React 组件
├── turbo.json         # Turbo 的配置
└── nx.json            # Nx 的配置

2.2 创建共享类型库:TypeScript 的护城河

这是最重要的一步。我们要创建一个纯 TypeScript 库,用来存放我们的接口和类型。它不包含任何运行时代码,只包含静态定义。

让我们在终端里敲点什么来建立这个神殿:

npx nx g @nx/workspace:lib shared-types --directory=libs/shared-types

Nx 会问你选择什么框架。这里我们要选最基础的:

  • Build target: None (或者 TypeScript)
  • Test runner: Jest (或者 Vitest)
  • Publishable: No (我们只在这里定义接口,不出售)

进入 libs/shared-types/src/lib,你会看到默认生成了 index.ts。让我们在里面定义一个通用的 User 接口。

文件:libs/shared-types/src/lib/user.interface.ts

// 这就是我们全栈通用的“契约”
// 告诉前端,后端发回来的用户长什么样;告诉后端,前端要发送什么结构
export interface User {
  id: string; // 这里我们统一用 string,避免前后端 ID 类型不一致的坑
  username: string;
  email: string;
  createdAt: Date;
}

// 我们还可以定义一些枚举
export enum UserRole {
  ADMIN = 'ADMIN',
  USER = 'USER',
  GUEST = 'GUEST'
}

文件:libs/shared-types/src/lib/index.ts

export * from './user.interface';
export * from './user.interface';

现在,在 apps/webapps/api 里,你都可以通过 import { User } from '@my-org/shared-types' 来使用这个类型。


第三章:NestJS 端——Backend 是大脑,Shared Types 是神经

NestJS 是一个非常有结构感的框架,它非常适合和 Nx 配合。我们要利用 NestJS 的 DTO (Data Transfer Objects) 机制,让 shared-types 变成 API 交互的守门员。

3.1 创建 API 应用

npx nx g @nx/nest:app api

3.2 编写 Controller 和 Service

现在,我们要在 api 应用里实现逻辑。注意,我们的 Service 和 Controller 应该依赖 shared-types 里的定义,而不是自己瞎写。

文件:apps/api/src/app.controller.ts

import { Controller, Get } from '@nestjs/common';
// 哦,看看这个优雅的导入,我们不需要在前端和后端之间同步两份接口定义
import { User } from '@my-org/shared-types'; 

@Controller()
export class AppController {
  constructor() {}

  @Get()
  getHello(): User[] {
    // 这里我们返回一些模拟数据
    return [
      {
        id: '1',
        username: 'Sharky',
        email: '[email protected]',
        createdAt: new Date(),
      },
    ];
  }
}

看,多干净。后端不需要知道前端用什么 UI 展示它,它只需要定义好 User 的形状。这就是关注点分离的最高境界。

3.3 增加验证

我们还可以利用 class-validator 来增强这些类型,让它们变成更严格的 DTO。在 NestJS 中,这几乎是强制性的。

首先安装依赖:

nx g @nx/workspace:lib shared-types --directory=libs/shared-types --importPath=@my-org/shared-types --unitTestRunner=jest --buildTarget=package
# 注意:为了方便演示,我们这里用默认配置,实际项目中建议加上 package 目标以便发布

apps/api 中安装 validator:

cd apps/api && npm install class-validator class-transformer

文件:apps/api/src/user.dto.ts

import { IsString, IsEmail, IsDate, IsEnum } from 'class-validator';

// 继承自共享接口,但增加了运行时的校验规则
export class CreateUserDto implements User {
  @IsString()
  username: string;

  @IsEmail()
  email: string;

  @IsDate()
  createdAt: Date;

  @IsEnum(UserRole)
  // 这里我们其实可以重新定义一个只包含部分字段的 DTO,或者完全使用 User
  // 这取决于你的设计哲学,但通常 DTO 只包含前端需要的字段
  id: string; 
}

第四章:React 端——Frontend 是门面,Shared Types 是说明书

现在轮到前端了。React 是声明式的,它喜欢数据。而 shared-types 给了我们声明数据的底气。

4.1 创建 Web 应用

npx nx g @nx/react:app web

4.2 享受类型安全

在 React 组件中,我们定义 Props 或者 State 时,直接引用 shared-types

文件:apps/web/src/app/UserCard.tsx

import React from 'react';
import { User } from '@my-org/shared-types'; // 导入自共享库!

interface UserCardProps {
  user: User; // Props 现在有了类型约束,TypeScript 会帮你检查是否传了 id
}

export const UserCard: React.FC<UserCardProps> = ({ user }) => {
  return (
    <div style={{ border: '1px solid #ccc', padding: '16px', margin: '8px' }}>
      <h3>{user.username}</h3>
      <p>Email: {user.email}</p>
      <p>ID: {user.id}</p>
    </div>
  );
};

export default UserCard;

4.3 连接后端 API

我们要写一个 Hook 来获取用户数据。注意看,我们的 API 调用返回的类型直接就是我们定义的 User[]

文件:apps/web/src/app/hooks/useUsers.ts

import { useState, useEffect } from 'react';
import { User } from '@my-org/shared-types'; // 同一个定义!

const API_URL = 'http://localhost:4200'; // 假设 NestJS 跑在这里

export const useUsers = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch(`${API_URL}/`)
      .then((res) => res.json())
      .then((data) => {
        setUsers(data); // 数据直接匹配!
        setLoading(false);
      })
      .catch((err) => {
        setError('哎呀,网线好像被拔了');
        setLoading(false);
      });
  }, []);

  return { users, loading, error };
};

第五章:让它们跑起来——Turborepo 的魔法时刻

现在,我们有了一个 Monorepo。我们有 React,有 NestJS,有共享类型。但它们还只是在文件系统里各自为政。我们需要让它们互相握手。

5.1 配置 Turbo

Nx 使用 turbo.json 来管理构建流程。这个文件告诉 Turbo 哪些任务依赖于哪些任务。

文件:turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "test": {
      "dependsOn": ["^test"],
      "outputs": ["coverage/**"]
    }
  }
}

5.2 本地缓存与远程缓存

当你第一次运行 nx build 时,Turborepo 会记录下所有的文件哈希值。这是它的缓存键。

假设你修改了 libs/shared-types 里的一个接口定义:

  1. Nx 检测到 shared-types 的构建依赖被触发了。
  2. Nx 重新构建 shared-types
  3. 因为 apps/apiapps/web 都依赖 shared-types,Nx 会触发它们的构建。
  4. 但是!如果 apps/apiapps/web 本身的代码没有变,Turborepo 会检查它们的缓存。如果缓存命中(也就是说,它们的输入没有因为 shared-types 的改变而改变),Turborepo 会直接返回缓存结果。

结果: 构建速度极快,仿佛变魔术。

5.3 运行构建

# 构建所有项目
npx nx run-many -t build

# 或者只构建特定的项目
npx nx build api
npx nx build web

第六章:实战演练——一个“超级英雄”管理系统的诞生

让我们来点更有趣的。我们要做一个“超级英雄”管理系统。我们有共享的类型:Hero

6.1 定义数据

文件:libs/shared-types/src/lib/hero.interface.ts

export interface Hero {
  id: string;
  name: string;
  alterEgo: string;
  superpower: string;
  isAvailable: boolean;
}

6.2 后端逻辑

NestJS 接管这些数据。

文件:apps/api/src/heroes.controller.ts

import { Controller, Get } from '@nestjs/common';
import { Hero } from '@my-org/shared-types';

@Controller('heroes')
export class HeroesController {
  // 模拟数据库
  private heroes: Hero[] = [
    { id: '1', name: 'Iron Man', alterEgo: 'Tony Stark', superpower: 'Repulsors', isAvailable: true },
    { id: '2', name: 'Superman', alterEgo: 'Clark Kent', superpower: 'Flight & Strength', isAvailable: false },
  ];

  @Get()
  findAll(): Hero[] {
    return this.heroes;
  }

  @Get(':id')
  findOne(id: string): Hero {
    return this.heroes.find(h => h.id === id);
  }
}

6.3 前端展示

React 接管这些数据,并让它们变得好看。

文件:apps/web/src/app/heroes/HeroCard.tsx

import React from 'react';
import { Hero } from '@my-org/shared-types';

interface HeroCardProps {
  hero: Hero;
}

export const HeroCard: React.FC<HeroCardProps> = ({ hero }) => {
  return (
    <div className="hero-card" style={{
      backgroundColor: hero.isAvailable ? '#e0f7fa' : '#ffebee',
      padding: '20px',
      borderRadius: '8px',
      margin: '10px',
      border: '1px solid #ddd'
    }}>
      <h2>{hero.name} ({hero.alterEgo})</h2>
      <p>超能力: {hero.superpower}</p>
      <button disabled={!hero.isAvailable}>
        {hero.isAvailable ? '加入战斗' : '正在休息'}
      </button>
    </div>
  );
};

6.4 数据流

  1. 用户打开 http://localhost:4200
  2. useHeroes Hook 发起请求到 http://localhost:3333/heroes
  3. NestJS Controller 返回 Hero[]
  4. React 渲染 HeroCard
  5. 关键点: 在整个链条中,Hero 类型在 TypeScript 中只存在一份定义。如果后端把 alterEgo 改成了 civilianName,TypeScript 会在前端编译时直接报错。你不需要手动去前端同步修改。

第七章:进阶技巧——Workspace 实时生成

Nx 还有一个非常牛逼的功能:自动导入

在 React 应用中,你通常需要手动 import { HeroCard } from './HeroCard'。但在 Nx 生成的项目中,你可以在 apps/web/src/app/heroes/index.ts 中定义一个 barrel export(桶导出),然后直接在组件里使用组件名,而不需要写 import 语句。

文件:apps/web/src/app/heroes/index.ts

export * from './HeroCard';
// Nx 会自动帮你 resolve 这个路径

文件:apps/web/src/app/heroes/page.tsx

import { useHeroes } from './hooks/useHeroes';

export default function HeroesPage() {
  const { users, loading } = useHeroes(); // 这里的 users 其实是 heroes
  // ... render logic
}

这叫什么?这叫元编程。Nx 在构建时知道文件在哪里,所以它甚至帮你想好了 import 语句。这就像你的编辑器是个读心术大师。


第八章:处理冲突与依赖地狱

Monorepo 的诅咒:两个团队同时修改同一个文件。或者更糟糕,一个团队修改了 shared-types,导致另一个团队的构建失败。

Nx 如何解决?

  1. 严格的构建顺序:turbo.json 中,我们设置了 "dependsOn": ["^build"]。这意味着 web 应用在构建前,必须先确保 shared-typesapi 都已经构建完成。这防止了在旧代码上运行新代码。
  2. Monorepo 锁文件: Nx 使用 nx.json 来管理构建系统。建议在 CI/CD 流程中,先运行 npx nx format-check 检查代码风格,再运行 npx nx affected --target=build 只构建改变的部分。

8.1 常见错误:忘记更新缓存

有时候你会遇到这种怪事:你改了代码,但构建没变。为什么?

因为你改的文件可能没有被 turbo.json 里的 pipeline 识别为构建输入。

文件:turbo.json 的修正:

"pipeline": {
  "build": {
    "dependsOn": ["^build"],
    "outputs": [".next/**", "dist/**"],
    // 确保所有源文件都被纳入监控
    "cache": true
  }
}

记住,Turborepo 是基于文件内容的哈希来缓存结果的。如果你改了一个文件的格式(比如把单引号改成双引号),只要文件内容变了,缓存就会失效,它必须重新构建。这是正确的。


第九章:团队协作与多应用管理

当你有 10 个人在同一个仓库工作时,apps 目录会变得很拥挤。

Nx 支持按工作区隔离。你可以把 apps/webapps/admin 放在同一个仓库,但在 Git 分支管理上,你可以让 UI 团队只关注 apps/web,让后端团队只关注 apps/api

如何做到?

利用 Nx 的 Project Graph 功能。你可以运行 npx nx graph,它会生成一个可视化的 HTML 文件,显示所有应用之间的依赖关系。

npx nx graph

打开这个 HTML,你会看到一条线从 shared-types 连接到 api,再连到 web。如果你删除了 shared-types,Nx 会警告你:web 应用正在失去它的“衣服”(类型定义)。


第十章:尾声与冷知识

在结束之前,我想分享几个关于 Nx/Turborepo 的“黑魔法”冷知识。

  1. 极速开发模式: Nx 还有一个 dev 任务。如果你在开发 NestJS 时修改了代码,它会自动重启。如果你在 React 开发时修改了代码,它会热更新。这已经是标配了,但别忘了是 Nx 的脚手架帮我们实现的。
  2. 单元测试: 在共享库中写单元测试是极其爽的。你可以测试你的接口定义,或者测试纯函数。因为在 Monorepo 里,测试也是隔离的。你可以轻松地为 shared-types 写测试(虽然通常是类型推导测试,但在 Nx 里这很容易)。
  3. 微前端集成: Nx 原生支持微前端。你可以把 shared-ui 库发布为一个 npm 包,或者作为 remote module 挂载到主应用上。这意味着你的单体仓库最终可以演变成一个微前端架构,而这一切都是从一个 Monorepo 开始的。

总结(正经点)

我们今天没有谈论那些枯燥的架构图,我们谈论的是协作。Monorepo 的核心不在于“在一个文件夹里”,而在于共享

Nx/Turborepo 解决了 Monorepo 的两个最大痛点:

  1. 构建慢: 通过 Turborepo 的智能缓存。
  2. 依赖混乱: 通过 Nx 的依赖图和严格的构建顺序。

通过将 React 和 NestJS 放在同一个生态系统中,利用 shared-types 作为桥梁,我们消除了前端和后端之间“翻译”的成本。代码不再是两套独立的语言,而是一个统一的知识库。

所以,下次当你看到你的同事在改 API 接口而前端还在报错时,不要生气。只需要打开你的终端,输入 npx nx format-check,然后微笑着说:“你看,Nx 已经帮我自动同步了类型。”

或者更简单点,直接告诉他们:“TypeScript 会替我骂你的。”

谢谢大家,现在,去把你的单体仓库拆了吧。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注