React: TypeScript + React 最佳实践

最后更新:2026-08-26

Tom 团队在开发支付模块时,线上出现了一个严重的 Bug:因为某个组件预期的 userIdnumber,但上游传过来的是 string,导致 API 请求失败。如果项目使用了 TypeScript,这种类型不匹配在编译阶段就能被发现。Tom 决定为整个项目引入 TypeScript 来提前拦截这类问题。


1. 你将学到



2. 概念图解

下面的图展示了 TypeScript 在 React 组件中的数据流中所做的类型检查:

100%
flowchart LR
    subgraph 编译阶段
        A[父组件] -->|"Props 类型检查"| B[子组件]
        B -->|"State 类型推断"| C[useState]
        C -->|"事件类型校验"| D["onChange / onClick"]
    end

    subgraph 运行时
        E["实际 DOM 事件"] --> F["类型匹配"]
        F -->|"通过"| G[正常执行]
        F -->|"类型不匹配"| H[编译时报错]
    end

    I["interface / type 定义"] --> A
    J["泛型参数 <T>"] --> B
    K["React.ChangeEvent"] --> D

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#e65100


3. 一个真实场景

TypeScript 场景 解决的问题 关键语法
Props 类型定义 调用时传错类型 interface Props { name: string }
事件类型 onChange 参数类型推断 e: React.ChangeEvent<HTMLInputElement>
泛型组件 列表/表格的数据类型参数化 <T> 泛型参数
Hooks 类型 useState/useRef 的类型推导 useState<string[]>
API 响应类型 接口返回值结构约束 interface ApiResponse { data: User[] }

Tom 的支付项目中有这样的数据流:

TEXT 📖 仅展示
订单列表页 → PaymentCard 组件 → AmountInput → 提交 API

在没有 TypeScript 时,AmountInput 期望 onSubmit(value: number),但列表页传了 onSubmit(value: string),结果 API 收到 "99.99" 而非 99.99,后端序列化出错。

TypeScript 的介入方式很简单——在组件接口中明确声明参数类型,任何类型不匹配都会在 npm run build 阶段报错。


(1) 组件 Props 类型定义

TypeScript 中定义 Props 有两种主要方式:interfacetype。它们的选型规则如下:

方式 适用场景 特性
interface 定义对象类型 Props / State 可合并声明(declaration merging),性能好
type 联合类型、工具类型、元组 更灵活,支持交叉类型和条件类型

经验规则: 定义 Props/State 用 interface,定义联合类型/工具类型用 type

基础 Props 定义

TSX
interface ButtonProps {
  /** 按钮文本 */
  label: string
  /** 变体样式 */
  variant?: 'primary' | 'danger' | 'default'
  /** 按钮尺寸 */
  size?: 'small' | 'medium' | 'large'
  /** 是否禁用 */
  disabled?: boolean
  /** 点击回调 */
  onClick: () => void
  /** 子元素(按钮内的图标等) */
  children?: React.ReactNode
}

function Button({
  label,
  variant = 'primary',
  size = 'medium',
  disabled = false,
  onClick,
  children,
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      style={{
        padding: size === 'small' ? '4px 12px' : size === 'large' ? '12px 28px' : '8px 20px',
        background: variant === 'danger' ? '#ff4d4f' : variant === 'primary' ? '#1890ff' : '#f0f0f0',
        color: variant === 'default' ? '#333' : '#fff',
        border: 'none',
        borderRadius: 6,
        cursor: disabled ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.5 : 1,
        transition: 'all 0.2s',
      }}
    >
      {children}
      {label}
    </button>
  )
}

▶ 示例 1:Props 类型进阶——扩展原生 HTML 属性

实际开发中,组件通常需要透传原生 HTML 属性(如 idclassNamearia-*)。可以用 ComponentPropsWithoutRef 继承:

TSX
import { ComponentPropsWithoutRef } from 'react'

// 方式一:扩展原生 button 属性(推荐)
interface PrimaryButtonProps
  extends ComponentPropsWithoutRef<'button'> {
  /** 加载状态 */
  loading?: boolean
  /** 图标名称 */
  icon?: string
}

function PrimaryButton({
  loading,
  icon,
  children,
  disabled,
  ...rest  // 剩余原生 button 属性
}: PrimaryButtonProps) {
  return (
    <button
      {...rest}
      disabled={disabled || loading}
      style={{
        padding: '8px 24px',
        background: loading ? '#91d5ff' : '#1890ff',
        color: '#fff',
        border: 'none',
        borderRadius: 6,
        cursor: loading ? 'wait' : 'pointer',
      }}
    >
      {loading ? '加载中...' : icon ? `${icon} ${children}` : children}
    </button>
  )
}

