TypeScript React 组件中的 Interface、泛型与 Props 解析

4 阅读12分钟

TypeScript React 组件中的 Interface、泛型与 Props 解析

本文以 vite-react-demo 项目为例,逐行解析 TypeScript 在 React 函数组件中的核心用法——interface 定义 Props 类型、React.FC 泛型约束、useState 泛型以及事件泛型。文末附带完整的数据流图和对照表,适合正在从 JavaScript 迁移到 TypeScript 的 React 开发者阅读。


一、整体架构速览

项目包含三个文件,构成一个典型的单向数据流三角:

App.tsx  (状态持有者)
  ├── Hello.tsx           —— 纯展示组件,只读 props
  └── NameEditComponent.tsx  —— 受控表单组件,通过回调读写 props
  • App 拥有全部状态(name、editingName),以 props 下发
  • 子组件本身不持有状态,通过回调函数把用户操作"上报"给父组件

1.1 组件通信的核心原则

React 组件通信遵循单向数据流,可以拆解为三个角色:

角色谁承担具体内容
持有状态父组件(App)useState 定义的 name、editingName
修改状态的方法父组件(App)setName、setEditingName、setUsernameState
下发通道props属性(userName、editingName)+ 自定义事件回调(onNameUpdated、onEditingNameUpdated)

这一模式的核心收益是:多个子组件共享同一份状态时,数据源始终唯一,不会出现状态不同步的问题。本项目中 Hello 和 NameEditComponent 都依赖 App 持有的 name / editingName,正是这一原则的体现。


二、什么是泛型(Generic)

在深入项目代码之前,先回答一个根本问题——泛型到底是什么?

2.1 一个没有泛型的世界

假设你需要写一个函数,返回数组的第一个元素:

// 方案 A:写死类型——只能用于 number[]
function firstNumber(arr: number[]): number {
  return arr[0]
}

// 方案 B:用 any——丢了类型安全
function firstAny(arr: any): any {
  return arr[0]
}

方案 A 为 string[]、User[] 等每种数组都要重写一遍;方案 B 虽然通用,但返回值退化为 any,调用方完全不知道拿到的是什么类型。

2.2 泛型的解决思路

泛型的核心思想:把类型也当作参数,让调用方在使用时指定。

// <T> 是类型参数——调用时由使用者"传入"具体类型
function first<T>(arr: T[]): T {
  return arr[0]
}

// 使用时:
const n = first<number>([1, 2, 3])      // n: number ✅
const s = first<string>(['a', 'b'])      // s: string ✅
const u = first<User>([{ name: 'Alice'}]) // u: User  ✅

<T> 就像一个类型的占位符——定义时不知道具体是什么,使用时才确定。TypeScript 根据调用时的参数自动推断(或由你显式指定),然后在整个函数签名中"替换"所有 T,从而得到精确的返回类型。

2.3 泛型在 React 中的四种形态

本项目中出现的所有泛型,本质都是同一个模式——"调用时传入具体类型,获得精确的类型结果":

位置写法类型参数它替代了什么
组件类型React.FC<Props>PropsP → 组件 props 的形状
状态 HookuseState<string>stringS → state 变量的类型
事件处理ChangeEvent<HTMLInputElement>HTMLInputElementT → 事件源 DOM 元素类型
自定义回调(newName: string) => void(非泛型,但配合使用)回调参数类型

掌握泛型的思维方式后,理解后续各章节中的 <Props>、<string>、<HTMLInputElement> 将变得非常简单——它们只是在不同场景下,为不同的类型参数填入具体类型而已。


三、interface Props:定义组件契约

在 TypeScript 中,interface 是描述对象形状的核心工具。React 组件用它来定义 props 的类型契约——调用方必须按约定传参。

2.1 简单示例:Hello.tsx

interface Props {
  userName: string
}

这里定义了一个 Props 接口,要求传入一个名为 userName 的字符串。这个接口随后被用在 React.FC<Props> 中,将组件类型化为"接收 { userName: string } 为 props 的函数"。

2.2 复杂示例:NameEditComponent.tsx

interface Props {
  editingName: string
  onNameUpdated: () => void
  onEditingNameUpdated: (newEditingName: string) => void
  disabled: boolean
}

这个接口展示了四种典型的 prop 类型:

属性类型含义
editingNamestring受控表单的当前值
onNameUpdated() => void无参无返回值的提交回调
onEditingNameUpdated(newEditingName: string) => void带参数(新值)的变更回调
disabledboolean按钮是否禁用

关键细节: onEditingNameUpdated 的函数签名 (newEditingName: string) => void 是 TypeScript 对回调的精确描述——"接受一个 string 参数,不返回任何东西"。这种写法在 React 的状态提升模式中极为常见,确保父组件拿到的参数类型安全。

