React 与 TypeScript

用类型系统为组件加上安全护栏

🎯 学习目标

学完本章,你将能够:

  • 能够为 React 组件的 Props、State 和事件处理添加类型注解
  • 能够正确使用 useState、useRef、useReducer 等内置 Hook 的类型
  • 能够使用泛型组件与 forwardRef 实现类型安全的组件复用
  • 能够构建类型安全的 Context 与自定义 Hook
  • 能够使用 React Router 类型与 react-hook-form 实现类型安全路由与表单

你有没有想过

为什么组件传错一个 prop,运行时才报错?能不能在写代码的时候就发现?

React 的 Props、State、事件处理——到处都是"数据流动"。如果类型不匹配,轻则页面显示异常,重则直接白屏。TypeScript 就是你的编译时安全网,写代码时就能发现类型错误。

本章从项目创建到 Hooks 类型、事件处理、Context 泛型,逐一讲透 React + TS 的核心用法。

Part 1: 项目与 Props

创建项目,定义组件的"输入接口"。

万事开头难。先把项目跑起来,再搞清楚怎么用 TypeScript 定义组件的 Props——这是 React + TS 的第一步。

学完本节,你会知道如何用 interface 约束组件参数,让编辑器在写代码时就帮你检查传参是否正确。

创建项目

一行命令拿到完整的 React + TS 项目

$ npm create vite@latest my-react-app -- --template react-ts
$ cd my-react-app && npm install && npm run dev

核心依赖:

依赖作用
reactReact 核心库
react-domReact DOM 渲染器
@types/reactReact 类型定义
@types/react-domReactDOM 类型定义

项目结构:

src/
├── main.tsx         # 入口:ReactDOM.createRoot
├── App.tsx          # 根组件
├── components/      # 组件目录
├── hooks/           # 自定义 Hook
├── types/           # 类型定义
└── utils/           # 工具函数

组件 Props 类型

用 interface 定义组件的"输入规格"

Props 就是组件的参数。用 TypeScript 定义 Props 类型,编辑器就能帮你检查传参是否正确。

// 推荐:用 interface 定义 Props
interface UserCardProps {
    name: string;
    age: number;
    email?: string;                    // 可选属性
    onClick?: (id: string) => void;    // 回调函数
}

function UserCard({ name, age, email, onClick }: UserCardProps) {
    return (
        <div onClick={() => onClick?.(name)}>
            <h2>{name}</h2>
            <p>{age}岁</p>
            {email && <p>{email}</p>}
        </div>
    );
}
关于 React.FC:虽然可以用 const Comp: React.FC<Props> 的写法,但现在社区更推荐直接用普通函数 + interface 的方式。更简洁,泛型支持也更好。

children 与常用类型

React 提供了一组专用类型

// children 的类型
interface LayoutProps {
    children: React.ReactNode;   // 最通用的 children 类型
    title: string;
}

function Layout({ children, title }: LayoutProps) {
    return (
        <div>
            <h1>{title}</h1>
            {children}
        </div>
    );
}
类型用途
React.ReactNode所有可渲染内容(string|number|JSX|null|undefined)
React.ReactElementJSX 元素
React.ComponentType组件类型(类组件或函数组件)
React.CSSPropertiesCSS 属性对象
React.Ref<T>ref 类型

Part 2: Hooks 类型

useState、useReducer、useRef——每个 Hook 都有对应的类型写法。

Hooks 是 React 的灵魂。TypeScript 能为每个 Hook 提供精确的类型推断,让 dispatch、ref.current 等都不再是 any。

本节还会讲解 useEffect、useCallback、useMemo 的类型要点——它们各有各的坑。

useState 类型

简单类型自动推断,复杂类型手动指定

// 自动推断
const [count, setCount] = useState(0);              // number
const [name, setName] = useState("张三");            // string

