TypeScript + React:类型安全的组件设计
第19章:TypeScript + React:类型安全的组件设计
TypeScript 在 React 中的价值不在于消灭所有 any,而在于将组件接口表达为可机器验证的契约。
本章核心问题:如何用类型系统精确描述组件接口?泛型组件和多态组件如何实现? 读完本章你将理解:
- 可辨识联合类型是变体组件最精确的类型建模方式
- React.FC 不再推荐,直接声明函数让 TypeScript 自然推断
- 泛型组件和 as prop 多态组件是设计系统的关键模式
Level 1 · 你需要知道的(1-3年经验)
React.FC 的兴衰
早期 React + TypeScript 教程几乎都这样写:
const MyComponent: React.FC<Props> = ({ name }) => {
return <div>{name}</div>;
};
React.FC(即 React.FunctionComponent)曾被认为是"正确"写法,但现在主流社区已经不推荐它了。原因有几个:
一、隐式包含 children。React.FC 在 React 17 及之前版本中自动给 Props 加上 children?: ReactNode,这让组件的接口不诚实——一个不应该接受子节点的组件,也会悄悄接受,且不报错。React 18 移除了这个行为,但历史包袱已经形成了混乱。
二、defaultProps 类型推断问题。React.FC 与 defaultProps 的类型推断存在已知缺陷,导致默认值无法正确收窄类型。
三、没有任何额外价值。普通函数组件已经能完整表达所有语义,React.FC 只是加了层没有实际收益的包装。
现代写法是直接声明函数,让 TypeScript 自然推断返回值:
// 推荐:简洁、诚实、无多余约束
function MyComponent({ name }: { name: string }) {
return <div>{name}</div>;
}
// 或使用独立 interface
interface MyComponentProps {
name: string;
}
function MyComponent({ name }: MyComponentProps) {
return <div>{name}</div>;
}
事件处理器类型
React 的合成事件系统有完整的 TypeScript 类型覆盖,关键是要知道用哪个类型。
// 鼠标事件:泛型参数是事件来源的 DOM 元素类型
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
console.log(e.currentTarget.value); // currentTarget 类型精确
}
// 输入变化:处理文本输入的标准写法
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value; // string,不需要 as string
}
// 表单提交
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
}
// 键盘事件
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
// ...
}
}
一个实用技巧:当你不确定某个事件处理器的类型,可以先写内联函数,让 TypeScript 自动推断,再用 typeof 或直接查看推断结果。
泛型组件
当一个组件需要对不同数据类型保持类型安全时,泛型组件是必要工具。最典型的例子是列表组件:
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
emptyState?: React.ReactNode;
}
function List<T>({ items, renderItem, keyExtractor, emptyState }: ListProps<T>) {
if (items.length === 0) {
return <>{emptyState ?? <p>暂无数据</p>}</>;
}
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>{renderItem(item, index)}</li>
))}
</ul>
);
}
// 使用时,TypeScript 自动推断 T 为 User
interface User {
id: string;
name: string;
}
<List<User>
items={users}
keyExtractor={(u) => u.id}
renderItem={(u) => <span>{u.name}</span>}
/>
注意:在 .tsx 文件中,泛型箭头函数 <T>() => ... 会被解析器误认为 JSX 标签。解决方案是写 <T,> 或 <T extends unknown>,或直接用 function 声明。
为 Hooks 标注类型
useState 的类型推断
大多数情况下,TypeScript 能从初始值推断出 useState 的类型:
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [user, setUser] = useState<User | null>(null); // 需要显式标注
当初始值是 null 或 undefined 时,必须显式提供泛型参数,否则推断出 null 类型会让后续的 setUser(someUser) 报错。
useRef 的重载
useRef 有三个重载,对应不同的使用场景:
// 场景一:引用 DOM 元素(初始值为 null,只读 .current)
const inputRef = useRef<HTMLInputElement>(null);
// inputRef.current 类型为 HTMLInputElement | null
// 赋值 inputRef.current = xxx 会报错(只读)
// 场景二:可变的命令式值(初始值非 null,可读写 .current)
const timerRef = useRef<number | undefined>(undefined);
// timerRef.current 可以赋值
// 场景三:不提供初始值(返回 MutableRefObject)
const valueRef = useRef<string>();
选错重载会导致奇怪的类型错误。记住:引用 DOM 元素始终传 null 作为初始值。
ReactNode、ReactElement 与 JSX.Element 的区别
这三个类型经常被混用,但它们有精确的语义差异:
-
JSX.Element:React.createElement的返回值,等价于React.ReactElement<any, any>。范围最窄。 -
React.ReactElement:表示一个 React 元素对象,可以带泛型参数指定 props 类型ReactElement<Props>。 -
React.ReactNode:范围最广,包含ReactElement | string | number | boolean | null | undefined | ReactFragment | ReactPortal。
实际工程中的选择原则:
// children prop:始终用 ReactNode,因为子节点可以是任何可渲染值
interface Props {
children: React.ReactNode;
}
// render prop 或 renderItem:如果要求返回的必须是元素(不能是字符串),用 ReactElement
interface Props {
renderHeader: () => React.ReactElement;
}
// 组件函数返回值:让 TypeScript 推断,无需手动标注
// 如果必须标注,用 JSX.Element(最常见约定)
function MyComponent(): JSX.Element {
return <div />;
}
Level 2 · 它是怎么运行的(3-5年经验)
as prop:多态组件的类型安全实现
多态组件允许消费方指定渲染的根元素类型,同时保持对应元素属性的类型安全。这在设计系统中非常常见:
type AsProp<C extends React.ElementType> = {
as?: C;
};
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
type PolymorphicComponentProps<
C extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
// 使用:
interface TextOwnProps {
size?: 'sm' | 'md' | 'lg';
weight?: 'normal' | 'bold';
}
type TextProps<C extends React.ElementType> = PolymorphicComponentProps<C, TextOwnProps>;
function Text<C extends React.ElementType = 'span'>({
as,
size = 'md',
weight = 'normal',
children,
...rest
}: TextProps<C>) {
const Component = as ?? 'span';
return (
<Component className={`text-${size} font-${weight}`} {...rest}>
{children}
</Component>
);
}
// 渲染为 a 标签时,href 变为合法且有类型约束的 prop
<Text as="a" href="https://example.com" size="lg">链接文字</Text>
// 渲染为 h1 时,href 不存在,传入会报错
<Text as="h1" size="lg">标题文字</Text>
这个模式的实现较为复杂,但一旦封装好,消费方使用起来非常流畅,且类型完全安全。
Level 3 · 规范怎么定义的(资深)
Props 类型设计:必选、可选与变体
Props 是组件的公共接口。设计良好的 Props 类型能让消费方一眼读懂组件契约,也能在编译期拦截大量错误。
必选与可选的权衡
interface ButtonProps {
children: React.ReactNode; // 必选:按钮必须有内容
onClick: () => void; // 必选:按钮的核心行为
disabled?: boolean; // 可选:默认 false
variant?: 'primary' | 'ghost'; // 可选:默认 primary
className?: string; // 可选:外部样式覆盖
}
可选 Props 要给默认值,而不是在组件内到处写 props.variant ?? 'primary'。在函数签名里解构并指定默认值是更惯用的做法:
function Button({
children,
onClick,
disabled = false,
variant = 'primary',
className,
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant} ${className ?? ''}`}
>
{children}
</button>
);
}
可辨识联合:变体组件的正确建模
当一个组件有多种"形态",且不同形态下 Props 的结构完全不同时,可辨识联合(Discriminated Union)是最精确的类型建模方式。
假设有一个 Alert 组件,error 类型需要传入错误对象,confirm 类型需要传入确认回调,而 info 类型两者都不需要:
type AlertProps =
| {
type: 'info';
message: string;
}
| {
type: 'error';
message: string;
error: Error;
onRetry?: () => void;
}
| {
type: 'confirm';
message: string;
onConfirm: () => void;
onCancel: () => void;
};
function Alert(props: AlertProps) {
// TypeScript 在每个分支里都能精确收窄类型
if (props.type === 'error') {
console.error(props.error); // 类型安全,props.error 一定存在
}
if (props.type === 'confirm') {
// props.onConfirm 一定存在,props.error 不存在
}
// ...
}
这种模式的好处是:消费方在传入 type="error" 时,TypeScript 会强制要求传入 error 字段;传入 type="info" 时,传入 error 字段则会报错。这是用类型系统替代运行时防御性检查的典型案例。
React 19 新特性的类型支持
React 19 引入了若干新 API,TypeScript 类型定义也随之更新。
use Hook 的类型:
import { use } from 'react';
// use 接受 Promise 或 Context
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // 类型推断为 User,非 Promise<User>
return <div>{user.name}</div>;
}
Server Actions 的类型:
// Server Action 是异步函数,接受 FormData 或自定义参数
async function submitForm(formData: FormData): Promise<{ success: boolean }> {
'use server';
// ...
return { success: true };
}
// 在客户端组件中使用
<form action={submitForm}>
<input name="email" type="email" />
<button type="submit">提交</button>
</form>
useOptimistic 的类型推断:
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages, // 初始状态,类型为 Message[]
(state: Message[], newMessage: Message) => [...state, newMessage]
// 返回值类型自动推断为 Message[]
);
Level 4 · 边界与陷阱(所有人)
常见类型陷阱与避坑指南
陷阱一:事件处理器中 e.target 与 e.currentTarget 的区别。e.target 是实际触发事件的元素,类型是 EventTarget(非常宽泛);e.currentTarget 是绑定事件的元素,类型是精确的 DOM 元素类型。处理输入值时,应当用 e.currentTarget.value 或 (e.target as HTMLInputElement).value。
陷阱二:条件渲染的类型收窄。condition && <Component /> 当 condition 是数字 0 时,React 会渲染 0 而非不渲染。类型安全的写法是 condition ? <Component /> : null 或将条件转为布尔值 !!count && <Component />。
陷阱三:Object.keys 的类型。Object.keys(obj) 返回 string[] 而非 (keyof typeof obj)[],这是 TypeScript 刻意的设计(对象可能有运行时额外属性)。需要用 (Object.keys(obj) as (keyof typeof obj)[]) 进行断言。
陷阱四:组件 ref 的类型。对于自定义组件,useRef<MyComponent>(null) 中的泛型参数应当是实例类型,但函数组件没有实例。正确做法是用 useImperativeHandle 暴露命令式 API,并用 React.useRef<{ focus: () => void }>(null) 来引用。
TypeScript 在 React 中的价值不在于消灭所有 any,而在于将组件接口表达为可机器验证的契约。当你能用类型系统准确描述一个组件"能接受什么、不能接受什么"时,整个团队的协作效率和代码可维护性都会显著提升。