2.3 回调即"自定义事件"

在 React 中,onXxx 命名的回调 prop 本质上就是组件的自定义事件。与原生 DOM 事件(onClick、onChange)不同,这些事件完全由开发者通过 interface 定义:

// 原生事件:浏览器规定签名,React 做类型封装
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void

// 自定义事件:开发者通过 interface 自由定义签名
onNameUpdated: () => void                       // "提交"事件,无需传参
onEditingNameUpdated: (newEditingName: string) => void  // "值变更"事件,携带新值

这种模式的好处是:事件的"形状"由 interface 集中管理,父组件在实现回调、子组件在调用回调时都能获得完整的类型检查和智能提示。本质上,interface Props 就是组件的公共 API 文档——只看接口定义就能知道组件能接收什么数据、会触发什么事件。


四、React.FC<Props>——函数组件的泛型入口

两行代码值得放在一起对比:

// Hello.tsx
const HelloComponent: React.FC<Props> = (props) => { ... }

// NameEditComponent.tsx
const NameEditComponent: React.FC<Props> = (props) => { ... }

3.1 React.FC 是什么

React.FC(全称 React.FunctionComponent)是 React 类型声明中提供的一个泛型类型:

// 简化版内部定义
type FC<P = {}> = FunctionComponent<P>

interface FunctionComponent<P = {}> {
  (props: P): ReactElement | null
  // 还附带 children、defaultProps、displayName 等
}

3.2 <Props> 泛型参数的作用

当写成 React.FC<Props> 时,TypeScript 做了三件事:

  1. props 自动获得类型:组件内部的 props 变量被推断为 Props 类型,访问 props.userName 时有智能提示
  2. 调用方被强制约束:在 JSX 中 <HelloComponent /> 如果缺少 userName 属性,TypeScript 编译器直接报错
  3. children 自动包含:React.FC 默认在 props 中隐式包含 children?: ReactNode,允许 <Component>...</Component> 嵌套子元素

3.3 与箭头函数结合时的写法注意

const HelloComponent: React.FC<Props> = (props) => {
  return <h2>Hello {props.userName}</h2>
}

这里的语法是:const 变量名: 类型 = 函数体。类型注解 React.FC<Props> 绑定在常量上而非函数参数上,这比在参数里写 (props: Props) 多了一层对外暴露的元信息(displayName、defaultProps 等)。


五、useState<string>——Hook 泛型

在 App.tsx 中:

const [name, setName] = React.useState<string>('defaultUserName')
const [editingName, setEditingName] = React.useState<string>('defaultUserName')

4.1 为什么需要显式泛型

useState 本身会根据初始值做类型推断。这里传入 'defaultUserName' 是字符串,TypeScript 能自动推断出 useState<string>。那为什么还要显式写 <string>?

原因有二:

  1. 文档意图:显式写出来让代码读者一眼看清状态的类型
  2. 兜底场景:当初始值为 null、undefined 或联合类型时,推断会失败或不够精确,必须显式指定:
// 不写泛型 → 推断为 never[],无法 push
const [items, setItems] = useState([])

// 写泛型 → 正确推断为 Item[]
const [items, setItems] = useState<Item[]>([])

4.2 setName 和 setEditingName 的类型

因为 useState<string> 的缘故:

  • setName 的类型是 React.Dispatch<React.SetStateAction<string>>
  • 这等价于 (value: string) => void——接收 string,无返回值

所以 setEditingName 可以直接以 prop 形式传给 NameEditComponent 的 onEditingNameUpdated 回调(它们签名兼容)。


六、React.ChangeEvent<HTMLInputElement>——事件泛型

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  onEditingNameUpdated(e.target.value)
}

5.1 React 合成事件:看起来像原生,实际上不是

React 中的事件对象并非浏览器原生的 Event,而是 React 封装的合成事件(SyntheticEvent)。它"看过去像原生事件"——同样有 target、preventDefault()、stopPropagation() 等属性和方法——但底层是 React 的事件池机制,实现了跨浏览器兼容。

TypeScript 为每一种合成事件提供了对应的泛型类型,命名规则是 React.XxxEvent<T>:

5.2 React 事件泛型的层级

原生事件React 类型泛型参数
input changeReact.ChangeEvent<T>T = DOM 元素类型
button clickReact.MouseEvent<T>T = DOM 元素类型
form submitReact.FormEvent<T>T = DOM 元素类型
key pressReact.KeyboardEvent<T>T = DOM 元素类型

5.3 泛型参数的核心意义:事件发生的元素