// 手动指定——联合类型或复杂类型
const [user, setUser] = useState<User | null>(null); // 需要手动指定
const [list, setList] = useState<string[]>([]);      // 空数组需要指定

规则很简单:初始值能推断出类型就不用写,推断不出或推断错了就手动指定泛型参数。

useReducer 类型

用联合类型确保 dispatch 的类型安全

// 定义 Action 的联合类型
type Action =
    | { type: "increment"; payload: number }
    | { type: "decrement"; payload: number }
    | { type: "reset" };

function reducer(state: number, action: Action): number {
    switch (action.type) {
        case "increment": return state + action.payload;
        case "decrement": return state - action.payload;
        case "reset": return 0;
    }
}

const [count, dispatch] = useReducer(reducer, 0);
dispatch({ type: "increment", payload: 5 }); // OK
// dispatch({ type: "increment" });           // 报错!缺少 payload
// dispatch({ type: "reset", payload: 0 });   // 报错!reset 不需要 payload

联合类型 + switch 穷举 = 完整的类型安全。编辑器会在你写 dispatch 的时候提示所有合法的 Action。

useRef 类型

三种典型用法

// 用法一:引用 DOM 元素
const inputRef = useRef<HTMLInputElement>(null);
const focus = () => inputRef.current?.focus();
return <input ref={inputRef} />;

// 用法二:保存可变值(定时器 ID 等)
const timerRef = useRef<ReturnType<typeof setInterval>>();

// 用法三:保存实例引用
const instanceRef = useRef<MyClass | null>(null);

useRef 和 useState 的关键区别:修改 ref.current 不会触发重新渲染。适合保存不影响 UI 的"幕后数据"。

useEffect 类型

副作用函数的返回值只能是 void 或清理函数

useEffect 的回调函数不能是 async——因为 async 函数返回 Promise,而 useEffect 期望返回 void | (() => void)。

// 返回类型:void | (() => void)
useEffect(() => {
    const controller = new AbortController();

    // 正确:在内部用 async
    async function loadData() {
        try {
            const res = await fetch("/api/users", {
                signal: controller.signal,
            });
            const data: User[] = await res.json();
            setUsers(data);
        } catch (err) {
            if (err instanceof DOMException && err.name === "AbortError") {
                return; // 组件卸载,忽略
            }
            setError((err as Error).message);
        }
    }
    loadData();

    // 清理函数:取消未完成的请求
    return () => controller.abort();
}, []); // 依赖数组
常见错误:直接写 useEffect(async () => { ... }, []) 会导致类型报错。正确做法是在 Effect 内部定义 async 函数再调用。

依赖数组与 ESLint

exhaustive-deps 规则帮你查漏补缺

依赖数组写错了,Effect 的行为就会和预期不同。ESLint 的 react-hooks/exhaustive-deps 规则能帮你检查。

// ESLint 配置
// .eslintrc.json
{
    "plugins": ["react-hooks"],
    "rules": {
        "react-hooks/exhaustive-deps": "warn"
    }
}

依赖缺失(危险)

// userId 变了,但 Effect 不会重新执行
useEffect(() => {
    fetchUser(userId);
}, []); // 缺少 userId

依赖正确(安全)

// userId 变了,Effect 会重新执行
useEffect(() => {
    fetchUser(userId);
}, [userId]); // 完整依赖

经验法则:Effect 里用到的所有外部变量都应该放进依赖数组。如果不想频繁触发,用 useMemo 或 useRef 缓存那个变量。

useCallback 类型

缓存回调函数,避免子组件不必要的重渲染

大多数情况下 TypeScript 能自动推断 useCallback 的类型。但如果回调的参数类型复杂,可以手动指定泛型。

// 自动推断——大多数情况够用
const handleClick = useCallback(() => {
    setCount(c => c + 1);
}, []);

// 手动指定——事件回调参数需要明确类型
const handleChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>(
    (e) => {
        setSearch(e.target.value); // e 的类型自动推断
    },
    []
);