// 使用 — 原生属性和自定义属性都可传入
<PrimaryButton
  id="submit-btn"
  loading={isSubmitting}
  icon=">"
  onClick={() => submit()}
  aria-label="提交表单"
>
  提交
</PrimaryButton>
▶ 试一试

关键点: ComponentPropsWithoutRef<'button'> 会自动包含 onClickdisabledidclassNamestylearia-* 等所有原生 button 属性。用 ...rest 展开到 button 元素上,不需要逐个声明。


(2) 泛型组件

泛型组件让一个组件可以处理多种数据类型,同时保持类型安全。最常见的场景是列表、表格和选择器组件。

▶ 示例 2:泛型列表组件

TSX 📖 仅展示
import { ReactNode } from 'react'

// 泛型接口 — T 为列表项类型
interface ListProps<T> {
  /** 数据源 */
  items: T[]
  /** 渲染每一项 */
  renderItem: (item: T, index: number) => ReactNode
  /** 唯一 key 提取函数 */
  keyExtractor: (item: T) => string | number
  /** 列表为空时的占位文本 */
  emptyText?: string
}

// 泛型组件 — <T,> 语法(TS 对 JSX 的兼容写法)
function List<T>({
  items,
  renderItem,
  keyExtractor,
  emptyText = '暂无数据',
}: ListProps<T>) {
  if (items.length === 0) {
    return (
      <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
        {emptyText}
      </div>
    )
  }

  return (
    <div>
      {items.map((item, index) => (
        <div key={keyExtractor(item)} style={{ marginBottom: 8 }}>
          {renderItem(item, index)}
        </div>
      ))}
    </div>
  )
}

// --- 使用示例 ---

interface User {
  id: number
  name: string
  role: 'admin' | 'user'
}

const users: User[] = [
  { id: 1, name: 'Alice', role: 'admin' },
  { id: 2, name: 'Bob', role: 'user' },
  { id: 3, name: 'Charlie', role: 'user' },
]

// 类型自动推断:List<User>
// items 自动推断为 User[],renderItem 的 item 自动为 User
function UserList() {
  return (
    <List
      items={users}
      keyExtractor={user => user.id}
      renderItem={(user, index) => (
        <div
          style={{
            padding: '8px 16px',
            background: index % 2 === 0 ? '#fafafa' : '#fff',
            borderRadius: 4,
          }}
        >
          <span style={{ fontWeight: 600 }}>{user.name}</span>
          <span
            style={{
              marginLeft: 8,
              color: user.role === 'admin' ? '#1890ff' : '#999',
              fontSize: 12,
            }}
          >
            {user.role}
          </span>
        </div>
      )}
    />
  )
}
逻辑代码 68 行(超过 40 行限制,仅展示)

泛型的关键机制:

  1. ListProps<T> 中的 T 是一个"类型参数",在使用时由 TypeScript 自动推断
  2. 当传入 items={users}User[] 类型)后,renderItemitem 自动变为 User 类型
  3. keyExtractoritem 也自动变为 User,调用 user.id 有完整的类型提示

(3) React 事件类型

React 用自己的合成事件系统封装了原生 DOM 事件。每个事件类型都需要指定绑定的 HTML 元素类型,以获得正确的 currentTarget 类型。

事件类型 对应元素 常用场景
ChangeEvent<HTMLInputElement> input / textarea / select 表单输入
ChangeEvent<HTMLSelectElement> select 下拉选择
MouseEvent<HTMLButtonElement> button / div 点击
FormEvent<HTMLFormElement> form 表单提交
KeyboardEvent<HTMLInputElement> input 键盘快捷键
FocusEvent<HTMLInputElement> input 聚焦/失焦

▶ 示例 3:完整的事件类型搜索表单

TSX 📖 仅展示
import { useState } from 'react'

interface SearchFormProps {
  /** 搜索回调 */
  onSearch: (query: string) => Promise<void>
  /** 占位文本 */
  placeholder?: string
}

