首页 / React 学习笔记 / 49

REACT · Vol.VII · LESSON 49 · AI 时代的前端进阶

从 React 到全栈:一个项目打通

实战核心#全栈#实战

选品与架构:一个书签管理器

四十八篇下来,知识在专栏里是分章的,但真实项目是「一次性」的:一个需求砸过来,路由、状态、取数、表单、部署同时到场。本篇就用一个小而完整的产品,把这六章串成一次真实交付。

选品定为书签管理器(Bookmark),理由很工程化:

  • 登录、增删改查、分页、关键字搜索——麻雀虽小五脏俱全,全书的知识点几乎都有落点;
  • 数据模型简单(一张 bookmark 表 + tags 字段),后端是你的老本行,注意力可以全部留给前端;
  • 业余时间两周能做完——学游泳先下浅水区,做产品先做能做完的。

架构一句话:Next.js(App Router)做前端 + 你熟悉的 Spring Boot 做 REST + JWT 后端。分工如下:

模块技术选型知识出处
数据建模与接口Spring Boot + MySQL + OpenAPI老本行
页面与路由Next.js App Router、动态路由第 30/31 篇、第 44 篇
登录守卫JWT + middleware第 35 篇
服务端数据TanStack Query第 17/33 篇
UI 状态Zustand第 18 篇
表单React Hook Form + Zod第 21 篇
类型体系TS interface 对齐后端 DTO第 28 篇
部署Nginx + jar + CI第 42 篇

请求有两条路:首屏由 Next.js 服务端组件直接 fetch Spring Boot(服务器对服务器);交互由客户端组件走 /api 反代——浏览器只见一个域名,跨域不存在,JWT 由后端签发与校验。

后端类比:Next.js 扮演 BFF

这套结构你在微服务里见过:Next.js 不只是「画页面的」,它同时是 SSR 引擎和前端的门面——转发请求(/api 反代)、拦截未登录(middleware)、聚合首屏数据(服务端组件 fetch)。角色约等于 BFF/gateway:对外统一入口,对内才是真正的服务。

第一步:契约先行——OpenAPI 就是前端的「建表 SQL」

后端做需求,你从来不先写 Controller:先评审表结构,再定 DTO,最后才是接口实现。全栈项目同样倒着来——先把 API 契约钉死,前后端才能并行开工

openapi.yaml · 书签模块核心接口(片段)
paths:
  /api/bookmarks:
    get:                          # 列表:分页 + 关键字
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 0 } }
        - { name: q,    in: query, schema: { type: string } }
      responses:
        "200": { description: 分页书签列表 }
    post:                         # 新建:需要登录
      security: [ { bearerAuth: [] } ]
      responses:
        "201": { description: 创建成功 }
  /api/bookmarks/{id}:
    get:
      responses:
        "200": { description: 书签详情 }
        "404": { description: 不存在 }

契约定了,前端的「表结构」就是 TS 类型——和后端 DTO 逐字段对齐(第 28 篇说的「类型即文档」):

types/api.ts · 前端侧的契约落地
// 与后端的 PageResp<Bookmark> 一一对应
export interface PageResp<T> {
  content: T[];
  totalElements: number;
  page: number;
  size: number;
}

export interface Bookmark {
  id: number;
  title: string;
  url: string;
  tags: string[];
  createdAt: string;   // ISO 字符串——和后端 LocalDateTime 的序列化格式要提前对齐
}

export interface NewBookmark {
  title: string;
  url: string;
  tags: string[];
}
后端类比:先建表,再写应用

OpenAPI 之于前后端,约等于 DDL 之于应用代码:两边共同认可的源头事实。更进一步可以全自动化——springdoc 从 Controller 导出 openapi.json,openapi-typescript 生成 TS 类型,契约漂移在 CI 直接编译报错,相当于契约测试,只是「测试」变成了「编译」。

核心要点
  • 契约先行:OpenAPI 定接口形状,TS interface 定前端类型,两边对齐后再写页面。
  • 类型对齐可以自动化:springdoc 导出 → openapi-typescript 生成,漂移在 CI 就死掉,不用人肉对字段。
  • 提前对齐序列化细节(日期格式、分页结构、错误码),这些坑比字段改名深得多。