// 等价写法:直接在参数上标注类型
const handleSubmit = useCallback(
    (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
    },
    []
);
什么时候用? 只有当回调被传给子组件(尤其是 React.memo 包裹的子组件)时,useCallback 才有意义。如果只在当前组件内使用,加了反而增加代码复杂度。

useMemo 类型

缓存计算结果,避免重复执行昂贵操作

// 自动推断——返回值类型从回调函数推断
const sortedList = useMemo(
    () => items.sort((a, b) => a.name.localeCompare(b.name)),
    [items]
); // 推断为 Item[]

// 手动指定——复杂返回值需要明确类型
const stats = useMemo<{ total: number; avg: number }>(() => {
    const total = scores.reduce((sum, s) => sum + s, 0);
    return { total, avg: total / scores.length };
}, [scores]);

// 过滤 + 转换的常见模式
const filteredUsers = useMemo(() => {
    return users
        .filter(u => u.age >= 18)
        .map(u => ({ ...u, displayName: `${u.name} (${u.age})` }));
}, [users]); // 推断为 { id: string; name: string; age: number; displayName: string }[]

性能 vs 可读性:useMemo 不是免费的——它每次渲染都要对比依赖。如果计算本身很轻量(比如简单的 .map),用 useMemo 反而更慢。只在"计算昂贵"或"引用稳定"有实际意义时才用。

Part 3: 事件与表单

每个 DOM 事件都有对应的 React 类型。

表单是用户交互的核心。React 把原生 DOM 事件包装成了 SyntheticEvent,每个事件类型都有对应的泛型。

本节从事件类型讲起,延伸到完整表单的类型安全方案,包括受控组件和表单验证模式。

事件处理类型

React 自带一套完整的事件类型定义

function Form() {
    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        // 表单提交逻辑
    };

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        console.log(e.target.value); // string
    };

    const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
        console.log("clicked");
    };

    const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
        if (e.key === "Enter") console.log("回车");
    };

    return (
        <form onSubmit={handleSubmit}>
            <input onChange={handleChange} onKeyDown={handleKeyDown} />
            <button onClick={handleClick}>提交</button>
        </form>
    );
}

事件类型速查

六个最常用的事件类型

事件类型触发场景常用属性
FormEvent表单提交e.preventDefault()
ChangeEvent输入变化e.target.value
MouseEvent鼠标事件e.clientX/Y
KeyboardEvent键盘事件e.key
FocusEvent焦点事件e.target
DragEvent拖拽事件e.dataTransfer

受控表单类型

用类型约束表单状态,杜绝拼写错误

interface FormData {
    username: string;
    email: string;
    age: number;
    role: "admin" | "user" | "guest";
}

function TypedForm() {
    const [form, setForm] = useState<FormData>({
        username: "",
        email: "",
        age: 0,
        role: "guest",
    });
    const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});

    const updateField = <K extends keyof FormData>(
        key: K,
        value: FormData[K]
    ) => {
        setForm(prev => ({ ...prev, [key]: value }));
        setErrors(prev => ({ ...prev, [key]: undefined }));
    };

    const validate = (): boolean => {
        const newErrors: typeof errors = {};
        if (!form.username.trim()) newErrors.username = "用户名不能为空";
        if (!form.email.includes("@")) newErrors.email = "邮箱格式错误";
        if (form.age < 0 || form.age > 150) newErrors.age = "年龄不合理";
        setErrors(newErrors);
        return Object.keys(newErrors).length === 0;
    };

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (validate()) console.log("提交", form);
    };

    return (
        <form onSubmit={handleSubmit}>
            <input value={form.username}
                onChange={e => updateField("username", e.target.value)} />
            {errors.username && <span>{errors.username}</span>}
            <input value={form.email}
                onChange={e => updateField("email", e.target.value)} />
            <input type="number" value={form.age}
                onChange={e => updateField("age", Number(e.target.value))} />
            <select value={form.role}
                onChange={e => updateField("role", e.target.value as FormData["role"])}>
                <option value="admin">管理员</option>
                <option value="user">用户</option>
                <option value="guest">访客</option>
            </select>
            <button type="submit">提交</button>
        </form>
    );
}