React.ChangeEvent<HTMLInputElement>

HTMLInputElement 这个泛型参数决定了 e.target 的具体类型。如果不写泛型或写错,就会出现两个问题:

  • 写了 HTMLInputElement:e.target.value 有智能提示且类型为 string ✅
  • 写了 HTMLElement:e.target.value 类型不明确,可能报错 ❌
  • 不写泛型:e.target 退化为 EventTarget,访问 .value 会报错 ❌

这种"传入具体 DOM 元素类型,获得精确的事件对象类型"的模式,是 TypeScript 泛型在 React 事件处理中的核心价值。

事件泛型的核心法则:事件最重要的就是事件发生的元素。 React.ChangeEvent<HTMLInputElement> 中,泛型参数 <HTMLInputElement> 指明了事件源是 <input> 标签——这个信息决定了 e.target 的类型、决定了你能访问哪些属性(如 .value、.checked)、也决定了 TypeScript 能否在编译阶段拦住你的错误用法。当你写 React.MouseEvent<HTMLButtonElement> 时,泛型参数 <HTMLButtonElement> 同理地窄化了事件源为 <button> 元素。


七、Props 的数据流图

┌─────────────────────────────────────────────────────┐
│  App.tsx (状态持有者)                                  │
│                                                       │
│  useState<string> → name           // 最终提交值       │
│  useState<string> → editingName    // 表单中间值       │
│                                                       │
│  setUsernameState() {                                │
│    setName(editingName)  // 提交确认                  │
│  }                                                    │
├──────────────────┬──────────────────────────────────┤
│  Props 下发       │  Props 下发                       │
│  ┌──────────┐    │  ┌─────────────────────────────┐ │
│  │userName  │    │  │editingName                  │ │
│  └──────────┘    │  │onNameUpdated                │ │
│                  │  │onEditingNameUpdated          │ │
│                  │  │disabled (计算属性)            │ │
│                  │  └─────────────────────────────┘ │
├──────────────────┼──────────────────────────────────┤
│                  ▼                                    │
│  Hello.tsx       NameEditComponent.tsx                │
│  (纯展示)        (受控组件)                            │
│                  │                                    │
│  interface {     │  interface {                       │
│    userName      │    editingName                     │
│  }              │    onNameUpdated                   │
│                  │    onEditingNameUpdated            │
│                  │    disabled                        │
│                  │  }                                 │
└──────────────────┴───────────────────────────────────┘

回调执行链路:

用户在 <input> 输入
  → onChange (React.ChangeEvent<HTMLInputElement>)
    → onEditingNameUpdated(e.target.value)
      → App.setState(editingName)     ← 状态提升
        → editingName 变更,重新渲染子组件

用户点击 <button>
  → onClick → onNameUpdated()
    → App.setUsernameState()
      → setName(editingName)          ← 正式提交
        → name 变更,Hello 组件更新显示

八、组件设计的三种演进:从耦合到纯粹

README 中记录了一个重要的设计演进过程——子组件如何把用户输入"上报"给父组件。这个过程经历了三个阶段,每一步都在降低耦合、提升组件的纯粹性。

8.1 第一阶段(❌ 不推荐):把 Event 对象传给父组件

// 子组件 — 反模式
interface Props {
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
const Child: React.FC<Props> = (props) => (
  <input onChange={props.onChange} />
)

// 父组件
<Child onChange={(e) => setName(e.target.value)} />

问题: 子组件把 React.ChangeEvent<HTMLInputElement> 暴露给了父组件。父组件被迫依赖 React 的事件类型——它本不应该关心"这个值是通过 <input> 还是 <select> 产生的"。同时,父组件的代码可读性也受到拖累:它的核心使命是持有状态 + 修改状态,不应被事件对象的细节干扰。

8.2 第二阶段(⚠️ 可行但非最优):子组件持有私有状态

// 子组件 — 自己管理中间态
const Child: React.FC<{ onSubmit: (value: string) => void }> = (props) => {
  const [editingName, setEditingName] = useState('')
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEditingName(e.target.value)
  }
  return (
    <>
      <input value={editingName} onChange={onChange} />
      <button onClick={() => props.onSubmit(editingName)}>提交</button>
    </>
  )
}

优点: React.ChangeEvent<HTMLInputElement> 的复杂性被封装在子组件内部,父组件只需接收纯净的 string 值。父组件不再耦合 React 事件类型。

缺点: 子组件引入了私有状态。这在简单场景下可行,但如果父组件需要感知中间值(例如实时校验、两个子组件的联动),私有状态就成了障碍,因为它们无法同步。

