讲座:如何用 Nx/Turborepo 构建一个全栈 React + NestJS 的共享代码地狱(哦不,是天堂)Monorepo
各位好,我是你们今天的讲师。我知道,你们可能刚从另一个讲座——那个教你怎么把所有东西塞进一个名为 main 的文件里的讲座——那里逃出来。你们一脸茫然,眼神中透露出对“单体仓库”的恐惧。
今天,我们要聊的是如何用 Nx 和 Turborepo 这种像瑞士军刀一样的工具,构建一个让技术总监看了想哭、让高级工程师看了想跳、让产品经理看了想打人(因为需求变了他们没法快速迭代)的全栈 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/web 和 apps/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 里的一个接口定义:
- Nx 检测到
shared-types的构建依赖被触发了。 - Nx 重新构建
shared-types。 - 因为
apps/api和apps/web都依赖shared-types,Nx 会触发它们的构建。 - 但是!如果
apps/api和apps/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 数据流
- 用户打开
http://localhost:4200。 useHeroesHook 发起请求到http://localhost:3333/heroes。- NestJS Controller 返回
Hero[]。 - React 渲染
HeroCard。 - 关键点: 在整个链条中,
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 如何解决?
- 严格的构建顺序: 在
turbo.json中,我们设置了"dependsOn": ["^build"]。这意味着web应用在构建前,必须先确保shared-types和api都已经构建完成。这防止了在旧代码上运行新代码。 - 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/web 和 apps/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 的“黑魔法”冷知识。
- 极速开发模式: Nx 还有一个
dev任务。如果你在开发 NestJS 时修改了代码,它会自动重启。如果你在 React 开发时修改了代码,它会热更新。这已经是标配了,但别忘了是 Nx 的脚手架帮我们实现的。 - 单元测试: 在共享库中写单元测试是极其爽的。你可以测试你的接口定义,或者测试纯函数。因为在 Monorepo 里,测试也是隔离的。你可以轻松地为
shared-types写测试(虽然通常是类型推导测试,但在 Nx 里这很容易)。 - 微前端集成: Nx 原生支持微前端。你可以把
shared-ui库发布为一个 npm 包,或者作为 remote module 挂载到主应用上。这意味着你的单体仓库最终可以演变成一个微前端架构,而这一切都是从一个 Monorepo 开始的。
总结(正经点)
我们今天没有谈论那些枯燥的架构图,我们谈论的是协作。Monorepo 的核心不在于“在一个文件夹里”,而在于共享。
Nx/Turborepo 解决了 Monorepo 的两个最大痛点:
- 构建慢: 通过 Turborepo 的智能缓存。
- 依赖混乱: 通过 Nx 的依赖图和严格的构建顺序。
通过将 React 和 NestJS 放在同一个生态系统中,利用 shared-types 作为桥梁,我们消除了前端和后端之间“翻译”的成本。代码不再是两套独立的语言,而是一个统一的知识库。
所以,下次当你看到你的同事在改 API 接口而前端还在报错时,不要生气。只需要打开你的终端,输入 npx nx format-check,然后微笑着说:“你看,Nx 已经帮我自动同步了类型。”
或者更简单点,直接告诉他们:“TypeScript 会替我骂你的。”
谢谢大家,现在,去把你的单体仓库拆了吧。