第二步:页面与路由——服务端出骨架,客户端出交互

路由结构五分钟定完(App Router 目录即路由,第 30 篇):layout.tsx 全局布局与 Providers、login 登录页、bookmarks 列表页、bookmarks/[id] 详情页(第 31 篇动态路由)、middleware.ts 登录守卫(第 35 篇)。

列表页用服务端组件写——取数和渲染一气呵成,首屏数据在服务器上就变成了 HTML(第 44 篇 RSC):

app/bookmarks/page.tsx · 服务端组件
import { cookies } from "next/headers";

export default async function BookmarksPage() {
  const token = (await cookies()).get("bk_token")?.value;

  const res = await fetch("http://api:8080/api/bookmarks?page=0", {
    headers: { Authorization: `Bearer ${token ?? ""}` },
  });
  const data: PageResp<Bookmark> = await res.json();

  return (
    <main>
      <h1>我的书签({data.totalElements})</h1>
      <BookmarkExplorer initial={data} />
    </main>
  );
}

首屏之后的搜索、翻页交互交给客户端组件,服务端组件把数据作为 props 递下去:

app/bookmarks/BookmarkExplorer.tsx · 客户端组件
"use client";

import { useBookmarks } from "@/hooks/useBookmarks";
import { useUiStore } from "@/stores/uiStore";

export function BookmarkExplorer({ initial }: { initial: PageResp<Bookmark> }) {
  const keyword = useUiStore((s) => s.keyword);
  const page = useUiStore((s) => s.page);
  const { data, isFetching } = useBookmarks(keyword, page, initial);

  // 搜索框、分页器、书签卡片……(骨架从略)
  return <BookmarkList items={data?.content ?? []} loading={isFetching} />;
}

最后补守卫:未登录访问 /bookmarks,middleware 在请求进页面前就拦下(第 35 篇):

middleware.ts · 受保护路由
import { NextRequest, NextResponse } from "next/server";

export function middleware(req: NextRequest) {
  const token = req.cookies.get("bk_token");
  if (!token) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/bookmarks/:path*"] };

注意边界:middleware 只判断「有没有 token」,JWT 签名校验永远在后端——前端守卫只是体验优化,不是安全边界。和你写后端时「不能只靠网关鉴权」是同一条军规。

后端类比:Filter 拦截器跑在了边缘

middleware 就是 Servlet Filter / Spring Interceptor 的位置:请求进页面之前先过闸。区别是它默认跑在轻量沙箱里,快但能力有限——只做「查 cookie、重定向」这类轻活,重鉴权交给后端(下一篇第 50 篇讲这个「边缘」是什么)。

第三步:状态与数据层——两本账分开管

第 17 篇的核心判断在这里兑现:服务端数据是数据库的缓存副本,UI 状态是用户此刻的操作痕迹。两本账分开管,项目越大越显出对。第一本账交给 TanStack Query:

hooks/useBookmarks.ts · 服务端数据层
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { Bookmark, NewBookmark, PageResp } from "@/types/api";

export function useBookmarks(q: string, page: number, initial: PageResp<Bookmark>) {
  return useQuery({
    queryKey: ["bookmarks", q, page],     // key 变了自动重新取数
    queryFn: () =>
      api.get<PageResp<Bookmark>>("/bookmarks", { params: { q, page } }),
    initialData: initial,                 // 服务端组件给的首屏数据直接复用
  });
}

export function useAddBookmark() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (body: NewBookmark) => api.post<Bookmark>("/bookmarks", body),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["bookmarks"] }),
    // 新增成功让列表缓存失效——界面自动回到「数据库的最新状态」
  });
}

第二本账交给 Zustand——搜索词、分页、视图模式这些「用户此刻的心思」:

stores/uiStore.ts · UI 状态层
import { create } from "zustand";

interface UiState {
  keyword: string;
  page: number;
  setKeyword: (k: string) => void;
  setPage: (p: number) => void;
}