8.3 第三阶段(✅ 本项目采用的方案):状态提升,子组件无状态

// 子组件 — 纯展示,无私有状态
interface Props {
  editingName: string
  onEditingNameUpdated: (newEditingName: string) => void
  onNameUpdated: () => void
  disabled: boolean
}
const Child: React.FC<Props> = (props) => {
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    props.onEditingNameUpdated(e.target.value)  // 只传值,不传事件
  }
  return (
    <>
      <input value={props.editingName} onChange={onChange} />
      <button disabled={props.disabled} onClick={props.onNameUpdated}>提交</button>
    </>
  )
}

这就是本项目 NameEditComponent.tsx 采取的设计:

  1. 状态全部提升到父组件:editingName 由 App 持有,通过 props 传下来
  2. 子组件不持有状态:只负责展示和触发回调,是一个无状态组件
  3. Event 对象的边界被严格隔离:React.ChangeEvent<HTMLInputElement> 只在子组件内部的 onChange 中使用,外部只看到干净的 string

8.4 三阶段对比总览

维度第一阶段第二阶段第三阶段
Event 对象暴露范围❌ 暴露给父组件✅ 仅在子组件内部✅ 仅在子组件内部
父组件可读性❌ 被事件细节干扰✅ 清爽✅ 清爽
子组件是否有状态无有私有状态无(纯展示)
多子组件共享中间态✅ 天然支持❌ 需额外同步逻辑✅ 天然支持
性能与可测试性一般一般最优

8.5 核心结论

子组件不持有状态,性能会更好,职责也更纯粹——它就负责展示。状态和修改状态的方法都放在父组件,通过 props(属性 + 自定义事件)让子组件们共享。


九、useEffect:副作用与组件生命周期

9.1 代码回顾

const loadUsername = () => {
  setTimeout(() => {
    setName("name from async call")
    setEditingName("name from async call")
  }, 2000)
}

React.useEffect(() => {
  loadUsername()
}, [])

9.2 什么是副作用(Side Effect)

在 React 中,"副作用"指组件渲染之外的操作——数据请求、定时器、订阅、手动 DOM 操作等。这些操作不能直接在组件函数体中执行(会导致每次渲染都触发),而应该放在 useEffect 中,让 React 在合适的时机调用。

9.3 组件生命周期的两步策略

README 中总结了一个核心设计理念:

组件的第一要素:快速显示,让用户觉得快。

拆解为两步:

第一步(即刻完成):组件挂载,展示初始 UI
  → 此时 name 显示为 'defaultUserName'
  → 用户立刻看到页面,感知到"快"

第二步(异步完成):useEffect 触发,请求数据,响应式更新
  → setTimeout 模拟接口调用(2 秒后返回数据)
  → setName / setEditingName 触发重新渲染
  → 页面更新为 'name from async call'

这种模式是 React 应用的标准范式:

阶段对应生命周期做什么
挂载(mounted)useEffect(..., [])发起 API 请求、设置订阅、启动定时器
更新(updated)useEffect(..., [deps])响应依赖项变化,重新拉取数据或清理副作用
卸载(unmounted)useEffect 返回的清理函数取消订阅、清除定时器、防止内存泄漏

9.4 [] 空依赖数组的含义

React.useEffect(() => {
  loadUsername()
}, [])  // ← 空数组 = 只在组件挂载后执行一次
  • []:effect 仅在组件挂载后执行一次,等价于 class 组件的 componentDidMount
  • [editingName]:effect 在挂载时 + 每次 editingName 变化时执行
  • 不传第二个参数:effect 在每次渲染后都执行(极少使用,容易造成死循环)

9.5 与状态提升的配合

useEffect 获取数据后直接更新父组件状态,而状态变化通过 props 自动流向所有子组件——这完成了单向数据流的闭环:

useEffect 异步获取数据
  → setName(...)               ← 状态变更
    → Hello 组件重渲染           ← props 自动下发
    → NameEditComponent 重渲染   ← 共享同一份状态,无需手动通知

十、泛型要点总结

位置写法泛型参数作用
组件类型React.FC<Props>约束 props 的类型形状
状态 HookuseState<string>(val)明确 state 和 setter 的类型
事件处理ChangeEvent<HTMLInputElement>窄化 e.target 到具体 DOM 类型
Props 回调(newName: string) => void约束回调参数类型,保证父子类型安全

这三层泛型(组件级、Hook 级、事件级)构成了 TypeScript + React 的类型骨架。掌握它们之后,你就能写出一份从组件树顶层到底层全程类型安全的 React 应用——编辑器的智能提示、重构时的稳健性、以及编译阶段的错误拦截,都是这份类型系统的自然红利。