function SearchForm({ onSearch, placeholder = '搜索...' }: SearchFormProps) {
  const [query, setQuery] = useState('')
  const [isSearching, setIsSearching] = useState(false)

  // ChangeEvent<HTMLInputElement> — input 值变化
  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value)
  }

  // KeyboardEvent<HTMLInputElement> — 键盘事件
  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (e.key === 'Escape') {
      e.currentTarget.blur()  // currentTarget 是 HTMLInputElement
      setQuery('')
    }
  }

  // FormEvent<HTMLFormElement> — 表单提交
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    if (!query.trim()) return

    setIsSearching(true)
    try {
      await onSearch(query.trim())
    } finally {
      setIsSearching(false)
    }
  }

  // MouseEvent<HTMLButtonElement> — 清除按钮点击
  function handleClear(e: React.MouseEvent<HTMLButtonElement>) {
    e.stopPropagation()  // 阻止事件冒泡
    setQuery('')
  }

  return (
    <form onSubmit={handleSubmit} style={{ display: 'flex', gap: 8 }}>
      <div style={{ position: 'relative', flex: 1 }}>
        <input
          type="text"
          value={query}
          onChange={handleChange}
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
          style={{
            width: '100%',
            padding: '8px 12px',
            border: '1px solid #d9d9d9',
            borderRadius: 6,
            fontSize: 14,
            outline: 'none',
            boxSizing: 'border-box',
          }}
        />
        {query && (
          <button
            type="button"
            onClick={handleClear}
            style={{
              position: 'absolute',
              right: 8,
              top: '50%',
              transform: 'translateY(-50%)',
              border: 'none',
              background: 'none',
              cursor: 'pointer',
              color: '#999',
            }}
          >
            x
          </button>
        )}
      </div>
      <button
        type="submit"
        disabled={isSearching || !query.trim()}
        style={{
          padding: '8px 20px',
          background: isSearching ? '#91d5ff' : '#1890ff',
          color: '#fff',
          border: 'none',
          borderRadius: 6,
          cursor: isSearching ? 'wait' : 'pointer',
          fontSize: 14,
        }}
      >
        {isSearching ? '搜索中...' : '搜索'}
      </button>
    </form>
  )
}

export default SearchForm
逻辑代码 88 行(超过 40 行限制,仅展示)

事件类型的关键要点:

  1. React.ChangeEvent<HTMLInputElement> — 泛型参数是事件绑定的元素类型,决定了 e.targete.currentTarget 的类型
  2. e.currentTarget 是事件绑定的元素(类型安全),e.target 是实际触发事件的元素(可能是子元素)
  3. KeyboardEvente.key 返回字符串,不需要额外类型

▶ 示例 4:自定义 Hook 的类型标注

自定义 Hook 也需要完整的类型标注,尤其是用泛型让调用方可以指定数据类型:

TSX 📖 仅展示
import { useState, useEffect, useCallback } from 'react'

// 定义 Hook 返回值的接口
interface UseFetchResult<T> {
  /** 返回数据 */
  data: T | null
  /** 加载中状态 */
  loading: boolean
  /** 错误信息 */
  error: string | null
  /** 手动重新请求 */
  refetch: () => void
}

// 泛型 Hook — 调用方指定 T 类型
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 response = await fetch(url)
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }
      const json: T = await response.json()
      setData(json)
    } catch (err) {
      const message = err instanceof Error ? err.message : '未知错误'
      setError(message)
    } finally {
      setLoading(false)
    }
  }, [url])

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

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

// --- 使用 —— 类型标注一目了然 ---

interface UserProfile {
  id: number
  name: string
  email: string
  avatar: string
}

function ProfilePage({ userId }: { userId: number }) {
  // data 自动推断为 UserProfile | null
  const { data: user, loading, error, refetch } =
    useFetch<UserProfile>(`/api/users/${userId}`)

  if (loading) return <div>加载中...</div>
  if (error) return <div style={{ color: 'red' }}>错误:{error}</div>
  if (!user) return <div>无数据</div>

  return (
    <div>
      <img src={user.avatar} alt={user.name} width={64} />
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <button onClick={refetch}>刷新</button>
    </div>
  )

  // ✅ user.name / user.email / user.avatar 都有类型提示
  // ❌ user.phone 会报编译错误(UserProfile 中没有 phone)
}
逻辑代码 54 行(超过 40 行限制,仅展示)

Hook 类型标注的关键原则:

  1. 返回值用 interface 定义,字段加 JSDoc 注释(编辑器会自动显示)
  2. useState<T | null> 让 TypeScript 知道 data 可能为 null,使用时必须判空
  3. 泛型参数 <T> 由调用方传入,useFetch<UserProfile>useFetch 内部的所有 T 都变成 UserProfile

(4) 高级类型技巧:条件 Props 和 Omit

条件 Props 模式

当某个 prop 的存在依赖于另一个 prop 时,可以用"可辨识联合"模式:

TSX
// 普通按钮 vs 链接按钮 — variant 为 'link' 时必须传 href
type ButtonVariant =
  | { variant: 'primary' | 'danger' | 'default' }
  | { variant: 'link'; href: string; target?: '_blank' | '_self' }

interface SmartButtonProps {
  label: string
} & ButtonVariant

