React 组件 typing
这一课你将能做到
- 给函数组件的 Props、State、事件、ref、children 写完整类型
- 写出泛型 React 组件,让组件在复用时保留类型信息
- 处理 React 开发中最常见的类型场景:受控组件、表单、ref 转发、Context
为什么要学
React 是 TypeScript 最大的应用场景。没有类型,React 组件的 props 就是黑盒——传错一个字段,页面不渲染,你只能靠肉眼在 JSX 里逐行排查。有了类型,编辑器自动补全 props、事件签名、ref 类型,传错一个字段当场红线。
这一课是「TS 理论 → React 实战」的最后一公里。前面学的接口、联合、泛型、工具类型,全部在这里汇合。写不好 React 组件类型,等于前面白学。
先建立图像
React 组件的类型关注点:
┌─────────────────────────────────────┐
│ React 组件 │
│ │
│ ┌─────────┐ ┌───────────────┐ │
│ │ Props │───▶│ 组件内部状态 │ │
│ │ (外部输入)│ │ useState │ │
│ └─────────┘ └───────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────┐ ┌───────────────┐ │
│ │ 事件处理 │◀───│ 事件对象 │ │
│ │ onChange │ │ React.SyntheticEvent │
│ └─────────┘ └───────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────┐ │
│ │ Ref │───▶ DOM 元素 / 子组件实例 │
│ └─────────┘ │
└─────────────────────────────────────┘
核心原则:Props 用接口描述,事件用 React 内置类型,ref 用泛型指定目标元素,children 用 ReactNode。
跟着做
新建 react-demo.tsx,逐步实操。
步骤 1:最基础的组件 Props
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "primary" | "secondary" | "danger";
}
function Button({ label, onClick, disabled = false, variant = "primary" }: ButtonProps) {
const baseStyle: React.CSSProperties = {
padding: "8px 16px",
border: "none",
borderRadius: "4px",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
};
const variantStyles: Record<string, React.CSSProperties> = {
primary: { backgroundColor: "#007bff", color: "#fff" },
secondary: { backgroundColor: "#6c757d", color: "#fff" },
danger: { backgroundColor: "#dc3545", color: "#fff" },
};
return (
<button
style={{ ...baseStyle, ...variantStyles[variant] }}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
);
}
// 使用
function App() {
return (
<div>
<Button label="主要按钮" onClick={() => console.log("clicked")} />
<Button label="危险按钮" variant="danger" onClick={() => {}} />
{/* <Button label={123} onClick={() => {}} /> */}
{/* 报错:label 必须是 string */}
</div>
);
}
步骤 2:State 类型
import { useState } from "react";
function UserProfile() {
// 简单类型推断
const [name, setName] = useState(""); // string
const [age, setAge] = useState(0); // number
const [isEditing, setIsEditing] = useState(false); // boolean
// 复杂类型要显式标注
interface User {
id: number;
name: string;
}
const [user, setUser] = useState<User | null>(null); // 需要 null 的情况
// 数组类型
const [tags, setTags] = useState<string[]>([]);
// 联合类型状态机
type Status = "idle" | "loading" | "success" | "error";
const [status, setStatus] = useState<Status>("idle");
const handleLogin = () => {
setStatus("loading");
// 模拟 API
setTimeout(() => {
setUser({ id: 1, name: "小明" });
setStatus("success");
}, 500);
};
return (
<div>
<p>状态:{status}</p>
{user && <p>用户:{user.name}</p>}
<button onClick={handleLogin}>模拟登录</button>
</div>
);
}
步骤 3:事件类型
import { useState } from "react";
function Form() {
const [value, setValue] = useState("");
// input onChange 事件
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value); // e.target 是 HTMLInputElement,有 value 属性
};
// form onSubmit 事件
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log("提交:", value);
};
// button onClick 事件
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log("点击位置:", e.clientX, e.clientY);
};
// 键盘事件
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
console.log("按了回车");
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
<button type="submit" onClick={handleClick}>提交</button>
</form>
);
}
| 事件 | 类型 |
|---|
| onChange | React.ChangeEvent<T> |
| onClick | React.MouseEvent<T> |
| onSubmit | React.FormEvent<T> |
| onKeyDown | React.KeyboardEvent<T> |
| onFocus | React.FocusEvent<T> |
T 是元素类型:HTMLInputElement、HTMLButtonElement、HTMLFormElement 等。
步骤 4:ref 类型
import { useRef, useEffect } from "react";
function VideoPlayer() {
// 1. DOM ref:指定元素类型
const videoRef = useRef<HTMLVideoElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// 2. 存储普通值(不触发重渲染)
const timerRef = useRef<number | null>(null);
useEffect(() => {
// videoRef.current 被推断为 HTMLVideoElement | null
if (videoRef.current) {
videoRef.current.volume = 0.5;
}
}, []);
const startTimer = () => {
timerRef.current = window.setInterval(() => {
console.log("tick");
}, 1000);
};
const stopTimer = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
return (
<div>
<video ref={videoRef} src="/video.mp4" />
<input ref={inputRef} />
<button onClick={startTimer}>开始</button>
<button onClick={stopTimer}>停止</button>
</div>
);
}
步骤 5:children 类型
import { ReactNode } from "react";
interface CardProps {
title: string;
children: ReactNode; // 任何可渲染的内容
}
function Card({ title, children }: CardProps) {
return (
<div style={{ border: "1px solid #ddd", borderRadius: "8px", padding: "16px" }}>
<h3>{title}</h3>
<div>{children}</div>
</div>
);
}
// children 可以是任何 JSX
function App2() {
return (
<Card title="用户信息">
<p>姓名:小明</p>
<Button label="编辑" onClick={() => {}} />
</Card>
);
}
// 更严格的 children:只允许特定组件
interface TabsProps {
children: React.ReactElement<TabProps> | React.ReactElement<TabProps>[];
}
interface TabProps {
label: string;
}
function Tab({ label }: TabProps) {
return <div>{label}</div>;
}
function Tabs({ children }: TabsProps) {
return <div>{children}</div>;
}
步骤 6:泛型组件
// 通用 Select 组件,选项类型由调用方决定
interface Option<T> {
value: T;
label: string;
}
interface SelectProps<T> {
options: Option<T>[];
value: T;
onChange: (value: T) => void;
}
function Select<T>({ options, value, onChange }: SelectProps<T>) {
return (
<select
value={String(value)}
onChange={(e) => {
const selected = options.find((o) => String(o.value) === e.target.value);
if (selected) onChange(selected.value);
}}
>
{options.map((opt) => (
<option key={String(opt.value)} value={String(opt.value)}>
{opt.label}
</option>
))}
</select>
);
}
// 使用:数字选项
function App3() {
const [size, setSize] = useState(16);
return (
<Select
options={[
{ value: 12, label: "小号" },
{ value: 16, label: "中号" },
{ value: 20, label: "大号" },
]}
value={size}
onChange={setSize} // setSize 自动推断为 (value: number) => void
/>
);
}
步骤 7:Context 类型
import { createContext, useContext, useState, ReactNode } from "react";
interface ThemeContextType {
theme: "light" | "dark";
toggle: () => void;
}
// 1. 创建 Context 时给类型和默认值
const ThemeContext = createContext<ThemeContextType>({
theme: "light",
toggle: () => {},
});
// 2. Provider 组件
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light"));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
// 3. 消费组件
function ThemedButton() {
const { theme, toggle } = useContext(ThemeContext);
return (
<button
style={{
backgroundColor: theme === "light" ? "#fff" : "#333",
color: theme === "light" ? "#333" : "#fff",
}}
onClick={toggle}
>
当前主题:{theme}
</button>
);
}
function App4() {
return (
<ThemeProvider>
<ThemedButton />
</ThemeProvider>
);
}
讲清楚要点
- Props 用接口,State 用推断+注解:Props 是外部契约必须写清楚;State 简单类型让 TS 推断,复杂类型(对象/数组/联合)显式标注。
- 事件类型是 React 提供的泛型:
React.ChangeEvent<HTMLInputElement> 的泛型参数是 DOM 元素类型,不是值类型。写错元素类型会导致 e.target 没有对应属性。
- ref 的两种用途:DOM 引用(
useRef<HTMLInputElement>(null))和值存储(useRef<number>(0)),前者不触发重渲染,后者同样不触发。
- children 用
ReactNode:它是所有可渲染内容的联合(string、number、JSX、数组、null、undefined)。如果要限制 children 类型,用 ReactElement 或更精确的类型。
- 泛型组件的关键:
<T,>(.tsx 文件)或 <T extends unknown>,确保编译器不把 <T> 解析成 JSX。
- Context 类型三步走:
createContext<T>(defaultValue) → Provider value={...} → useContext 消费。默认值必须匹配类型,否则编译报错。
你可能会懵的地方
-
为什么 useState 有时要显式写类型?
当初始值不能代表完整类型时:useState(null) 推断为 null,但你后续要赋 User 对象。useState<User | null>(null) 让类型覆盖完整。
-
e.target 和 e.currentTarget 有什么区别?
target 是触发事件的元素(可能是子元素),currentTarget 是绑定事件的元素。input 上两者通常相同;嵌套元素点击时不同。类型上 currentTarget 更准确。
-
FC(FunctionComponent)类型要不要写?
不需要。React.FC 是历史遗留写法,现在直接写函数参数类型更简洁。FC 还会自动包含 children,有时反而多余。
-
泛型组件在 JSX 里怎么写?
<Select<number> options={...} />,但如果 options 里已经能推断出 number,可以省略 <number> 让 TS 自动推断。
-
forwardRef 的类型怎么写?
forwardRef<HTMLInputElement, Props>((props, ref) => ...),两个泛型参数:ref 目标类型、Props 类型。
错法对照
| 错法 | 问题 | 正解 |
|---|
Props 用 any | 失去类型保护 | 定义接口精确描述每个字段 |
useState(null) 后续赋对象 | 类型推断为 null,赋值报错 | useState<T | null>(null) |
| 事件 handler 参数不写类型 | 隐式 any,strict 下报错 | 写 React.ChangeEvent<HTMLInputElement> 等 |
ref 不给泛型 useRef(null) | current 推断为 null,无法调用方法 | useRef<HTMLInputElement>(null) |
children 写 any 或 JSX.Element | 太宽或太窄(不允许 string) | 用 ReactNode |
卡点急救
Property 'value' does not exist on type 'EventTarget':事件泛型参数错了。ChangeEvent<HTMLInputElement> 才有 value,HTMLButtonElement 没有。
- ref 报
Object is possibly 'null':useRef 初始值是 null,访问 .current 前判空或用 ?.。
- 泛型组件报
JSX element type does not have any construct signatures:<T> 被解析成 JSX。改成 <T,> 或 <T extends unknown>。
- Context 默认值类型不匹配:
createContext 的默认值必须完整匹配类型,或给类型加 | null 并在消费时判空。
面试常问
-
React 组件 Props 类型怎么写?
定义接口描述每个字段的类型、可选性、函数签名。配合 ReactNode 处理 children,React.CSSProperties 处理 style。
-
useState 的类型怎么标注?
简单类型靠推断:useState("") → string。复杂类型显式标注:useState<User | null>(null)、useState<string[]>([])。
-
React 事件类型有哪些?
ChangeEvent(onChange)、MouseEvent(onClick)、FormEvent(onSubmit)、KeyboardEvent(onKeyDown)、FocusEvent(onFocus)。泛型参数是 DOM 元素类型。
-
forwardRef 的类型怎么写?
forwardRef<RefType, PropsType>((props, ref) => ...)。ref 参数是 ForwardedRef<RefType>,实际上是 RefObject<T> | ((instance: T | null) => void) | null 的联合。
自测三问
React.ChangeEvent<HTMLInputElement> 和 React.ChangeEvent<HTMLButtonElement> 的 e.target 有什么区别?
- 为什么
useRef<HTMLVideoElement>(null) 访问 .current 时要判空,而 useRef<HTMLVideoElement>(document.createElement("video")) 不用?
- 写一个泛型组件
List<T>,接收 items: T[] 和 renderItem: (item: T) => ReactNode,并说明 <T,> 中逗号的作用。
小结一句话
React + TS 的核心是把 JSX 的「动态黑盒」变成「类型契约」:Props 接口化、事件泛型化、ref 精确化、children 用 ReactNode、复杂状态用联合——组件的每个输入输出都有编译器站岗。
延伸