首页 / React 学习笔记 / 28

REACT · Vol.IV · LESSON 28 · 组件设计与工程化

TypeScript 与 React:类型安全的组件

实战核心#TypeScript#类型

给 Java 程序员的 TS 速通表

上一篇(第 27 篇)我们把组件拆成了展示 / 容器 / 逻辑 Hook 三层,层与层的连接点是 Props。问题在于:JavaScript 的 Props 是个运行时对象,字段拼错、漏传、多传,默认没人拦你——直到页面上出现 undefined。所以从这一篇起,我们的代码全面切到 TypeScript。好消息是:对你来说这不算学新语言,更像「类型推断更聪明的 Java 脚本版」。先把心智映射建立起来:

TypeScript你脑中的 Java一句话解释
interface / typeinterface最大差异:TS 是结构化类型——形状对上就兼容,不需要 implements 声明
<T> 泛型<T> 泛型写法几乎一样,推断更聪明,多数场合不用手写
"idle" | "loading"sealed 接口 + 有限子类联合类型:值只能取列出的几种(受控多态)
type Role = "admin"enum Role字面量类型当枚举用,编译后零运行时开销
email?: string@Nullable String可能有(string)、可能没有(undefined)
unknownObject什么都收,但用之前必须收窄——严谨版
any(没有对应物)放弃类型检查,等于把编译器关了——评审见一次骂一次
types.ts · 左边 TS,右边你的 Java 直觉
interface User {                  // ≈ record User(...):形状即类型
  id: number;
  name: string;
  email?: string;                 // ≈ @Nullable String email
  role: Role;
}

type Role = "admin" | "editor" | "viewer";   // ≈ enum Role { ADMIN, EDITOR, VIEWER }

function firstOrNull<T>(items: T[]): T | undefined {
  // ≈ static <T> T firstOrNull(List<T> list),没找到就返回 undefined
  return items.length > 0 ? items[0] : undefined;
}
一句话点破

TS 的 interface 是编译期的验收标准,不参与运行时——编译成 JS 后彻底消失,比 Java 的泛型擦除还彻底。所以别在 interface 里找逻辑,它只是「形状契约」。而结构化类型意味着:一个恰好有 id/name/email/role 的对象,不用声明「我实现了 User」,它就是 User——鸭子类型,你的 DTO 复用会轻很多。

给 Props 和事件建模:契约写进代码

回到第 3 篇的 Props。没有 TS 时,props 是个「来什么收什么」的袋子;有了 TS,Props 变成了方法签名——组件的使用说明书直接由编译器执行。

Avatar.tsx · 必填、可选、默认值、children 一网打尽
import type { ReactNode } from "react";

interface AvatarProps {
  name: string;                 // 必填:漏传直接编译报错
  size?: "sm" | "md" | "lg";    // 可选 + 字面量联合:写 "big" 当场红线
  showName?: boolean;           // 可选布尔
  children?: ReactNode;         // 子元素:ReactNode ≈ 「任何能渲染的东西」
}

export function Avatar({ name, size = "md", showName = true, children }: AvatarProps) {
  return (
    <span className={`avatar avatar--${size}`}>
      {name.slice(0, 1)}
      {showName && <b>{name}</b>}
      {children}
    </span>
  );
}

三个要点:解构时直接给默认值(size = "md"),比 Java 的「重载两个构造器」轻得多;children: ReactNode 表示调用者可以塞任何可渲染内容(第 25 篇的主题);可选 props 别滥用——契约从严,默认从宽,必填的就老老实实必填。

事件类型:别再写 e: any

SearchBox.tsx · 给事件对象一个精确类型
import { useState } from "react";
import type { ChangeEvent } from "react";

export function SearchBox() {
  const [value, setValue] = useState("");

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setValue(e.target.value);   // e.target 精确是 input,.value 有补全
  }

  return <input value={value} onChange={handleChange} />;
}
场景事件类型
输入框 / textarea / select 变化React.ChangeEvent<HTMLInputElement>
点击React.MouseEvent<HTMLButtonElement>
表单提交React.FormEvent<HTMLFormElement>
键盘按键React.KeyboardEvent<HTMLInputElement>