react-hook-form

社区最流行的表单方案,类型支持一流

受控表单在字段多时性能下降。react-hook-form 用非受控方式管理表单,减少不必要的渲染。

import { useForm } from "react-hook-form";

interface LoginForm {
    email: string;
    password: string;
    remember: boolean;
}

function LoginForm() {
    const { register, handleSubmit, formState: { errors } } =
        useForm<LoginForm>({
            defaultValues: { email: "", password: "", remember: false },
        });

    const onSubmit = (data: LoginForm) => {
        console.log("登录", data);
    };

    return (
        <form onSubmit={handleSubmit(onSubmit)}>
            <input {...register("email", {
                required: "请输入邮箱",
                pattern: { value: /\S+@\S+/, message: "邮箱格式错误" }
            })} />
            {errors.email && <span>{errors.email.message}</span>}

            <input type="password" {...register("password", {
                required: "请输入密码",
                minLength: { value: 6, message: "至少6位" }
            })} />
            {errors.password && <span>{errors.password.message}</span>}

            <label>
                <input type="checkbox" {...register("remember")} />
                记住我
            </label>
            <button type="submit">登录</button>
        </form>
    );
}

useForm<LoginForm> 一行搞定泛型。register 返回的 ref、onChange 等属性会自动绑定到 input 上,字段名都有类型检查——拼错了立刻报红。

Part 4: Context 与泛型

跨组件传递数据,类型安全不能丢。

Context 解决了"逐层传 Props"的痛点,但它的默认值机制容易引入 null。用泛型封装后,消费者可以放心使用。

本节还会展示 Context + useReducer 的组合模式——这是 React 社区最常用的全局状态方案。

类型安全的 Context

三步走:定义类型、创建 Context、封装 Hook

// 1. 定义 Context 的数据类型
interface Theme {
    mode: "light" | "dark";
    toggle: () => void;
}

// 2. 创建 Context
const ThemeContext = createContext<Theme | null>(null);

// 3. 封装自定义 Hook(带空值检查)
function useTheme(): Theme {
    const ctx = useContext(ThemeContext);
    if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
    return ctx;
}

为什么要封装成 Hook?因为 useContext 返回的是 Theme | null,每次用都要判空。封装后返回 Theme,使用时不再需要判空。

Provider 与使用

Provider 提供数据,消费者通过 Hook 获取

// Provider 组件
function ThemeProvider({ children }: { children: React.ReactNode }) {
    const [mode, setMode] = useState<"light" | "dark">("light");
    const toggle = () => setMode(m => m === "light" ? "dark" : "light");

    return (
        <ThemeContext.Provider value={{ mode, toggle }}>
            {children}
        </ThemeContext.Provider>
    );
}

// 在任意子组件中使用——完整类型推断
function ThemedButton() {
    const { mode, toggle } = useTheme();
    return (
        <button onClick={toggle}
            style={{ background: mode === "dark" ? "#333" : "#fff" }}>
            切换主题
        </button>
    );
}

Context + Reducer

用联合类型 Action 构建类型安全的全局状态

Context + useReducer 是 React 社区最常用的"轻量级状态管理"方案。类型写对了,dispatch 时编辑器会提示所有合法操作。

// 1. 定义 State 和 Action
interface Todo {
    id: string;
    text: string;
    done: boolean;
}

type TodoAction =
    | { type: "add"; text: string }
    | { type: "toggle"; id: string }
    | { type: "delete"; id: string }
    | { type: "clear_completed" };

interface TodoState {
    todos: Todo[];
    nextId: number;
}

