大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
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、复杂状态用联合——组件的每个输入输出都有编译器站岗。
延伸
- React TypeScript Cheatsheet
- 下一课:异步与 API 类型——
Promise<T>、async/await、API 响应建模、zod 运行时校验。