记不住?偷懒法:先写 (e) => ... 让编辑器推断,鼠标悬停看一眼类型,再补上显式标注。唯一禁止的是 e: any——e.target 会退化成 any,一行代码废掉整条类型链。

你在 Java 里用 @Nullable 注解时,大概体会过那种「能不能查出来要看工具配置」的无力感。TS 的可选 ? 不是注解,是类型系统本身——「空值检查」默认开启,不用装插件。你幻想过的 NullSafe 编译期检查,前端这边直接送。

state 建模与泛型组件

useState 也有泛型参数

第 4 篇说过 state 是组件的「实例字段」——字段也需要类型。大多数时候推断够用,但有两种情况必须显式给:

TodoBoard.tsx · useState 的泛型写法
import { useState } from "react";
import type { Todo } from "./types";

// ① 初始值是 null,推断不出完整形状——显式给「联合类型」
const [todos, setTodos] = useState<Todo[] | null>(null);
// null 表示「还没加载」,Todo[] 表示「已加载」,类型系统分得清清楚楚

// ② 空数组初始化:被推断成 never[],后面 push 什么都报错
const [tags, setTags] = useState([]);                // ❌ never[]
const [tags2, setTags2] = useState<string[]>([]);    // ✅ 显式给泛型

泛型组件:React 版的 List<T> 工具类

List.tsx · 可复用列表:数据类型和渲染方式都参数化
import type { ReactNode } from "react";

interface ListProps<T> {
  items: T[];                          // 数据
  keyOf: (item: T) => string;          // 怎么取 key(呼应第 7 篇)
  renderItem: (item: T) => ReactNode;  // 怎么画每一项
}

