综合练习与 Next.js
这一课你将能做到
- 把 12 课所有知识点串起来,独立完成一个「类型完整」的 Next.js 页面
- 写出生产级的类型化数据流:DTO → API 层 → 状态管理 → 组件渲染
- 识别并修复真实项目中常见的类型问题,建立「类型先行」的开发习惯
为什么要学
前面 11 课是「点」,这一课是「面」。真实项目里你不会只写 interface User,而是要同时处理:
- 后端返回的 DTO 类型(可能和前端展示模型不同)
- API 层的错误处理和加载状态
- 组件的 props 传递和事件回调
- 表单的用户输入和校验
- 全局状态的类型共享
这一课用一个完整的「课程卡片列表」页面,把上述所有场景串起来。写完之后,你应该能独立在 Next.js 项目里写出类型安全的页面,不再靠复制粘贴和 any 硬撑。
先建立图像
完整数据流的类型链路:
后端 API 返回 JSON
│
▼
DTO 类型(zod schema 校验)
│
▼
前端模型类型(业务需要的形状,可能和 DTO 不同)
│
▼
API 层函数(fetch + 校验 + 转换)
│
▼
组件状态(useState / useReducer / SWR)
│
▼
组件 Props(渲染 + 事件回调)
│
▼
用户交互(表单 → 校验 → 提交 → 更新状态)
每个箭头都是一次类型检查的机会。任何一环类型断了,后面全是 any。
跟着做
新建 Next.js 项目(或在本站现有项目里),逐步实操。
步骤 1:定义 DTO 和领域模型
// types/course.ts
// 后端返回的原始数据结构(DTO)
export interface CourseDTO {
course_id: number;
course_name: string;
instructor_name: string;
price_cents: number; // 后端用分存储
cover_image_url: string | null;
enrollment_count: number;
created_at: string; // ISO 8601
tags: string[];
difficulty_level: "beginner" | "intermediate" | "advanced";
}
// 前端展示用的模型(ViewModel)
export interface Course {
id: number;
title: string;
instructor: string;
price: number; // 转成元
coverUrl: string; // 给默认值,避免空值处理
students: number;
createdAt: Date; // 转成 Date 对象
tags: string[];
difficulty: "入门" | "进阶" | "高级";
}
// DTO → ViewModel 转换
export function toCourse(dto: CourseDTO): Course {
const difficultyMap = {
beginner: "入门",
intermediate: "进阶",
advanced: "高级",
} as const;
return {
id: dto.course_id,
title: dto.course_name,
instructor: dto.instructor_name,
price: dto.price_cents / 100,
coverUrl: dto.cover_image_url ?? "/default-cover.png",
students: dto.enrollment_count,
createdAt: new Date(dto.created_at),
tags: dto.tags,
difficulty: difficultyMap[dto.difficulty_level],
};
}
关键决策:DTO 和 ViewModel 分开。后端字段命名风格(snake_case)和前端(camelCase)不同;后端存分,前端显示元;后端返回 ISO 字符串,前端用 Date 对象。转换层一次搞定,后面所有组件都只面对干净的 ViewModel。
步骤 2:zod 校验 + API 层
// lib/api.ts
import { z } from "zod";
import type { CourseDTO, Course } from "@/types/course";
import { toCourse } from "@/types/course";
// zod schema 和 CourseDTO 对应
const CourseDTOSchema = z.object({
course_id: z.number(),
course_name: z.string(),
instructor_name: z.string(),
price_cents: z.number().int().nonnegative(),
cover_image_url: z.string().url().nullable(),
enrollment_count: z.number().int().nonnegative(),
created_at: z.string(),
tags: z.array(z.string()),
difficulty_level: z.enum(["beginner", "intermediate", "advanced"]),
});
const CourseListResponseSchema = z.object({
code: z.literal(0),
data: z.object({
courses: z.array(CourseDTOSchema),
total: z.number(),
page: z.number(),
}),
});
const CourseListErrorSchema = z.object({
code: z.number().refine((c) => c !== 0),
error: z.string(),
});
// 判别联合
const ApiResponseSchema = z.union([
CourseListResponseSchema,
CourseListErrorSchema,
]);
export type CourseListResult =
| { ok: true; courses: Course[]; total: number; page: number }
| { ok: false; error: string };
export async function fetchCourses(page: number = 1): Promise<CourseListResult> {
try {
const res = await fetch(`/api/courses?page=${page}`);
if (!res.ok) {
return { ok: false, error: `HTTP ${res.status}` };
}
const raw: unknown = await res.json();
const parsed = ApiResponseSchema.safeParse(raw);
if (!parsed.success) {
console.error("API 数据格式异常:", parsed.error.issues);
return { ok: false, error: "数据格式错误" };
}
if (parsed.data.code !== 0) {
return { ok: false, error: parsed.data.error };
}
const { courses, total, page: currentPage } = parsed.data.data;
return {
ok: true,
courses: courses.map(toCourse),
total,
page: currentPage,
};
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "网络异常",
};
}
}
步骤 3:类型化的自定义 Hook
// hooks/useCourses.ts
import { useState, useEffect, useCallback } from "react";
import type { Course } from "@/types/course";
import { fetchCourses } from "@/lib/api";
import type { CourseListResult } from "@/lib/api";
// 加载状态机(判别联合)
type LoadingState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: Course[]; total: number; page: number }
| { status: "error"; message: string };
interface UseCoursesReturn {
state: LoadingState;
refresh: () => Promise<void>;
loadMore: () => Promise<void>;
hasMore: boolean;
}
export function useCourses(): UseCoursesReturn {
const [state, setState] = useState<LoadingState>({ status: "idle" });
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const load = useCallback(async (page: number, append: boolean = false) => {
setState((prev) => (append ? prev : { status: "loading" }));
const result: CourseListResult = await fetchCourses(page);
if (!result.ok) {
setState({ status: "error", message: result.error });
return;
}
setState((prev) => {
const existing = append && prev.status === "success" ? prev.data : [];
return {
status: "success",
data: [...existing, ...result.courses],
total: result.total,
page: result.page,
};
});
setCurrentPage(result.page);
setHasMore(result.courses.length > 0 && result.page * 20 < result.total);
}, []);
useEffect(() => {
load(1);
}, [load]);
const refresh = useCallback(() => load(1), [load]);
const loadMore = useCallback(() => load(currentPage + 1, true), [load, currentPage]);
return { state, refresh, loadMore, hasMore };
}
步骤 4:类型完整的组件
// components/CourseCard.tsx
import Image from "next/image";
import type { Course } from "@/types/course";
interface CourseCardProps {
course: Course;
onEnroll: (courseId: number) => void;
isEnrolling?: boolean;
}
export function CourseCard({ course, onEnroll, isEnrolling = false }: CourseCardProps) {
return (
<article className="rounded-lg border p-4 shadow-sm">
<div className="relative h-40 w-full overflow-hidden rounded-md">
<Image
src={course.coverUrl}
alt={course.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 33vw"
/>
</div>
<div className="mt-3 space-y-2">
<h3 className="text-lg font-semibold">{course.title}</h3>
<p className="text-sm text-gray-600">讲师:{course.instructor}</p>
<div className="flex items-center gap-2 text-sm">
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-800">
{course.difficulty}
</span>
<span className="text-gray-500">{course.students} 人已报名</span>
</div>
<div className="flex flex-wrap gap-1">
{course.tags.map((tag) => (
<span key={tag} className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
<div className="flex items-center justify-between pt-2">
<span className="text-xl font-bold text-red-600">
¥{course.price.toFixed(2)}
</span>
<button
onClick={() => onEnroll(course.id)}
disabled={isEnrolling}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isEnrolling ? "报名中..." : "立即报名"}
</button>
</div>
</div>
</article>
);
}
步骤 5:页面组装——状态驱动 UI
// app/courses/page.tsx
"use client";
import { useState } from "react";
import { useCourses } from "@/hooks/useCourses";
import { CourseCard } from "@/components/CourseCard";
export default function CoursesPage() {
const { state, refresh, loadMore, hasMore } = useCourses();
const [enrollingId, setEnrollingId] = useState<number | null>(null);
const handleEnroll = async (courseId: number) => {
setEnrollingId(courseId);
try {
const res = await fetch("/api/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId }),
});
if (!res.ok) throw new Error("报名失败");
alert("报名成功!");
} catch (error) {
alert(error instanceof Error ? error.message : "报名失败");
} finally {
setEnrollingId(null);
}
};
return (
<main className="mx-auto max-w-6xl p-6">
<h1 className="mb-6 text-2xl font-bold">全部课程</h1>
{/* 状态驱动渲染:每个分支都有类型保障 */}
{state.status === "loading" && (
<div className="py-12 text-center text-gray-500">加载中...</div>
)}
{state.status === "error" && (
<div className="py-12 text-center">
<p className="text-red-600">{state.message}</p>
<button onClick={refresh} className="mt-4 text-blue-600 underline">
重试
</button>
</div>
)}
{state.status === "success" && (
<>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{state.data.map((course) => (
<CourseCard
key={course.id}
course={course}
onEnroll={handleEnroll}
isEnrolling={enrollingId === course.id}
/>
))}
</div>
{hasMore && (
<div className="mt-8 text-center">
<button
onClick={loadMore}
className="rounded border px-6 py-2 hover:bg-gray-50"
>
加载更多
</button>
</div>
)}
</>
)}
</main>
);
}
步骤 6:表单类型化(报名弹窗)
// components/EnrollForm.tsx
import { useState } from "react";
import { z } from "zod";
const EnrollFormSchema = z.object({
name: z.string().min(2, "姓名至少 2 个字"),
phone: z.string().regex(/^1[3-9]\d{9}$/, "手机号格式不正确"),
email: z.string().email("邮箱格式不正确"),
note: z.string().max(200, "备注最多 200 字").optional(),
});
type EnrollFormData = z.infer<typeof EnrollFormSchema>;
interface EnrollFormProps {
courseId: number;
onSubmit: (data: EnrollFormData) => Promise<void>;
onCancel: () => void;
}
export function EnrollForm({ courseId, onSubmit, onCancel }: EnrollFormProps) {
const [formData, setFormData] = useState<EnrollFormData>({
name: "",
phone: "",
email: "",
note: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// 清除该字段的错误
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// zod 校验
const result = EnrollFormSchema.safeParse(formData);
if (!result.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of result.error.issues) {
const field = issue.path[0] as string;
fieldErrors[field] = issue.message;
}
setErrors(fieldErrors);
return;
}
setIsSubmitting(true);
try {
await onSubmit(result.data);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
姓名 *
</label>
<input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className={`mt-1 w-full rounded border px-3 py-2 ${
errors.name ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
)}
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium">
手机号 *
</label>
<input
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className={`mt-1 w-full rounded border px-3 py-2 ${
errors.phone ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.phone && (
<p className="mt-1 text-sm text-red-600">{errors.phone}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
邮箱 *
</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
className={`mt-1 w-full rounded border px-3 py-2 ${
errors.email ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
<div>
<label htmlFor="note" className="block text-sm font-medium">
备注
</label>
<textarea
id="note"
name="note"
value={formData.note}
onChange={handleChange}
rows={3}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2"
/>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "提交中..." : "确认报名"}
</button>
<button
type="button"
onClick={onCancel}
className="rounded border px-4 py-2"
>
取消
</button>
</div>
</form>
);
}
讲清楚要点
- DTO 和 ViewModel 分离:后端返回的原始数据(snake_case、分、ISO 字符串)和前端展示模型(camelCase、元、Date 对象)是两种类型,中间用纯函数转换。这样后端改字段名只影响转换层,不波及组件。
- zod schema 即类型:
z.infer<typeof Schema> 生成的类型和手写 interface 完全等价,但多一份运行时校验。API 边界、表单输入、环境变量,所有「外部数据入口」都应该有 zod 把关。
- 状态机用判别联合:
{ status: "loading" } | { status: "success"; data: T } | { status: "error"; message: string },每个状态分支的可用字段由类型系统保证,不可能出现「loading 状态访问 data」的错误。
- 事件处理类型要精确:
React.ChangeEvent<HTMLInputElement> 和 React.FormEvent 不是可选项,是必选项。写对了,编译器自动补全 e.target.value;写错了或省略了,就是 any 的天下。
- 表单错误用
Record<string, string>:字段名到错误信息的映射,配合 zod 的 issue.path 自动生成。比散落的 nameError、phoneError 变量优雅得多。
- 类型是写给人看的:
CourseCardProps 接口不仅给编译器看,也给下一个接手的工程师看。好的类型注解就是最好的文档。
你可能会懵的地方
-
DTO 和 ViewModel 一定要分开吗?
如果后端和前端字段完全一致,可以不分开。但现实中几乎不会完全一致(命名风格、数据格式、字段冗余)。分开的代价是多写一个转换函数,收益是前后端解耦。
-
zod 会不会让代码变臃肿?
schema 确实多写几行,但它同时替代了「手写 interface」+「手写校验函数」两份工作。总体代码量通常更少。
-
useState 的类型参数和初始值哪个优先?
显式类型参数优先。useState<Course[]>([]) 里 [] 被推断为 never[],但类型参数把它提升为 Course[]。不写参数只写 [],后续 push 会报错。
-
Next.js 的页面组件能直接用 async 吗?
App Router 的页面组件可以是 async(服务端组件),但带交互的页面需要 "use client" 变成客户端组件,客户端组件不能用 async 作为组件函数签名。
-
生产环境 zod 校验失败怎么处理?
记录日志(Sentry)、返回降级 UI、或抛出到 Error Boundary。不要静默失败,否则数据异常会被掩盖。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 组件直接消费后端 DTO | 后端改字段,组件全崩 | DTO → ViewModel 转换层隔离 |
fetch 返回 any 直接渲染 | 无类型安全,运行时可能崩 | zod 校验 + 类型守卫 |
状态用多个布尔变量 isLoading, isError, hasData | 状态组合爆炸,逻辑冲突 | 判别联合状态机 |
| 表单字段错误用独立 state 管理 | 字段多时代码冗余 | Record<string, string> + zod 统一处理 |
| 所有组件写在一个文件里 | 类型无法复用,测试困难 | 按职责拆分,类型独立导出 |
卡点急救
- zod 校验报「Expected string, received undefined」:后端漏了字段。检查 schema 是否该加
.optional(),或和后端确认字段是否必定返回。
- useEffect 里调用 setState 导致无限循环:依赖数组里包含了每次渲染都新建的对象/函数。用
useCallback/useMemo 稳定引用,或检查是否真的需要这个依赖。
- Next.js Image 组件报
src 类型错误:coverUrl 可能是空字符串。确保 ViewModel 转换时给默认值,或加条件渲染 {coverUrl && <Image ... />}。
- 表单提交后页面刷新:忘了
e.preventDefault()。React 表单必须阻止默认提交行为。
面试常问
-
如何在 Next.js 中组织类型?
DTO 和 ViewModel 分离,zod 做运行时校验,判别联合管理状态,组件 Props 用接口。类型文件按领域划分(types/course.ts、types/user.ts),通过 @/ 别名导入。
-
zod 和 TypeScript 是什么关系?
zod 是运行时校验库,TypeScript 是编译期类型系统。zod 的 z.infer 可以从 schema 生成 TS 类型,实现「一次定义,两处生效」。两者互补:TS 管代码内部,zod 管外部数据入口。
-
如何处理 API 加载状态?
判别联合状态机:idle | loading | success | error。每个状态携带对应数据(success 带 data,error 带 message),组件用 switch 渲染,类型系统保证不会访问不存在的数据。
-
DTO 和 ViewModel 分离的好处?
前后端解耦:后端改字段名只影响转换层;数据格式统一:分转元、ISO 转 Date 在边界完成;类型更清晰:组件只面对干净的 ViewModel。
自测三问
- 为什么
useState<Course[]>([]) 比 useState([]) 好?后者会导致什么问题?
- zod 的
safeParse 返回什么类型?如何利用判别联合处理成功和失败?
- 如果后端把
price_cents 改成 price_yuan,你的代码需要改几个文件?为什么?
小结一句话
TypeScript 的综合实战 = DTO/ViewModel 分离 + zod 运行时校验 + 判别联合状态机 + 精确的事件和 Props 类型——类型不是负担,是帮你把「想当然」变成「确定」的契约。
延伸