// 2. Reducer 函数
function todoReducer(state: TodoState, action: TodoAction): TodoState {
    switch (action.type) {
        case "add":
            return {
                ...state,
                todos: [...state.todos,
                    { id: String(state.nextId), text: action.text, done: false }],
                nextId: state.nextId + 1,
            };
        case "toggle":
            return {
                ...state,
                todos: state.todos.map(t =>
                    t.id === action.id ? { ...t, done: !t.done } : t
                ),
            };
        case "delete":
            return { ...state, todos: state.todos.filter(t => t.id !== action.id) };
        case "clear_completed":
            return { ...state, todos: state.todos.filter(t => !t.done) };
    }
}

全局状态封装

Provider 组件 + 自定义 Hook = 完美的类型安全

// 3. 创建 Context(值为 state + dispatch)
const TodoContext = createContext<{
    state: TodoState;
    dispatch: React.Dispatch<TodoAction>;
} | null>(null);

// 4. Provider 组件
function TodoProvider({ children }: { children: React.ReactNode }) {
    const [state, dispatch] = useReducer(todoReducer, {
        todos: [],
        nextId: 1,
    });
    return (
        <TodoContext.Provider value={{ state, dispatch }}>
            {children}
        </TodoContext.Provider>
    );
}

// 5. 自定义 Hook:空值保护
function useTodo() {
    const ctx = useContext(TodoContext);
    if (!ctx) throw new Error("useTodo must be inside TodoProvider");
    return ctx;
}

// 6. 使用
function TodoList() {
    const { state, dispatch } = useTodo();
    return (
        <ul>
            {state.todos.map(t => (
                <li key={t.id} onClick={() => dispatch({ type: "toggle", id: t.id })}
                    style={{ textDecoration: t.done ? "line-through" : "none" }}>
                    {t.text}
                    <button onClick={e => { e.stopPropagation(); dispatch({ type: "delete", id: t.id }); }}>
                        删除
                    </button>
                </li>
            ))}
        </ul>
    );
}

这个模式的核心:useTodo() 返回的 dispatch 有完整类型约束——你只能传入合法的 Action 对象,字段名、类型全部有检查。

自定义 Hook 泛型

用泛型让 Hook 支持任意数据类型

// 泛型 Hook:封装数据获取逻辑
interface UseFetchResult<T> {
    data: T | null;
    loading: boolean;
    error: string | null;
    refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    const fetchData = useCallback(async () => {
        setLoading(true);
        setError(null);
        try {
            const res = await fetch(url);
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            const json = await res.json();
            setData(json as T);
        } catch (err) {
            setError(err instanceof Error ? err.message : "未知错误");
        } finally {
            setLoading(false);
        }
    }, [url]);

    useEffect(() => { fetchData(); }, [fetchData]);

    return { data, loading, error, refetch: fetchData };
}

// 使用——data 类型自动推断
interface User { id: string; name: string; }
const { data, loading, error } = useFetch<User[]>("/api/users");
// data: User[] | null

Part 5: ref 转发

让子组件暴露方法给父组件调用。

ref 默认只在原生 DOM 元素上有效。forwardRef 让你可以把 ref 传进自定义组件。

搭配 useImperativeHandle,你还能精确控制子组件暴露哪些方法,实现类型安全的命令式调用。

forwardRef

把 ref 从父组件"转发"进子组件

interface InputProps {
    label: string;
    placeholder?: string;
}

const Input = forwardRef<HTMLInputElement, InputProps>(
    ({ label, placeholder }, ref) => (
        <div>
            <label>{label}</label>
            <input ref={ref} placeholder={placeholder} />
        </div>
    )
);

forwardRef<HTMLInputElement, InputProps> 的两个泛型参数:第一个是 ref 指向的类型,第二个是 Props 类型。

