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 不只是「画页面的」,它同时是 SSR 引擎和前端的门面——转发请求(/api 反代)、拦截未登录(middleware)、聚合首屏数据(服务端组件 fetch)。角色约等于 BFF/gateway:对外统一入口,对内才是真正的服务。
第一步:契约先行——OpenAPI 就是前端的「建表 SQL」
后端做需求,你从来不先写 Controller:先评审表结构,再定 DTO,最后才是接口实现。全栈项目同样倒着来——先把 API 契约钉死,前后端才能并行开工:
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 篇说的「类型即文档」):
// 与后端的 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):
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 递下去:
"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 篇):
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 签名校验永远在后端——前端守卫只是体验优化,不是安全边界。和你写后端时「不能只靠网关鉴权」是同一条军规。
middleware 就是 Servlet Filter / Spring Interceptor 的位置:请求进页面之前先过闸。区别是它默认跑在轻量沙箱里,快但能力有限——只做「查 cookie、重定向」这类轻活,重鉴权交给后端(下一篇第 50 篇讲这个「边缘」是什么)。
第三步:状态与数据层——两本账分开管
第 17 篇的核心判断在这里兑现:服务端数据是数据库的缓存副本,UI 状态是用户此刻的操作痕迹。两本账分开管,项目越大越显出对。第一本账交给 TanStack Query:
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——搜索词、分页、视图模式这些「用户此刻的心思」:
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 篇):
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 说清:
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 + compose | SPA 部署全套 | 第 42 篇 |
本章回顾
五十篇的旅程只剩最后一篇。从第 1 篇那个「点击加一」的计数器,到今天一个契约先行、类型安全、首屏直出、自动部署的全栈产品——你已经能独立交付一个全栈产品了。这不是客套:建模、契约、路由、状态、表单、部署,每一环都亲手打通,每一环背后都有专栏某篇的原理托底。下一篇第 50 篇是全系列收官「前沿展望」:Server Actions、Partial Prerendering、边缘计算——看看这套刚建好的体系,正被哪些力量推向下一个十年。
Comments · 评论