export const useUiStore = create<UiState>((set) => ({
  keyword: "",
  page: 0,
  setKeyword: (k) => set({ keyword: k, page: 0 }),   // 换词回第一页,细节见真章
  setPage: (p) => set({ page: p }),
}));

新建书签的表单,RHF 管状态、Zod 管校验,类型直接从 schema 推导(第 21 篇):

BookmarkForm.tsx · 表单核心片段
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";

const schema = z.object({
  title: z.string().min(1, "标题必填"),
  url: z.string().url("URL 不合法"),
  tags: z.array(z.string()).max(5, "最多 5 个标签"),
});
type FormInput = z.infer<typeof schema>;   // 类型从 schema 推导,不用写两遍

const form = useForm<FormInput>({
  resolver: zodResolver(schema),
  defaultValues: { title: "", url: "", tags: [] },
});
后端类比:读模型缓存与会话草稿

TanStack Query 管的服务端数据,是你后端眼里的「读模型缓存」:源头在数据库,前端只做缓存与失效——invalidateQueries 就是失效策略,QueryClient 就是缓存容器。Zustand 管的是「会话内草稿」:不落库、可丢弃。最忌讳把接口数据再 useState 存一份副本——缓存上再套缓存,过期了都没人通知。

核心要点
  • 服务端数据 → TanStack Query:缓存、失效、重取全自动,initialData 无缝衔接服务端首屏。
  • UI 状态 → Zustand:小、快、无样板;联动规则写在 store 里最清晰。
  • 表单 → RHF + Zod:类型从 schema 推导,校验规则一处定义、两端各认一半。

第四步:部署上线,与一次全书复盘

上线结构沿用第 42 篇的一切:Nginx 守 80,静态与回退路由照配,/api 反代进 Spring Boot(8080 不对公网暴露),JWT 后端签发与校验。最小拓扑用一个 compose 说清:

docker-compose.yml · 一台 2C4G 能跑的最小全栈
services:
  web:                      # 前端:第 42 篇的多阶段构建镜像(Nginx + dist)
    build: ./web
    ports:
      - "80:80"             # 静态资源、回退路由、/api 反代都在这
  api:                      # 后端:Spring Boot jar 的镜像
    build: ./api
    expose:
      - "8080"              # 不对公网暴露,只有 web 访得到
  db:
    image: mysql:8.4
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:

CI 沿用第 42 篇那套:前端 build/镜像,后端 mvn verify/镜像,合并后 docker compose up -d 拉起。到这里四步走完:契约 → 页面 → 状态 → 上线,一个产品活了。停下来复盘:哪一章的知识用在了哪一步?

步骤做了什么知识点出处
① 契约OpenAPI 定接口,TS interface 定类型类型即契约第 28 篇
② 路由App Router 布局、[id] 动态路由嵌套与动态路由第 30/31 篇
② 取数服务端组件首屏直出 + 客户端交互RSC 双组件模型第 44 篇
② 守卫middleware + JWT 分工认证与路由守卫第 35 篇
③ 服务端数据TanStack Query 缓存与失效服务端状态的概念与工具第 17/33 篇
③ UI 状态Zustand 管搜索词、分页轻量客户端状态第 18 篇
③ 表单RHF + Zod,类型从 schema 推导表单状态管理第 21 篇
④ 部署Nginx + 反代 + CI + composeSPA 部署全套第 42 篇
复盘时最扎眼的发现:难的从来不是某个 API 的用法,而是「什么东西该放哪」——服务端/客户端组件的边界(第 27 篇)、两类状态的分类(第 17 篇)、工具选型(第 20 篇)。这些架构判断力你在后端早练成了,转型最占便宜的就是这块。

本章回顾

五十篇的旅程只剩最后一篇。从第 1 篇那个「点击加一」的计数器,到今天一个契约先行、类型安全、首屏直出、自动部署的全栈产品——你已经能独立交付一个全栈产品了。这不是客套:建模、契约、路由、状态、表单、部署,每一环都亲手打通,每一环背后都有专栏某篇的原理托底。下一篇第 50 篇是全系列收官「前沿展望」:Server Actions、Partial Prerendering、边缘计算——看看这套刚建好的体系,正被哪些力量推向下一个十年。

Comments · 评论