React 19 起推荐更简单的写法:ref 直接当 prop 传。从 React 19 开始,函数组件可以直接接收 ref 作为普通 prop,forwardRef 已被官方标记为"将来废弃"(仅老项目兼容用)。上面的等价现代写法:
// React 19+:ref 作为普通 prop 接收,无需 forwardRef
import { type Ref } from "react";

interface InputProps {
    label: string;
    placeholder?: string;
    ref?: Ref<HTMLInputElement>;   // 直接在 Props 里声明 ref
}

function Input({ label, placeholder, ref }: InputProps) {
    return (
        <div>
            <label>{label}</label>
            <input ref={ref} placeholder={placeholder} />
        </div>
    );
}

useImperativeHandle

自定义暴露给父组件的方法

// 子组件:定义暴露的接口
interface InputHandle {
    focus: () => void;
    clear: () => void;
    getValue: () => string;
}

const ControlledInput = forwardRef<InputHandle, InputProps>(
    ({ label }, ref) => {
        const inputRef = useRef<HTMLInputElement>(null);
        const [value, setValue] = useState("");

        useImperativeHandle(ref, () => ({
            focus: () => inputRef.current?.focus(),
            clear: () => setValue(""),
            getValue: () => value,
        }));

        return <input ref={inputRef} value={value}
            onChange={e => setValue(e.target.value)} />;
    }
);

// 父组件:通过 ref 调用子组件方法
function Parent() {
    const inputRef = useRef<InputHandle>(null);
    return (
        <>
            <ControlledInput ref={inputRef} label="姓名" />
            <button onClick={() => inputRef.current?.focus()}>聚焦</button>
            <button onClick={() => inputRef.current?.clear()}>清空</button>
        </>
    );
}
React 19 提示:这里 useImperativeHandle 配合 forwardRef 暴露自定义 handle 的模式仍然有效,但 React 19 同样支持把 ref 当普通 prop 接收(类型为 Ref<InputHandle>),省掉 forwardRef 包裹。新项目建议直接用 ref-as-prop 写法。

泛型组件

让一个组件支持多种数据类型

当你需要一个"通用列表"、"通用选择器"时,泛型组件就派上用场了。用 <T> 让组件适配任意数据类型。

// 泛型列表组件——渲染任意类型的数组
interface ListProps<T> {
    items: T[];
    renderItem: (item: T, index: number) => React.ReactNode;
    keyExtractor: (item: T) => string;
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
    return (
        <ul>
            {items.map((item, i) => (
                <li key={keyExtractor(item)}>{renderItem(item, i)}</li>
            ))}
        </ul>
    );
}

// 使用——类型自动推断
interface User { id: string; name: string; }
const users: User[] = [{ id: "1", name: "张三" }, { id: "2", name: "李四" }];

<List
    items={users}
    renderItem={user => <span>{user.name}</span>}  // user 自动推断为 User
    keyExtractor={user => user.id}
/>

泛型 Select

类型安全的下拉选择组件

// 泛型 Select:选项值可以是任意类型
interface SelectProps<T> {
    options: { label: string; value: T }[];
    value: T;
    onChange: (value: T) => void;
}

function Select<T extends string | number>(
    { options, value, onChange }: SelectProps<T>
) {
    return (
        <select
            value={String(value)}
            onChange={e => {
                const raw = e.target.value;
                const selected = options.find(o => String(o.value) === raw);
                if (selected) onChange(selected.value);
            }}
        >
            {options.map(opt => (
                <option key={String(opt.value)} value={String(opt.value)}>
                    {opt.label}
                </option>
            ))}
        </select>
    );
}

// 使用——value 和 onChange 的类型自动推断
type Role = "admin" | "user" | "guest";
const [role, setRole] = useState<Role>("guest");

<Select
    options={[
        { label: "管理员", value: "admin" as Role },
        { label: "用户", value: "user" as Role },
        { label: "访客", value: "guest" as Role },
    ]}
    value={role}
    onChange={setRole}  // setRole 的类型是 (value: Role) => void
