React: TypeScript + React 最佳实践
最后更新:2026-08-26
Tom 团队在开发支付模块时,线上出现了一个严重的 Bug:因为某个组件预期的
userId是number,但上游传过来的是string,导致 API 请求失败。如果项目使用了 TypeScript,这种类型不匹配在编译阶段就能被发现。Tom 决定为整个项目引入 TypeScript 来提前拦截这类问题。
1. 你将学到
- Props 类型定义(interface / type 的选择策略)
- 泛型组件
<T>的实现原理 - React 事件类型系统(ChangeEvent / MouseEvent / KeyboardEvent)
- 自定义 Hooks 的完整类型标注
- HTML 元素属性的类型扩展技巧
2. 概念图解
下面的图展示了 TypeScript 在 React 组件中的数据流中所做的类型检查:
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 的支付项目中有这样的数据流:
订单列表页 → PaymentCard 组件 → AmountInput → 提交 API
在没有 TypeScript 时,AmountInput 期望 onSubmit(value: number),但列表页传了 onSubmit(value: string),结果 API 收到 "99.99" 而非 99.99,后端序列化出错。
TypeScript 的介入方式很简单——在组件接口中明确声明参数类型,任何类型不匹配都会在 npm run build 阶段报错。
(1) 组件 Props 类型定义
TypeScript 中定义 Props 有两种主要方式:interface 和 type。它们的选型规则如下:
| 方式 | 适用场景 | 特性 |
|---|---|---|
interface |
定义对象类型 Props / State | 可合并声明(declaration merging),性能好 |
type |
联合类型、工具类型、元组 | 更灵活,支持交叉类型和条件类型 |
经验规则: 定义 Props/State 用
interface,定义联合类型/工具类型用type。
基础 Props 定义
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 属性(如 id、className、aria-*)。可以用 ComponentPropsWithoutRef 继承:
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'> 会自动包含 onClick、disabled、id、className、style、aria-* 等所有原生 button 属性。用 ...rest 展开到 button 元素上,不需要逐个声明。
(2) 泛型组件
泛型组件让一个组件可以处理多种数据类型,同时保持类型安全。最常见的场景是列表、表格和选择器组件。
▶ 示例 2:泛型列表组件
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>
)}
/>
)
}
泛型的关键机制:
ListProps<T>中的T是一个"类型参数",在使用时由 TypeScript 自动推断- 当传入
items={users}(User[]类型)后,renderItem的item自动变为User类型 keyExtractor的item也自动变为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:完整的事件类型搜索表单
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
事件类型的关键要点:
React.ChangeEvent<HTMLInputElement>— 泛型参数是事件绑定的元素类型,决定了e.target和e.currentTarget的类型e.currentTarget是事件绑定的元素(类型安全),e.target是实际触发事件的元素(可能是子元素)KeyboardEvent的e.key返回字符串,不需要额外类型
▶ 示例 4:自定义 Hook 的类型标注
自定义 Hook 也需要完整的类型标注,尤其是用泛型让调用方可以指定数据类型:
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)
}
Hook 类型标注的关键原则:
- 返回值用
interface定义,字段加 JSDoc 注释(编辑器会自动显示) useState<T | null>让 TypeScript 知道 data 可能为 null,使用时必须判空- 泛型参数
<T>由调用方传入,useFetch<UserProfile>让useFetch内部的所有 T 都变成UserProfile
(4) 高级类型技巧:条件 Props 和 Omit
条件 Props 模式
当某个 prop 的存在依赖于另一个 prop 时,可以用"可辨识联合"模式:
// 普通按钮 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 省略不需要的原生属性
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:泛型组件——类型安全的数据表格
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} />
}
❓ 常见问题
type Status = 'loading' | 'success' | 'error')。两者在大部分场景可互换,但团队项目中建议统一约定一种为主。React.FC 为什么现在不推荐使用了?React.FC(或 React.FunctionComponent)默认包含 children 属性,但实际开发中很多组件不需要 children,这会导致类型过于宽松。另外 React.FC 不支持泛型组件。现在的社区最佳实践是直接给函数参数标注类型,不再使用 React.FC。e.target 和 e.currentTarget 有什么区别?e.currentTarget 是事件绑定的元素(类型安全),e.target 是实际触发事件的元素(可能是子元素)。例如 input 上绑定了 onChange,e.currentTarget 始终是那个 input,但 e.target 可能是 input 内部的某个元素。在 TypeScript 中,用 e.currentTarget 可以获得准确的元素类型。extends 约束泛型参数。例如 <T extends { id: string | number }>,这样 T 必须包含 id 字段。如果传入的类型没有 id,TypeScript 会报错。React.FC(即 React.FunctionComponent)。原因是:① 它隐式添加 children?: ReactNode,即使你的组件不需要 children;② 泛型写法繁琐(React.FC<Props> vs 直接 ({ prop }: Props) => JSX.Element);③ 默认导出时类型推断不如直接写参数类型。推荐直接写函数参数类型:function Comp({ name }: Props) {}。📖 小节
- Props 类型用
interface定义,ComponentPropsWithoutRef扩展原生 HTML 属性 - 泛型组件
<T>让列表/表格等容器组件可以处理多种数据类型,同时保持类型安全 - React 事件类型是泛型:
ChangeEvent<T>、MouseEvent<T>,T 是元素类型 - 自定义 Hook 用泛型返回值支持类型推断,用
interface定义返回值结构 - 条件 Props(可辨识联合)和
Omit是高级类型技巧,用于精确控制组件接口
📝 作业
- 用 TypeScript 创建一个
Table组件:泛型<T extends { id: string | number }>,支持列定义配置(columns:{ key: keyof T, title: string }),并实现排序功能。 - 用 TypeScript 创建一个自定义
useLocalStorage<T>Hook:读取和写入时保持类型安全,支持默认值,JSON 序列化/反序列化自动处理。 - 用
Omit和ComponentPropsWithoutRef创建一个PasswordInput组件:继承所有原生 input 属性,但type固定为"password",并额外添加showToggleprop 控制密码可见性切换。