export function List<T>({ items, keyOf, renderItem }: ListProps<T>) {
  return (
    <ul>
      {items.map(item => (
        <li key={keyOf(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}
使用处 · T 自动推断为 User
<List
  items={users}
  keyOf={u => String(u.id)}
  renderItem={user => <b>{user.name}</b>}
/>
// user.name 有补全;写成 user.nmae 当场红线。换传 articles,T 又变成 Article
后端类比:Java 泛型类

class List<T> 把「元素的类型」参数化;泛型组件把「数据的类型 + 怎么渲染」一起参数化,写法几乎是直译。唯一的深层差异:TS 泛型擦除得更彻底——运行时不存在任何类型信息,所有检查都在编译期完成,零性能开销。

可辨识联合:把非法状态挡在编译期

后端封装接口返回时,你写过无数个 Result<T>(code + message + data)。前端管理「一次请求」的状态时,新手最常见的写法是三个平铺字段——这是 TS 里最值得纠正的反模式:

❌ 平铺三字段 · 非法组合全靠自觉
interface UserState {
  loading: boolean;      // loading 时 data 是什么?
  data: User[] | null;   // data 和 error 能同时有值吗?没有理论禁止
  error: string | null;  // 三个字段组合出 8 种状态,合法的只有 4 种
}

三个可空字段拼出 8 种组合,其中一半是「不可能出现、但编译器不管」的鬼态。解法叫可辨识联合(discriminated union):用公共的判别字段把所有合法状态逐一列出来,追求「非法状态不可表示」:

requestState.ts · ✅ 四态互斥,一眼看全
type RequestState<T> =
  | { status: "idle" }                     // 还没开始
  | { status: "loading" }                  // 请求中
  | { status: "success"; data: T }         // 成功:才有 data
  | { status: "error"; message: string };  // 失败:才有 message
UserSection.tsx · switch 判别字段,每个分支自动收窄
function UserSection({ state }: { state: RequestState<User[]> }) {
  switch (state.status) {
    case "idle":    return <p>输入关键字开始搜索</p>;
    case "loading": return <p>加载中…</p>;
    case "success": return <UserList users={state.data} />;   // 只有这里才有 state.data
    case "error":   return <p role="alert">出错了:{state.message}</p>;
  }
}
// 漏写一个分支?编译报错。在 error 分支访问 state.data?编译报错。
后端类比:Java 17 sealed + record

这招你们后端其实已经在用了——sealed interface + record 就是可辨识联合的 Java 版,switch 模式匹配就是上面的自动收窄,编译器同样逼你处理完每一种情况。

RequestState.java · 同款思想,直译过去
sealed interface RequestState<T> permits Idle, Loading, Success, Failure {}
record Success<T>(List<T> data) implements RequestState<T> {}
record Failure<T>(String message) implements RequestState<T> {}
// switch 模式匹配 + 穷尽性检查:漏一种状态编译不过

这套建模是第 34 篇「加载态·错误态·竞态」的主旋律;第 33 篇的 TanStack Query 则在内部替你造好了这台状态机——先懂原理,再用工具,心里才有底。

idle 等待发起 loading 请求进行中 success data: T error message 发起请求 2xx 抛异常 四态互斥:不存在「既 success 又 error」的取值
图 1可辨识联合建模的请求状态机:同一时刻只处于一种状态,所有切换路径显式可见

工具类型与类型收窄:省一半的类型代码

工具类型:从源类型派生 DTO

user.types.ts · 同一份 User,派生出表单、补丁、选项
interface User {
  id: number;
  name: string;
  email: string;
  createdAt: string;
}

type UserCreateForm = Omit<User, "id" | "createdAt">;  // 创建表单:去掉服务端生成的字段
type UserPatch      = Partial<User>;                   // 局部更新:全字段可选,PATCH 语义
type UserOption     = Pick<User, "id" | "name">;       // 下拉选项:只要两个字段
后端类比:DTO 漂移问题

Java 里你为 UserVO、UserCreateDTO、UserUpdateDTO 手工复制三份类,字段一改就漂移,靠 MapStruct 兜底。TS 的工具类型在编译期从同一源类型派生出所有变体——改一处、处处同步,零运行时成本。表单场景(第 21 篇)尤其好用。

类型收窄:和 Optional 判空一个直觉

narrowing.tsx · if 一下,类型就精确了
const [user, setUser] = useState<User | null>(null);

if (user) {                        // 收窄:这个块里 user 一定是 User
  console.log(user.email);         // 不用写 user!.email,也不会报「可能为 null」
}

// 提前返回同样触发收窄——React 组件里最常见的姿势
if (!user) return <p>请先登录</p>;
return <Profile user={user} />;    // 这里 user 已收窄为 User

收窄的手段不止 ifswitch 判别字段(上一站)、typeofin 运算符,都能让编译器把联合类型「切」到你正在用的那个分支。配合可辨识联合,绝大多数判空和状态判断都变成了编译期检查,而不是线上事故。

核心要点
  • TS 对 Java 程序员近乎母语:interface、泛型、联合类型一一对应;记住「结构化类型」这个最大差异。
  • Props 用 interface 建模 + children: ReactNode;事件用 React.ChangeEvent<HTMLInputElement> 这类精确类型,拒绝 any。
  • 请求状态用可辨识联合建模(idle / loading / success / error),让非法状态无法通过编译。
  • Partial / Pick / Omit 从源类型派生表单与 DTO,告别三份定义漂移。

本章回顾

把 TypeScript 接入 React,只需抓住三个位置:Props 契约(组件的接口签名)、state 形状(useState 的泛型参数)、状态机建模(可辨识联合)。类型不能让程序没有 bug,但能拦住一大半「拼错字段、漏判空值、非法状态」这类低级事故——而这恰恰是协作与重构中最贵的那类 bug。

类型问题解决了,还剩最后一问:这些 .tsx 文件往哪个目录放?components、hooks、pages 平铺,还是按功能分域?组件文件叫 UserList.tsx 还是 user-list.tsx?下一篇第 29 篇——目录结构与代码规范,把你带 Java 项目的规范意识完整平移到前端,也为卷四收官。

Comments · 评论