NestJS+React+Zustand 全栈开发学习笔记

3 阅读17分钟

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 拦截器中正常使用的关键:

  1. 无环境限制:不属于 React 钩子,可在任意普通函数、拦截器、异步代码、工具函数中自由调用,打破了 React 钩子的使用边界。
  2. 实时获取最新状态:每次调用都会获取当前状态的最新快照。在 Token 刷新、用户重新登录等场景中,新的 Token 值会实时同步到 Zustand 状态中,axios 每次发起请求时执行拦截器,调用该方法就能拿到最新的 Token,避免因状态同步不及时导致的请求失败。
  3. 同步执行无阻塞:该方法为同步执行,无需异步等待(无 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 的三个核心参数作用至关重要:

  1. whitelist: true:开启白名单模式,自动过滤请求中不属于 DTO 定义的字段。例如,前端传递了 “createTime” 字段,但 PostQueryDto 中未定义该字段,该字段会被自动过滤,不会传递到控制器中,避免冗余数据干扰业务逻辑。
  2. forbidNonWhitelisted: true:严格模式,若前端传递了 DTO 中未定义的字段,直接抛出 400 Bad Request 异常,并返回详细的错误信息,提示前端传递了非法字段,便于快速定位问题。
  3. 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 采用模块化、分层架构设计,核心分为四层,每层职责明确,实现业务逻辑与技术细节的解耦,整体架构如下:

  1. 控制器(Controller) :负责接收客户端请求,处理路由映射,参数校验,将请求转发给服务层,返回响应结果。
  2. 服务层(Service) :负责核心业务逻辑实现,调用数据访问层(Prisma Client)获取或操作数据,不直接与客户端交互。
  3. 数据访问层(Prisma Client) :负责与数据库交互,封装数据库操作细节,为服务层提供数据支持。
  4. 模块(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 核心知识点梳理

  1. React 钩子与 Zustand:牢记钩子的使用环境限制,非 React 环境中使用 Zustand 的 getState()/setState() 方法获取/更新状态,适配拦截器、工具函数等场景。
  2. DTO 设计与校验:通过 class-validator 和 class-transformer 实现数据校验与类型转换,配置全局校验管道,规范化前后端数据交互,减少冗余校验代码。
  3. Prisma Client 集成:通过全局模块注入 PrismaService,实现数据库连接的统一管理,利用类型安全的 API 简化数据库操作,提升开发效率。
  4. NestJS 架构设计:遵循模块化、分层架构原则,明确控制器、服务、模块的职责,通过依赖注入实现解耦,配置跨域、全局路由前缀等基础功能。

5.2 实践建议

  • DTO 设计时,尽量细化校验规则,结合业务场景添加合适的装饰器(如 @MaxLength()、@IsEmail()),提前规避非法数据导致的业务异常。
  • Prisma Schema 设计时,合理定义数据模型与关联关系,生成 Prisma Client 后,充分利用 TypeScript 类型提示,减少类型错误。
  • axios 拦截器中,除了携带 Token,还可添加请求加载状态、错误统一处理等逻辑,提升前端用户体验。
  • NestJS 项目中,合理划分模块,核心服务(如 PrismaService、日志服务)封装为全局模块,业务模块按功能拆分,便于维护与扩展。

通过本文的学习与实践,可掌握全栈开发中核心的技术点与架构设计思路,后续可结合实际项目进一步拓展,如添加用户认证授权、日志系统、异常处理机制等功能,构建完整的企业级全栈应用。