function SmartButton(props: SmartButtonProps) {
  if (props.variant === 'link') {
    // 这里 props.href 类型安全地存在
    return <a href={props.href} target={props.target}>{props.label}</a>
  }
  return <button>{props.label}</button>
}

使用 Omit 省略不需要的原生属性

TSX
import { ComponentPropsWithoutRef } from 'react'

// 自定义 Input 组件,不允许透传 type(强制为 text)
type CustomInputProps = Omit<
  ComponentPropsWithoutRef<'input'>,
  'type'
> & {
  label: string
}

function CustomInput({ label, ...inputProps }: CustomInputProps) {
  return (
    <label>
      {label}
      <input type="text" {...inputProps} />
    </label>
  )
}

▶ 示例 5:泛型组件——类型安全的数据表格

TSX 📖 仅展示
interface Column<T> {
  key: keyof T & string
  title: string
  render?: (value: T[keyof T], row: T) => React.ReactNode
}

function DataTable<T extends Record<string, any>>({ data, columns }: { data: T[]; columns: Column<T>[] }) {
  return (
    <table style={{ borderCollapse: 'collapse', width: '100%' }}>
      <thead>
        <tr>
          {columns.map(col => (
            <th key={col.key} style={{ border: '1px solid #ddd', padding: 8, textAlign: 'left', background: '#f5f5f5' }}>
              {col.title}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((row, i) => (
          <tr key={i}>
            {columns.map(col => (
              <td key={col.key} style={{ border: '1px solid #ddd', padding: 8 }}>
                {col.render ? col.render(row[col.key], row) : String(row[col.key])}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  )
}

interface User {
  id: number
  name: string
  email: string
  active: boolean
}

function UserTable() {
  const users: User[] = [
    { id: 1, name: 'Alice', email: 'alice@test.com', active: true },
    { id: 2, name: 'Bob', email: 'bob@test.com', active: false },
  ]

  const columns: Column<User>[] = [
    { key: 'name', title: 'Name' },
    { key: 'email', title: 'Email' },
    { key: 'active', title: 'Status', render: (v) => (
      <span style={{ color: v ? '#52c41a' : '#999' }}>{v ? 'Active' : 'Inactive'}</span>
    )},
  ]

  return <DataTable data={users} columns={columns} />
}
逻辑代码 51 行(超过 40 行限制,仅展示)

❓ 常见问题

Q interface 和 type 到底怎么选?
A 简单规则:定义 Props/State 用 interface(可合并声明、性能更好);定义联合类型、交叉类型、工具类型用 type(如 type Status = 'loading' | 'success' | 'error')。两者在大部分场景可互换,但团队项目中建议统一约定一种为主。
Q React.FC 为什么现在不推荐使用了?
A React.FC(或 React.FunctionComponent)默认包含 children 属性,但实际开发中很多组件不需要 children,这会导致类型过于宽松。另外 React.FC 不支持泛型组件。现在的社区最佳实践是直接给函数参数标注类型,不再使用 React.FC
Q e.targete.currentTarget 有什么区别?
A e.currentTarget 是事件绑定的元素(类型安全),e.target 是实际触发事件的元素(可能是子元素)。例如 input 上绑定了 onChange,e.currentTarget 始终是那个 input,但 e.target 可能是 input 内部的某个元素。在 TypeScript 中,用 e.currentTarget 可以获得准确的元素类型。
Q 泛型组件的类型参数怎么约束必须有某个字段?
A 使用 extends 约束泛型参数。例如 <T extends { id: string | number }>,这样 T 必须包含 id 字段。如果传入的类型没有 id,TypeScript 会报错。
Q React.FC 还要不要用?
A React 官方已不推荐使用 React.FC(即 React.FunctionComponent)。原因是:① 它隐式添加 children?: ReactNode,即使你的组件不需要 children;② 泛型写法繁琐(React.FC<Props> vs 直接 ({ prop }: Props) => JSX.Element);③ 默认导出时类型推断不如直接写参数类型。推荐直接写函数参数类型:function Comp({ name }: Props) {}

📖 小节


📝 作业

  1. 用 TypeScript 创建一个 Table 组件:泛型 <T extends { id: string | number }>,支持列定义配置(columns: { key: keyof T, title: string }),并实现排序功能。
  2. 用 TypeScript 创建一个自定义 useLocalStorage<T> Hook:读取和写入时保持类型安全,支持默认值,JSON 序列化/反序列化自动处理。
  3. OmitComponentPropsWithoutRef 创建一个 PasswordInput 组件:继承所有原生 input 属性,但 type 固定为 "password",并额外添加 showToggle prop 控制密码可见性切换。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