TypeScript 结合 React 与 Hooks 实战

在 React 开发中,我们最常用的两个操作是:① 定义组件;② 管理状态。如果使用 JavaScript 来开发 React 应用,我们经常会遇到各种问题,比如:

  • 这个组件到底要传什么参数?
  • 这个 State 为什么变成了 undefined
  • ……

现在有了 TypeScript 的加持,我们可以让 React 组件在编写的那一刻起,就具备强大的自解释能力和错误拦截能力。

组件 Props 定义:如何用 React.FC 规范传参类型?

在使用 React 定义组件时,最核心的就是对 Props 进行标注。

1. 经典写法:React.FC

React.FC(Function Component)是官方提供的一个泛型接口。它能自动帮我们处理 children 的隐式类型(在 React 18 之后有所变化),并提供完美的语法提示。

示例 1:使用 React.FC 定义有状态组件

import React from "react";

interface UserProps {
    name: string;
    level?: "Vip" | "Normal";    // 联合类型约束
}

// 使用 React.FC 泛型标注
const UserCard: React.FC<UserProps> = ({ name, level = "Normal" }) => {
    return (
        <div className="user-card">
            <h3>用户:{ name }</h3>
            <p>等级:{ level }</p>
        </div>
    );
};

export default UserCard;

分析:

通过使用 React.FC<UserProps>,我们为组件建立了一套硬性 “契约”。如果你在父组件中调用 UserCard 时漏传了 name,或者给 level 传了一个不在联合类型里的字符串,那么 TypeScript 就会在编辑器里立刻 “爆红”。

提示: 在 React 18 中,React.FC 默认不再包含 children。如果需要使用子组件,我们必须在 UserProps 中显式定义 children: React.ReactNode。

2. 大厂写法:直接约束参数

在目前最新的 React 社区最佳实践中,很多大厂架构师已经不再推荐使用 React.FC 了。原因在于它会增加不必要的类型嵌套,而且与 React 新特性的兼容性有时不够灵活。

目前最流行、也是最简单的做法是:直接对解构出来的 props 参数进行类型标注。

示例 2:直接约束参数(推荐)

import React from "react";

interface UserProps {
    name: string;
    level?: "Vip" | "Normal";
    // 手动声明子组件插槽
    children?: React.ReactNode; 
}

// 直接在参数位置使用冒号进行解构与类型约束
const UserCard = ({ name, level = "Normal", children }: UserProps) => {
    return (
        <div className="user-card">
            <h3>用户:{ name }</h3>
            <p>等级:{ level }</p>
            <div className="content">{ children }</div>
        </div>
    );
};

export default UserCard;

分析:

这种写法抛弃了 React.FC 的泛型包裹,直接回归到了最原生的 TypeScript 函数传参约束机制。它看起来更加直观、干净,并且能够非常完美地配合 ES6 的默认参数(如 level = "Normal")使用,这正是当前企业级重构中最推崇的现代写法。

Hooks 状态约束:如何给 useState() 和 useRef() 注入泛型?

Hooks 是 React 18 的灵魂。如果 Hooks 的类型标注不准确,那么整个组件的逻辑链条都会崩溃。

1. useState() 的类型推导

对于简单数据来说,TypeScript 能够自动推导。但对于对象数组或者异步获取的数据,我们必须手动注入泛型才行。

示例 3:useState() 泛型注入

import { useState } from "react";

interface Article {
    id: number;
    title: string;
}

const ArticleList = () => {
    // 标注状态为 Article 数组,初始值为空数组
    const [list, setList] = useState<Article[]>([]);

    const addArticle = () => {
        // TS 会校验对象结构是否符合 Article 接口
        setList([...list, { id: 2026, title: "React + TS 实战指南" }]);
    };

    return <button onClick={ addArticle }>添加文章</button>;
};

2. useRef() 与 DOM 操作

在操作真实 DOM(如 Canvas、Input)时,useRef() 的类型标注至关重要。

示例 4:useRef 标注 HTML 元素

import { useRef, useEffect } from "react";

const SearchBar = () => {
    // 标注为 HTMLInputElement,初始值必须为 null
    const inputRef = useRef<HTMLInputElement>(null);

    useEffect(() => {
        // 使用可选链安全调用,TS 会自动收窄类型
        inputRef.current?.focus();
    }, []);

    return <input ref={ inputRef } type="text" />;
};

分析:

配合 HTMLInputElement 等内置 DOM 类型,useRef() 使得我们告别了 any。当我们尝试在 inputRef.current 上调用一个不存在的方法时,TypeScript 会根据你标注的元素类型进行实时拦截。

3. useRef() 保存可变变量(如定时器)

