REACT · Vol.IV · LESSON 28 · 组件设计与工程化
TypeScript 与 React:类型安全的组件
给 Java 程序员的 TS 速通表
上一篇(第 27 篇)我们把组件拆成了展示 / 容器 / 逻辑 Hook 三层,层与层的连接点是 Props。问题在于:JavaScript 的 Props 是个运行时对象,字段拼错、漏传、多传,默认没人拦你——直到页面上出现 undefined。所以从这一篇起,我们的代码全面切到 TypeScript。好消息是:对你来说这不算学新语言,更像「类型推断更聪明的 Java 脚本版」。先把心智映射建立起来:
| TypeScript | 你脑中的 Java | 一句话解释 |
|---|---|---|
interface / type | interface | 最大差异:TS 是结构化类型——形状对上就兼容,不需要 implements 声明 |
<T> 泛型 | <T> 泛型 | 写法几乎一样,推断更聪明,多数场合不用手写 |
"idle" | "loading" | sealed 接口 + 有限子类 | 联合类型:值只能取列出的几种(受控多态) |
type Role = "admin" | enum Role | 字面量类型当枚举用,编译后零运行时开销 |
email?: string | @Nullable String | 可能有(string)、可能没有(undefined) |
unknown | Object | 什么都收,但用之前必须收窄——严谨版 |
any | (没有对应物) | 放弃类型检查,等于把编译器关了——评审见一次骂一次 |
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 变成了方法签名——组件的使用说明书直接由编译器执行。
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
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,一行代码废掉整条类型链。
@Nullable 注解时,大概体会过那种「能不能查出来要看工具配置」的无力感。TS 的可选 ? 不是注解,是类型系统本身——「空值检查」默认开启,不用装插件。你幻想过的 NullSafe 编译期检查,前端这边直接送。state 建模与泛型组件
useState 也有泛型参数
第 4 篇说过 state 是组件的「实例字段」——字段也需要类型。大多数时候推断够用,但有两种情况必须显式给:
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> 工具类
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>
);
}
<List
items={users}
keyOf={u => String(u.id)}
renderItem={user => <b>{user.name}</b>}
/>
// user.name 有补全;写成 user.nmae 当场红线。换传 articles,T 又变成 Article
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):用公共的判别字段把所有合法状态逐一列出来,追求「非法状态不可表示」:
type RequestState<T> =
| { status: "idle" } // 还没开始
| { status: "loading" } // 请求中
| { status: "success"; data: T } // 成功:才有 data
| { status: "error"; message: string }; // 失败:才有 message
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?编译报错。
这招你们后端其实已经在用了——sealed interface + record 就是可辨识联合的 Java 版,switch 模式匹配就是上面的自动收窄,编译器同样逼你处理完每一种情况。
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 则在内部替你造好了这台状态机——先懂原理,再用工具,心里才有底。
工具类型与类型收窄:省一半的类型代码
工具类型:从源类型派生 DTO
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">; // 下拉选项:只要两个字段
Java 里你为 UserVO、UserCreateDTO、UserUpdateDTO 手工复制三份类,字段一改就漂移,靠 MapStruct 兜底。TS 的工具类型在编译期从同一源类型派生出所有变体——改一处、处处同步,零运行时成本。表单场景(第 21 篇)尤其好用。
类型收窄:和 Optional 判空一个直觉
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
收窄的手段不止 if:switch 判别字段(上一站)、typeof、in 运算符,都能让编译器把联合类型「切」到你正在用的那个分支。配合可辨识联合,绝大多数判空和状态判断都变成了编译期检查,而不是线上事故。
- 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 · 评论