CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
TypeScript 核心/课时/综合练习与 Next.js
RawBlame
课时目录12 节

TypeScript 核心

  1. 01TypeScript 是什么
  2. 02基本类型
  3. 03函数类型
  4. 04接口与类型别名
  5. 05联合与交叉类型
  6. 06类型收窄
  7. 07泛型
  8. 08工具类型
  9. 09模块与 tsconfig
  10. 10React 组件 typing
  11. 11异步与 API 类型
  12. 12综合练习与 Next.js

综合练习与 Next.js

类型安全 Course API 客户端;读 platform 源码。

45 分钟

综合练习与 Next.js

这一课你将能做到

  1. 把 12 课所有知识点串起来,独立完成一个「类型完整」的 Next.js 页面
  2. 写出生产级的类型化数据流:DTO → API 层 → 状态管理 → 组件渲染
  3. 识别并修复真实项目中常见的类型问题,建立「类型先行」的开发习惯

为什么要学

前面 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>
  );
}

讲清楚要点

  1. DTO 和 ViewModel 分离:后端返回的原始数据(snake_case、分、ISO 字符串)和前端展示模型(camelCase、元、Date 对象)是两种类型,中间用纯函数转换。这样后端改字段名只影响转换层,不波及组件。
  2. zod schema 即类型:z.infer<typeof Schema> 生成的类型和手写 interface 完全等价,但多一份运行时校验。API 边界、表单输入、环境变量,所有「外部数据入口」都应该有 zod 把关。
  3. 状态机用判别联合:{ status: "loading" } | { status: "success"; data: T } | { status: "error"; message: string },每个状态分支的可用字段由类型系统保证,不可能出现「loading 状态访问 data」的错误。
  4. 事件处理类型要精确:React.ChangeEvent<HTMLInputElement> 和 React.FormEvent 不是可选项,是必选项。写对了,编译器自动补全 e.target.value;写错了或省略了,就是 any 的天下。
  5. 表单错误用 Record<string, string>:字段名到错误信息的映射,配合 zod 的 issue.path 自动生成。比散落的 nameError、phoneError 变量优雅得多。
  6. 类型是写给人看的: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 统一处理
所有组件写在一个文件里类型无法复用,测试困难按职责拆分,类型独立导出

卡点急救

  1. zod 校验报「Expected string, received undefined」:后端漏了字段。检查 schema 是否该加 .optional(),或和后端确认字段是否必定返回。
  2. useEffect 里调用 setState 导致无限循环:依赖数组里包含了每次渲染都新建的对象/函数。用 useCallback/useMemo 稳定引用,或检查是否真的需要这个依赖。
  3. Next.js Image 组件报 src 类型错误:coverUrl 可能是空字符串。确保 ViewModel 转换时给默认值,或加条件渲染 {coverUrl && <Image ... />}。
  4. 表单提交后页面刷新:忘了 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。

自测三问

  1. 为什么 useState<Course[]>([]) 比 useState([]) 好?后者会导致什么问题?
  2. zod 的 safeParse 返回什么类型?如何利用判别联合处理成功和失败?
  3. 如果后端把 price_cents 改成 price_yuan,你的代码需要改几个文件?为什么?

小结一句话

TypeScript 的综合实战 = DTO/ViewModel 分离 + zod 运行时校验 + 判别联合状态机 + 精确的事件和 Props 类型——类型不是负担,是帮你把「想当然」变成「确定」的契约。

延伸

  • Next.js 官方文档 - TypeScript
  • zod 官方文档
  • React TypeScript Cheatsheet
  • 回顾全部 12 课,把本课的综合练习独立重写一遍,不看示例代码。
加载本节练习…
← 上一课异步与 API 类型