NestJS+React+Zustand 全栈开发学习笔记
本文基于实际项目代码片段,系统梳理全栈开发中涉及的核心知识点,包括 React 钩子使用规则与 Zustand 状态管理技巧、DTO 数据传输对象的设计与校验、Prisma 客户端的集成与使用,以及 NestJS 项目的核心配置与模块设计。通过对这些知识点的拆解与延伸,深入理解全栈开发中的数据流转、状态管理及项目架构设计逻辑,为后续开发奠定坚实基础。
一、React 钩子使用限制与 Zustand 状态管理进阶
1.1 React 钩子的执行环境限制核心原理
React 自定义钩子(如 Zustand 提供的 useUserStore())遵循严格的使用规则,这一规则的本质是为了保证 React 组件渲染流程的稳定性和可预测性。React 钩子的设计依赖于组件渲染时的调用顺序,因此官方明确规定:钩子只能在 React 组件或自定义钩子的顶层执行,严禁在普通函数、条件语句(if/else)、循环语句(for)、异步代码(如 async/await、setTimeout 回调)中调用。
这一限制的核心原因的是 React 内部通过链表结构跟踪钩子的调用顺序,若在非顶层环境调用钩子,会导致链表结构混乱,进而引发组件状态更新异常、渲染错误等问题。在实际开发中,最容易踩坑的场景之一就是在脱离 React 组件渲染流程的普通 JavaScript 函数中调用钩子,axios 拦截器就是典型案例。
1.2 axios 拦截器中使用钩子的报错原因与解决方案
axios 拦截器是纯普通 JavaScript 函数,其执行完全脱离 React 组件的渲染生命周期,不属于 React 环境。若在拦截器中直接调用 Zustand 的 useUserStore() 钩子获取 Token,代码如下:
// 错误示例:在axios拦截器中调用React钩子
axios.interceptors.request.use(config => {
const { token } = useUserStore(); // 直接触发React报错
config.headers.Authorization = `Bearer ${token}`;
return config;
});
上述代码会立即触发 React 报错,报错信息通常为“Invalid hook call. Hooks can only be called inside of the body of a function component.”,本质是违反了 React 钩子的使用规则。
针对这一问题,Zustand 提供了专门的解决方案——useUserStore.getState() 方法,该方法是 Zustand 为非 React 环境设计的同步状态获取 API,完美适配 axios 拦截器、工具函数、原生 JavaScript 代码等场景。
1.3 useUserStore.getState() 的核心特性与使用场景
useUserStore.getState() 并非 React 钩子,因此不受 React 环境限制,其核心特性可总结为三点,这也是它能在 axios 拦截器中正常使用的关键:
- 无环境限制:不属于 React 钩子,可在任意普通函数、拦截器、异步代码、工具函数中自由调用,打破了 React 钩子的使用边界。
- 实时获取最新状态:每次调用都会获取当前状态的最新快照。在 Token 刷新、用户重新登录等场景中,新的 Token 值会实时同步到 Zustand 状态中,axios 每次发起请求时执行拦截器,调用该方法就能拿到最新的 Token,避免因状态同步不及时导致的请求失败。
- 同步执行无阻塞:该方法为同步执行,无需异步等待(无 Promise 包裹),不会影响 axios 拦截器的执行流程,保证请求拦截器能快速处理请求配置并发起请求。
正确的使用方式如下:
// 正确示例:使用getState()在拦截器中获取Token
axios.interceptors.request.use(config => {
const { token } = useUserStore.getState(); // 无React环境限制
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
补充说明:若需要在非 React 环境中修改 Zustand 状态,可使用 useUserStore.setState() 方法,该方法同样为同步执行,用法与 getState() 一致,适用于工具函数中更新全局状态的场景。
二、DTO 数据传输对象:规范化前后端数据交互
2.1 DTO 的核心概念与设计意义
DTO(Data Transfer Object,数据传输对象)是一种用于规范前后端之间、后端各层之间数据传输格式的对象,其核心作用是定义数据结构、约束数据类型、统一数据校验规则,避免因数据格式不统一导致的业务异常。
在全栈开发中,数据流转流程通常为:前端 → 后端接口 → 控制器(Controller)→ 服务层(Service),DTO 贯穿整个流转过程,承担着“数据契约”的角色。具体意义体现在三点:
- 统一数据格式:明确前后端交互的数据字段、类型、可选/必选属性,减少沟通成本,避免前端传递非法字段或缺失必要字段。
- 简化数据校验:将参数校验逻辑集中在 DTO 中,通过装饰器实现自动化校验,替代手动编写大量 if-else 校验代码,提升开发效率。
- 解耦业务逻辑:DTO 仅负责数据传输与校验,不包含业务逻辑,使控制器、服务层专注于核心业务,符合单一职责原则。
在 NestJS 项目中,DTO 通常以 TypeScript 类的形式存在,结合 class-validator 和 class-transformer 两个库实现数据校验与类型转换。
2.2 依赖库安装与核心作用
实现 DTO 的数据校验与类型转换,需依赖两个核心库,通过 pnpm 安装:
# 安装数据校验库
pnpm i class-validator
# 安装数据转换库
pnpm i class-transformer
两个库的核心作用分工明确,相辅相成:
2.2.1 class-validator:数据校验核心库
class-validator 提供了一系列装饰器(如 @IsInt()、@Min()、@IsOptional() 等),用于在 DTO 类中定义字段的校验规则,支持整数、字符串、邮箱、日期、自定义正则等多种校验场景。其核心优势是将校验逻辑通过装饰器固化到 DTO 中,实现校验流程的自动化、规范化,无需手动编写校验代码。
2.2.2 class-transformer:数据类型转换库
前端传递给后端的数据通常为 JSON 格式,部分字段类型可能与后端定义不一致(如前端传递的数字为字符串类型 “10”,后端需要 Number 类型 10)。class-transformer 提供的 @Type() 装饰器可实现自动类型转换,同时支持将普通对象转换为 DTO 类实例,便于后续校验与业务处理。
2.3 DTO 类的编写规范与示例
在 NestJS 项目中,DTO 文件通常放在对应模块的 dto 目录下,命名格式遵循“业务场景+DTO 类型.ts”,如 post-query.dto.ts(帖子列表查询 DTO)、post-new.dto.ts(新增帖子 DTO)。以下以 PostQueryDto 为例,详解 DTO 类的编写规范:
import {
IsOptional,
IsInt,
Min
} from 'class-validator';
import { Type } from 'class-transformer';
// 帖子列表查询DTO
export class PostQueryDto {
@IsOptional() // 标记该字段为可选参数
@Type(() => Number) // 将前端传递的参数转换为Number类型(解决"1"→1的问题)
@IsInt() // 校验字段必须为整数
@Min(1) // 校验字段最小值为1,避免传递0或负数
page?: number = 1; // 默认值为1
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 10; // 默认值为10,控制每页显示数量
}
上述代码中,每个装饰器的作用与使用场景如下:
- @IsOptional():标记字段为可选参数,若前端不传递该字段,后端将使用默认值,不会触发校验错误。
- @Type(() => Number):强制将前端传递的参数转换为 Number 类型。由于前端通过 URL 参数传递的数据默认是字符串类型,即使传递的是数字,也会被解析为字符串,该装饰器可解决类型不匹配问题。
- @IsInt():校验字段必须为整数,若前端传递小数(如 1.5)或非数字(如 “abc”),将触发校验失败。
- @Min(1):校验字段的最小值为 1,防止前端传递 0、负数等非法值,避免分页逻辑出现异常。
若需要编写新增帖子的 PostNewDto,需包含标题、内容、作者等必选字段,示例如下:
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
export class PostNewDto {
@IsString() // 校验字段为字符串类型
@IsNotEmpty() // 校验字段不为空(非空字符串)
@MaxLength(100) // 校验字符串最大长度为100,避免标题过长
title: string;
@IsString()
@IsNotEmpty()
content: string;
@IsString()
@IsNotEmpty()
authorId: string;
}
2.4 NestJS 全局校验管道配置
编写完 DTO 类后,需在 NestJS 中配置全局校验管道(ValidationPipe),才能使 DTO 的校验规则生效。ValidationPipe 是 NestJS 内置的管道,可实现全局范围内的自动数据校验与类型转换,无需在每个控制器中单独配置。
配置代码通常写在项目入口文件(main.ts)中,结合 NestExpressApplication 实现跨域、全局路由前缀等配置,完整代码如下:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common'; // 引入全局校验管道
async function bootstrap() {
// 创建NestExpressApplication实例,支持Express的所有特性
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: true, // 开启跨域,解决前端(如5173端口)与后端(3000端口)的跨域问题
});
app.setGlobalPrefix('api'); // 设置全局路由前缀为/api,所有接口路径均以/api开头
// 配置全局校验管道
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // 自动过滤DTO中未定义的字段,避免前端传递冗余字段
forbidNonWhitelisted: true, // 若前端传递DTO中未定义的字段,直接抛出异常
transform: true, // 自动将请求参数转换为DTO类实例,同时支持类型转换(依赖class-transformer)
}));
// 监听端口,优先使用环境变量PORT,默认3000
await app.listen(process.env.PORT ?? 3000);
}
上述配置中,ValidationPipe 的三个核心参数作用至关重要:
- whitelist: true:开启白名单模式,自动过滤请求中不属于 DTO 定义的字段。例如,前端传递了 “createTime” 字段,但 PostQueryDto 中未定义该字段,该字段会被自动过滤,不会传递到控制器中,避免冗余数据干扰业务逻辑。
- forbidNonWhitelisted: true:严格模式,若前端传递了 DTO 中未定义的字段,直接抛出 400 Bad Request 异常,并返回详细的错误信息,提示前端传递了非法字段,便于快速定位问题。
- transform: true:开启自动转换功能,一方面将请求参数转换为 DTO 类的实例,使控制器中能直接使用 DTO 实例的方法与属性;另一方面结合 class-transformer 的 @Type() 装饰器,实现参数类型的自动转换(如字符串转数字)。
配置完成后,当前端发起请求时,NestJS 会自动将请求参数与对应的 DTO 校验规则比对,若校验失败,会返回标准化的错误响应,示例如下:
{
"statusCode": 400,
"message": [
"page must be an integer number",
"page must not be less than 1"
],
"error": "Bad Request"
}
2.5 DTO 在控制器中的使用方式
在 NestJS 控制器中,通过 @Query()、@Body()、@Param() 等装饰器将请求参数绑定到 DTO 实例上,实现自动校验与类型转换。以 PostsController 为例,使用 PostQueryDto 接收分页查询参数:
import {
Controller,
Get,
Query,
} from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostQueryDto } from './dto/post-query.dto';
// 控制器路由前缀为/posts,结合全局前缀/api,完整路径为/api/posts
@Controller('posts')
export class PostsController {
// 依赖注入PostsService,实现业务逻辑解耦
constructor(private readonly postsService: PostsService) {}
// 处理GET请求,使用@Query()绑定PostQueryDto
@Get()
async getPosts(@Query() query: PostQueryDto) {
// query已被自动校验和类型转换,可直接传递给服务层
return this.postsService.findAll(query);
}
}
此时,前端发起的请求路径为 /api/posts?page=2&limit=20,NestJS 会自动将 page 和 limit 参数转换为 PostQueryDto 实例,若参数不符合校验规则(如 page=0),会直接返回校验错误,无需在控制器中编写任何校验代码。
三、Prisma Client:高效集成数据库交互
3.1 Prisma 概述与核心优势
Prisma 是一套现代化的数据库工具集,包含 Prisma Schema、Prisma Client、Prisma Migrate 三大核心组件,其中 Prisma Client 是自动生成的类型安全数据库客户端,用于在应用程序中与数据库交互。相比传统的 ORM 框架(如 TypeORM、Sequelize),Prisma Client 具有以下优势:
- 类型安全:基于 Prisma Schema 自动生成 TypeScript 类型,避免手动定义数据模型类型,减少类型错误。
- 简洁的 API:提供直观的链式调用 API,替代复杂的 SQL 语句或 ORM 配置,提升数据库操作效率。
- 自动生成:通过
npx prisma generate命令自动生成客户端代码,支持多种数据库(PostgreSQL、MySQL、MongoDB 等)。 - 与 NestJS 无缝集成:可通过全局模块注入 Prisma Client,实现全项目共享数据库连接。
3.2 Prisma Client 的生成与导入
使用 Prisma Client 前,需先通过 Prisma Schema 定义数据模型(通常在 prisma/schema.prisma 文件中),示例如下:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // 数据库类型
url = env("DATABASE_URL") // 数据库连接地址,从环境变量获取
}
// 定义Post数据模型
model Post {
id String @id @default(uuid())
title String
content String
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
定义完数据模型后,执行以下命令生成 Prisma Client 代码:
npx prisma generate
该命令会根据 Prisma Schema 自动生成适配当前数据模型的客户端代码,生成路径为 node_modules/.prisma/client。生成后,可在项目中直接导入 Prisma Client:
import { PrismaClient } from '@prisma/client';
3.3 NestJS 中 Prisma Client 的全局注入配置
在 NestJS 项目中,为避免重复创建 Prisma Client 实例(导致数据库连接泄露),通常将 Prisma Client 封装为服务,并通过全局模块注入,实现全项目共享。具体步骤如下:
3.3.1 创建 PrismaService
创建 prisma.service.ts 文件,封装 Prisma Client 实例,实现数据库连接的管理:
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
// 模块初始化时连接数据库
async onModuleInit() {
await this.$connect();
}
// 可选:模块销毁时关闭数据库连接
async onModuleDestroy() {
await this.$disconnect();
}
}
上述代码中,PrismaService 继承自 PrismaClient,通过实现 OnModuleInit 接口的 onModuleInit() 方法,在模块初始化时自动连接数据库;通过 onModuleDestroy() 方法,在模块销毁时关闭数据库连接,确保资源合理释放。
3.3.2 创建全局 PrismaModule
创建 prisma.module.ts 文件,将 PrismaService 声明为全局模块,使其他模块无需导入即可使用 PrismaService:
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
// @Global()装饰器标记为全局模块
@Global()
@Module({
providers: [PrismaService], // 提供PrismaService实例
exports: [PrismaService], // 导出PrismaService,供其他模块使用
})
export class PrismaModule {}
全局模块只需在根模块(AppModule)中导入一次,即可在所有子模块中通过依赖注入使用 PrismaService,无需重复导入,简化模块配置。
3.3.3 根模块导入 PrismaModule
在 AppModule 中导入 PrismaModule 和业务模块(如 PostsModule),完成整体架构配置:
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PostsModule } from './posts/posts.module'; // 导入帖子业务模块
import { PrismaModule } from './prisma/prisma.module'; // 导入全局PrismaModule
@Module({
imports: [PostsModule, PrismaModule], // 导入模块
controllers: [AppController], // 根控制器
providers: [AppService], // 根服务
})
export class AppModule {}
3.4 Prisma Client 在服务层的使用
在业务服务层(如 PostsService)中,通过依赖注入 PrismaService,使用生成的 Prisma Client 执行数据库操作。以 PostsService 的 findAll 方法为例,实现分页查询帖子列表:
import { Injectable } from "@nestjs/common";
import { PostQueryDto } from "./dto/post-query.dto";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class PostsService {
// 依赖注入PrismaService,获取Prisma Client实例
constructor(private prisma: PrismaService) {}
async findAll(query: PostQueryDto) {
const { page, limit } = query;
// 计算分页偏移量
const skip = (page - 1) * limit;
// 使用Prisma Client查询数据,链式调用API,类型安全
const items = await this.prisma.post.findMany({
skip, // 跳过前面的记录
take: limit, // 获取指定数量的记录
orderBy: {
createdAt: 'desc' // 按创建时间降序排列
}
});
// 查询总记录数,用于分页计算
const total = await this.prisma.post.count();
// 返回分页结果
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit)
};
}
}
上述代码中,this.prisma.post 是 Prisma Client 自动生成的对应 Post 模型的操作对象,提供 findMany、count、create、update、delete 等方法,每个方法都有严格的类型定义,传递参数时会自动进行类型校验,避免数据库操作错误。
补充说明:Prisma Client 支持复杂的查询操作,如关联查询、条件筛选、排序、分页等,通过链式调用即可实现,无需编写原生 SQL,示例如下(关联查询作者信息):
const items = await this.prisma.post.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
author: true // 关联查询作者信息,需在Prisma Schema中定义关联关系
}
});
四、NestJS 项目核心架构与模块设计
4.1 NestJS 项目整体架构
NestJS 采用模块化、分层架构设计,核心分为四层,每层职责明确,实现业务逻辑与技术细节的解耦,整体架构如下:
- 控制器(Controller) :负责接收客户端请求,处理路由映射,参数校验,将请求转发给服务层,返回响应结果。
- 服务层(Service) :负责核心业务逻辑实现,调用数据访问层(Prisma Client)获取或操作数据,不直接与客户端交互。
- 数据访问层(Prisma Client) :负责与数据库交互,封装数据库操作细节,为服务层提供数据支持。
- 模块(Module) :负责组织项目结构,将控制器、服务、依赖等封装为独立单元,实现模块化管理。
结合本文涉及的代码,项目目录结构如下(简化版):
src/
├── app.module.ts // 根模块
├── main.ts // 入口文件
├── prisma/ // Prisma相关文件
│ ├── prisma.service.ts
│ └── prisma.module.ts
└── posts/ // 帖子业务模块
├── posts.controller.ts
├── posts.service.ts
├── posts.module.ts
└── dto/
├── post-query.dto.ts
└── post-new.dto.ts
4.2 模块设计原则与实践
NestJS 模块是组织代码的核心单元,每个模块都应遵循单一职责原则,专注于某一业务领域(如 PostsModule 负责帖子相关业务)。模块的核心组成部分包括 imports、controllers、providers、exports,各部分作用如下:
- imports:导入当前模块依赖的其他模块(如 PostsModule 依赖 PrismaModule,但由于 PrismaModule 是全局模块,无需导入)。
- controllers:声明当前模块包含的控制器,负责处理请求。
- providers:声明当前模块提供的服务,供控制器或其他服务依赖注入。
- exports:导出当前模块的服务或控制器,供其他模块使用(若仅在模块内部使用,无需导出)。
以 PostsModule 为例,其模块定义如下,仅包含自身的控制器和服务,无需导出,因为控制器通过路由对外提供接口,服务仅在模块内部被控制器依赖:
import { Module } from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostsController } from './posts.controller';
@Module({
controllers: [PostsController], // 声明控制器
providers: [PostsService] // 声明服务
})
export class PostsModule {}
4.3 跨域配置与全局路由前缀
在全栈开发中,前端与后端通常运行在不同端口(如前端 Vite 项目运行在 5173 端口,后端 NestJS 运行在 3000 端口),会出现跨域问题。NestJS 可通过在创建应用实例时配置 cors: true 开启跨域支持,如 main.ts 中的代码:
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: true, // 开启跨域
});
若需要更精细的跨域配置(如允许特定域名、请求头),可传递具体的 cors 配置对象:
cors: {
origin: 'http://localhost:5173', // 允许的前端域名
methods: ['GET', 'POST', 'PUT', 'DELETE'], // 允许的请求方法
allowedHeaders: ['Content-Type', 'Authorization'], // 允许的请求头
}
全局路由前缀(app.setGlobalPrefix('api'))的作用是为所有接口统一添加前缀,避免接口路径冲突,同时便于区分 API 接口与静态资源路径。配置后,原本的 /posts 接口路径变为 /api/posts,前端请求时需对应调整路径。
4.4 前端请求封装与后端接口对接
前端通过 axios 封装请求,对接后端 NestJS 接口,实现数据交互。以获取帖子列表为例,前端封装 fetchPosts 函数,调用后端 /api/posts 接口:
import axios from './config'; // 封装后的axios实例
import type { Post } from '@/types'; // 帖子类型定义
// 封装获取帖子列表的请求
export const fetchPosts = async (
page: number = 1,
limit: number = 10,
) => {
try {
const response = await axios.get('/posts', {
params: {
page,
limit,
}
});
console.log('获取帖子列表成功', response);
return response.data; // 返回后端响应数据
} catch (error) {
console.error('获取帖子列表失败', error);
throw error; // 抛出错误,供上层组件处理
}
};
上述代码中,axios 实例已配置好基础路径(如 baseURL: 'http://localhost:3000/api'),因此请求时只需传递相对路径 /posts。结合前文的 axios 拦截器配置,请求会自动携带 Token,后端通过全局校验管道校验参数,服务层处理业务逻辑,最终返回分页结果,形成完整的前后端交互链路。
五、核心知识点总结与实践建议
5.1 核心知识点梳理
- React 钩子与 Zustand:牢记钩子的使用环境限制,非 React 环境中使用 Zustand 的 getState()/setState() 方法获取/更新状态,适配拦截器、工具函数等场景。
- DTO 设计与校验:通过 class-validator 和 class-transformer 实现数据校验与类型转换,配置全局校验管道,规范化前后端数据交互,减少冗余校验代码。
- Prisma Client 集成:通过全局模块注入 PrismaService,实现数据库连接的统一管理,利用类型安全的 API 简化数据库操作,提升开发效率。
- NestJS 架构设计:遵循模块化、分层架构原则,明确控制器、服务、模块的职责,通过依赖注入实现解耦,配置跨域、全局路由前缀等基础功能。
5.2 实践建议
- DTO 设计时,尽量细化校验规则,结合业务场景添加合适的装饰器(如 @MaxLength()、@IsEmail()),提前规避非法数据导致的业务异常。
- Prisma Schema 设计时,合理定义数据模型与关联关系,生成 Prisma Client 后,充分利用 TypeScript 类型提示,减少类型错误。
- axios 拦截器中,除了携带 Token,还可添加请求加载状态、错误统一处理等逻辑,提升前端用户体验。
- NestJS 项目中,合理划分模块,核心服务(如 PrismaService、日志服务)封装为全局模块,业务模块按功能拆分,便于维护与扩展。
通过本文的学习与实践,可掌握全栈开发中核心的技术点与架构设计思路,后续可结合实际项目进一步拓展,如添加用户认证授权、日志系统、异常处理机制等功能,构建完整的企业级全栈应用。