当我们使用 useRef() 去保存一个定时器 id 或者某个计数器时,它里面装的就不再是 null 或者 DOM 元素了,而是一个随时可能会变的普通数据。此时我们也需要为它注入具体的泛型。

示例 5:useRef() 保存定时器 id

import { useRef, useState, useEffect } from "react";

const TimerComponent = () => {
    const [count, setCount] = useState(0);
    
    // 存储定时器 id(浏览器环境推断为 number)
    const timerRef = useRef<number | null>(null);

    const startTimer = () => {
        // 如果已经有定时器了,就不再重复开启
        if (timerRef.current !== null) return;
        
        timerRef.current = window.setInterval(() => {
            setCount((prev) => prev + 1);
        }, 1000);
    };

    const stopTimer = () => {
        if (timerRef.current !== null) {
            window.clearInterval(timerRef.current);
            timerRef.current = null; // 清空记录
        }
    };

    // 组件销毁时,清理定时器防止内存泄漏
    useEffect(() => {
        return () => stopTimer();
    }, []);

    return (
        <div>
            <p>计数器:{ count }</p>
            <button onClick={ startTimer }>开始</button>
            <button onClick={ stopTimer }>停止</button>
        </div>
    );
};

分析:

在这个例子中,我们使用 useRef<number | null>(null) 明确告诉 TypeScript:这个容器里装的要么是数字,要么是 null。

由于 timerRef.current 的改变不会引发 React 组件的重新渲染,因此它是存储这种纯逻辑缓存数据的最佳选择。加上了严格的类型标注后,即使我们在后续代码中误把它当成字符串去调用 .split(),TypeScript 也会立刻大红波浪线拦截,彻底终结因为类型混乱导致的内存泄漏问题。

React 事件处理:事件参数 “e” 怎么写?

在 React 中,所有的事件(如 onClick、onChange)都是经过封装的 “合成事件”。很多初学的小伙伴在这里会习惯性地写 (e: any),这样其实会导致丢失所有的事件属性提示。

示例 6:标注事件类型

import React from "react";

const LoginForm = () => {
    // 标注为 React.ChangeEvent,并指定关联的元素类型
    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        console.log("当前输入值:", e.target.value);
    };

    // 标注为 React.FormEvent
    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        console.log("提交表单");
    };

    return (
        <form onSubmit={ handleSubmit }>
            <input type="text" onChange={ handleChange } />
            <button type="submit">登录</button>
        </form>
    );
};

分析:

通过 React.ChangeEvent<HTMLInputElement>,我们可以精准地拿到 e.target.value 的提示,而不需要去盲猜。这就是 TypeScript 为 React 带来的极致确定性。

进阶组件封装:抛弃 forwardRef,拥抱现代 Ref 传递

当我们需要封装基础 UI 组件(比如一个定制的 input 输入框),并且允许父组件通过 ref 直接控制它时,在 React 18 及以前,我们必须痛苦地使用 forwardRef(),并且要牢记它反直觉的泛型参数顺序。

但在最新的 React 19 中,forwardRef 已经被官方废弃!ref 现在只是一个普通的 prop,我们可以像传递字符串一样轻松地传递它。

示例 7:现代 React 19+ 的 Ref 传递

import React, { useRef } from "react";

interface CustomInputProps {
    label: string;
    placeholder?: string;
    // React 19+ 推荐写法:直接将 ref 作为一个普通的属性进行类型声明
    ref?: React.Ref<HTMLInputElement>;
}

// 抛弃 forwardRef,直接在普通组件的参数中解构出 ref
const CustomInput = ({ label, placeholder = "请输入...", ref }: CustomInputProps) => {
    return (
        <div className="custom-input">
            <label>{ label }</label>
            <input ref={ ref } placeholder={ placeholder } type="text" />
        </div>
    );
};

const App = () => {
    // 父组件创建一个精确的 Input Ref
    const inputRef = useRef<HTMLInputElement>(null);

    const focusInput = () => {
        inputRef.current?.focus();
    };

    return (
        <div>
            {/* 直接像传普通参数一样传给子组件 */}
            <CustomInput ref={ inputRef } label="用户名" />
            <button onClick={ focusInput }>点击获取焦点</button>
        </div>
    );
};

export default App;

分析:

在现代 React 开发中,我们直接在 Props 接口中增加一行 ref?: React.Ref<HTMLInputElement> 即可。这不仅让组件代码去掉了臃肿的 forwardRef 嵌套,还完全符合了最原生的 TypeScript 函数传参直觉。一旦父组件传入了错误的 ref 类型(比如传了一个绑定在 div 上的 ref),TypeScript 依然会精准地给出红色波浪线报错。

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号