/>
什么时候用泛型? 当组件的 Props 类型之间有依赖关系时(比如 options 的 value 类型和 onChange 参数类型必须一致)。如果各 Props 独立,用联合类型就够了。

Part 6: 路由与性能

URL 参数和查询参数也需要类型安全。

路由参数默认是 string | undefined,查询参数永远是字符串——如果不加类型约束,很容易出 bug。

本节还补充了 React.memo 的类型要点:什么时候该用、什么时候不该用、自定义比较函数怎么写。

React Router 类型

useParams 和 useSearchParams

import { useParams, useSearchParams, Link } from "react-router-dom";

// useParams —— 获取路由参数
function UserProfile() {
    const { userId } = useParams<{ userId: string }>();
    if (!userId) return <div>用户ID缺失</div>;
    return <div>用户: {userId}</div>;
}

// useSearchParams —— 获取查询参数
function ProductList() {
    const [searchParams, setSearchParams] = useSearchParams();
    const page = Number(searchParams.get("page")) || 1;

    const goToPage = (p: number) => {
        setSearchParams(prev => {
            prev.set("page", String(p));
            return prev;
        });
    };

    return (
        <div>
            <p>第 {page} 页</p>
            <button onClick={() => goToPage(page + 1)}>下一页</button>
        </div>
    );
}

React.memo

浅比较 Props,跳过不必要的渲染

当父组件重新渲染时,所有子组件默认也会重新渲染。React.memo 让子组件在 Props 没变时跳过渲染。

// 基本用法:Props 类型自动从泛型推断
interface ExpensiveListProps {
    items: Item[];
    onSelect: (id: string) => void;
}

const ExpensiveList = React.memo(function ExpensiveList(
    { items, onSelect }: ExpensiveListProps
) {
    console.log("渲染 ExpensiveList");
    return (
        <ul>
            {items.map(item => (
                <li key={item.id} onClick={() => onSelect(item.id)}>
                    {item.name}
                </li>
            ))}
        </ul>
    );
});

// 自定义比较函数
const StableList = React.memo(function StableList(
    props: { data: ComplexData[] }
) {
    return <div>{props.data.length} 条数据</div>
}, (prevProps, nextProps) => {
    // 返回 true = 跳过渲染(Props 没变)
    // 返回 false = 重新渲染(Props 变了)
    return prevProps.data.length === nextProps.data.length
        && prevProps.data.every((d, i) => d.id === nextProps.data[i].id);
});
对象 Props 的坑:React.memo 做的是浅比较。如果每次渲染都传新的对象/数组/函数引用,memo 会失效。配合 useMemo 和 useCallback 才能真正起效。

常见排错

类型报错不是"建议",是"错误"。 不要用 any 或 @ts-ignore 绕过类型检查。理解报错原因,写出真正类型安全的代码。

问题一:Props 解构时某个属性报"可能为 undefined"

原因:可选属性(带 ? 的)在 TS 中类型包含 undefined。

修复:使用前加判断,或用默认值:const { name = "默认名" } = props;

问题二:useRef 报"可能为 null"

原因:useRef<HTMLInputElement>(null) 的 current 类型是 HTMLInputElement | null。

修复:使用可选链 inputRef.current?.focus(),或在事件回调中确认非空后使用 ! 断言。

问题三:children 报类型错误

原因:没有声明 children 的类型。

修复:在 Props 接口中加上 children: React.ReactNode。

问题四:useEffect 里直接写 async 报错

原因:async 函数返回 Promise,而 useEffect 要求返回 void | (() => void)。

修复:在 Effect 内部定义 async 函数再调用,如 useEffect(() => { async function load() { ... } load(); }, [])。

问题五:泛型组件报"找不到名称 T"

原因:函数声明缺少泛型参数,或者用箭头函数时泛型写法不对。

修复:确保函数签名有 <T>,如 function List<T>(props: ListProps<T>)。箭头函数需要写成 <T,> 避免和 JSX 混淆。

问题六:React.memo 没起效,子组件还是频繁渲染

原因:每次渲染都传了新的对象/数组/函数引用,浅比较判定 Props 变了。

修复:用 useMemo 缓存对象和数组,用 useCallback 缓存回调函数,确保稳定引用。

试一试

练习一

任务:创建一个 TodoItem 组件,Props 包含 title(string)、done(boolean,默认 false)、onToggle(回调函数)。点击时切换完成状态。

// 提示:定义 interface TodoItemProps
// 你的代码:

参考答案

interface TodoItemProps {
    title: string;
    done?: boolean;
    onToggle: () => void;
}

function TodoItem({ title, done = false, onToggle }: TodoItemProps) {
    return (
        <div onClick={onToggle}
            style={{ textDecoration: done ? "line-through" : "none" }}>
            {title}
        </div>
    );
}

练习二

任务:用 useReducer 实现一个计数器,支持三种操作:加 1、减 1、重置为 0。确保 dispatch 的类型安全。

// 提示:定义 Action 联合类型
// 你的代码:

参考答案

type Action =
    | { type: "increment" }
    | { type: "decrement" }
    | { type: "reset" };

function reducer(state: number, action: Action): number {
    switch (action.type) {
        case "increment": return state + 1;
        case "decrement": return state - 1;
        case "reset": return 0;
    }
}

function Counter() {
    const [count, dispatch] = useReducer(reducer, 0);
    return (
        <>
            <p>{count}</p>
            <button onClick={() => dispatch({ type: "increment" })}>+1</button>
            <button onClick={() => dispatch({ type: "decrement" })}>-1</button>
            <button onClick={() => dispatch({ type: "reset" })}>重置</button>
        </>
    );
}

练习三

任务:创建一个 Context + useReducer 的主题管理器。State 包含 theme("light" | "dark" | "system")和 fontSize(number)。Action 支持切换主题和调整字号。封装 useThemeContext() Hook,在 Provider 外使用时抛出错误。

// 提示:定义 ThemeState 和 ThemeAction
// 封装 ThemeProvider 和 useThemeContext
// 你的代码:

参考答案

interface ThemeState {
    theme: "light" | "dark" | "system";
    fontSize: number;
}

type ThemeAction =
    | { type: "set_theme"; theme: ThemeState["theme"] }
    | { type: "set_fontSize"; size: number };

function themeReducer(state: ThemeState, action: ThemeAction): ThemeState {
    switch (action.type) {
        case "set_theme": return { ...state, theme: action.theme };
        case "set_fontSize": return { ...state, fontSize: action.size };
    }
}

const ThemeCtx = createContext<{ state: ThemeState; dispatch: React.Dispatch<ThemeAction> } | null>(null);

function ThemeProvider({ children }: { children: React.ReactNode }) {
    const [state, dispatch] = useReducer(themeReducer, {
        theme: "system", fontSize: 14,
    });
    return <ThemeCtx.Provider value={{ state, dispatch }}>{children}</ThemeCtx.Provider>;
}

function useThemeContext() {
    const ctx = useContext(ThemeCtx);
    if (!ctx) throw new Error("useThemeContext must be inside ThemeProvider");
    return ctx;
}

📝 要点总结

本章核心知识点

组件类型

Props 接口定义、children 类型、FC 函数组件类型

Hook 类型

useState 泛型、useRef 可变/不可变、useReducer 完整类型

事件与表单

ChangeEvent、MouseEvent 等事件类型,受控表单类型

Context 与 Ref

createContext 泛型、forwardRef + useImperativeHandle

高级模式

泛型组件、React Router 类型、react-hook-form 集成

下一章

React + TS 搞定了。下一章学习 Vue 3 与 TypeScript——Composition API 下的类型写法,同样优雅。

1 / X