第六章 React + TypeScript 实战指南:从理论到落地(5 大核心理论 + 4 个项目),让你的应用更健壮、易维护

第六章: TypeScript:为 React 应用注入类型之魂

摘要: 在前面的章节中,我们已经使用 JavaScript 构建了多个功能完备的 React 应用。我们已经体会到了 React 的强大,但同时也可能感受到了 JavaScript 动态类型带来的一些“不安全感”。随着应用规模的扩大,我们如何确保传递给组件的数据总是正确的?如何避免因为一个简单的拼写错误或类型不匹配而导致的运行时 Bug?本章将给出答案:TypeScript


为什么要在学完 React 基础后再深入 TypeScript?

这是一个经过精心设计的学习路径。我们之所以先用 JavaScript 学习 React,是为了让您能够 专注于 React 自身的核心思想——组件化、状态管理、Hooks 等,而不被类型定义的语法分散注意力。

现在,您已经对 React 的“骨架”了然于胸,是时候为其穿上“铠甲”了。TypeScript 这层铠甲将为我们带来:

  1. 代码的健壮性: 在代码 运行前(编译阶段)就能发现大量的潜在错误,而不是等到用户在浏览器中遇到问题。
  2. 开发体验的飞跃: 享受无与伦比的编辑器自动补全、类型提示和重构能力,让我们写代码更快、更自信。
  3. 团队协作的基石: 类型定义本身就是最精准、最不会过时的“文档”。任何接手我们代码的同事都能立即明白一个组件需要什么数据,返回什么结果。

在本章中,我们将系统性地学习如何将 TypeScript 的类型系统无缝融入到 React 开发的每一个环节,构建出真正生产级的应用程序。


6.1. Props 的类型艺术

React 组件的核心是通过 props 接收数据。那么,我们与 TypeScript 的第一次亲密接触,也自然从定义 props 的“形状”和“契约”开始。

6.1.1. 基础:为 Props 定义类型

在纯 JavaScript 中,我们无法限制一个组件能接收哪些 props,也无法限制这些 props 的类型。但在 TypeScript 中,我们可以像签订合同一样,精确地定义组件的“输入”。

让我们从一个简单的 User 组件开始。

文件路径: src/components/User.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 这是一个没有类型定义的组件,存在风险
// function User(props) {
// return (
// <main>
// <h2>{props.name}</h2>
// <p> 年龄: {props.age}</p>
// </main>
// );
// }

// 方式一:内联类型定义 (Inline Typing)
// 我们可以直接在函数参数后面使用 `:` 来定义 props 对象的形状
const User = (props: { name: string; age: number; isStudent: boolean }) => {
return (
<main>
<h2 className="text-xl font-bold">{props.name}</h2>
<p>年龄: {props.age}</p>
<p>是学生吗? {props.isStudent ? '是' : '否'}</p>
</main>
);
};

export default User;

现在,当我们在 App.tsx 中使用这个组件时,TypeScript 和我们的编辑器会立刻成为我们的“守护神”。

文件路径: src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import User from './components/User';

function App() {
return (
<div>
{/* 正确使用 */}
<User name="Prorise" age={3} isStudent={true} />

{/* 尝试传递错误类型,编辑器会立刻报错! */}
{/* <User name="Prorise" age="三岁" isStudent={true} /> */}
{/* ^^^^^^^^^^ 类型 'string' 不能赋值给类型 'number' */}

{/* 尝试漏掉某个 prop,编辑器也会报错! */}
{/* <User name="Prorise" isStudent={true} /> */}
{/* `^^^^` 缺少属性 'age' */}
</div>
);
}

6.1.2. 优化:解构与自定义类型 (type / interface)

内联类型定义虽然直接,但有两个缺点:一是当 props 很多时会显得很冗长;二是我们每次都写 props.xxx 也很繁琐。我们可以通过 解构自定义类型 来优化它。

文件路径: src/components/User.tsx (升级版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type UserProps = {
name: string
age: number
isStudent: boolean
}

// 我们也可以使用 `interface`,在定义组件 props 时,两者几乎等效
// interface UserProps {
// name: string;
// age: number;
// isStudent: boolean;
// }


const User = ({ name, age, isStudent }: UserProps) => {
return (
<main>
<h2 className="text-xl font-bold">{name}</h2>
<p>年龄:{age}</p>
<p>身份:{isStudent ? '学生' : '非学生'}</p>
</main>
)
}

export default User

这种写法是 React + TypeScript 开发中的 黄金标准:代码既简洁又类型安全。

6.1.3. 实战练习:创建一个带类型的 Button 组件

现在,让我们亲手实践一下。我们将创建一个可复用的 Button 组件,它需要接收 labelonClickdisabled 三个 props。

文件路径: src/components/Button.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React from 'react';

// 1. 使用 `type` 或 `interface` 为 Button 的 props 定义类型
type ButtonProps = {
label: string;
onClick: () => void; // `onClick` 是一个不接收参数、无返回值的函数
disabled?: boolean; // `?` 表示 `disabled` 是一个可选的 prop
};

const Button = ({ label, onClick, disabled = false }: ButtonProps) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`
px-4 py-2 font-semibold text-white rounded-md transition-colors
${disabled
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600'
}
`}
>
{label}
</button>
);
};

export default Button;

文件路径: src/App.tsx (使用 Button 组件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import Button from './components/Button';

function App() {
const handlePrimaryClick = () => {
alert('主按钮被点击了!');
};

return (
<div className="p-8 space-x-4">
<Button
label="点我"
onClick={handlePrimaryClick}
/>
<Button
label="禁用按钮"
onClick={() => alert('这个提示不会出现')}
disabled={true}
/>
</div>
);
}

export default App;

6.1.4. 特殊 Prop: children 的类型

我们知道,children 是一个特殊的 prop,代表了组件标签之间的内容。React 为它提供了一个专门的类型:React.ReactNode

文件路径: src/components/Card.tsx (示例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import type { ReactNode } from 'react'; // `type` 关键字表示我们只导入类型信息

type CardProps = {
children: ReactNode; // `ReactNode` 可以是任何 React 能渲染的东西
};

const Card = ({ children }: CardProps) => {
return (
<div className="p-4 border rounded-lg shadow-md">
{children}
</div>
);
};

export default Card;

现在,我们可以在 App.tsx 中安全地使用 Card 组件来包裹任何内容。

1
2
3
4
5
6
7
8
9
10
11
import Card from './components/Card';
import User from './components/User';

function App() {
return (
<Card>
{/* 这里的 User 组件就是 Card 的 children */}
<User name="Prorise" age={3} isStudent={true} />
</Card>
);
}

总结:
为 props 添加类型,是我们从 JavaScript 迈向 TypeScript 的第一步,也是最重要的一步。它像一道坚固的防线,能拦截掉绝大多数因数据类型错误而引发的 Bug,并为我们提供了无与伦比的开发体验。


6.2. 类型的复用与组合

在上一节中,我们学会了为单个组件定义 props 类型。但随着应用变得复杂,我们会发现一个普遍现象:不同的组件之间,往往需要共享相似甚至相同的 props 结构。例如,一个 UserProfileCard 组件和一个 UserEditForm 组件可能都需要接收一个包含 id, name, emailuser 对象。

如果我们为每个组件都重复定义一次这个 user 对象的类型,就会违反 DRY (Don’t Repeat Yourself) 原则,导致代码冗余和维护困难。本节,我们将学习如何创建可复用的、可组合的类型,从根本上解决这个问题。

6.2.1. 第一步:创建全局类型定义文件

最佳实践是将那些需要在多个地方共享的类型,抽离到一个或多个专门的 .ts 文件中。这通常放在一个 typesinterfaces 目录下。

让我们首先创建一个全局的类型定义文件。

文件路径: src/types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 定义一个基础的用户信息类型,包含所有用户共有的属性
type Info = {
id: number;
name: string;
email: string;
};

// 定义一个管理员信息类型
// 它不仅包含所有基础用户信息,还有额外的管理员专属属性
// 我们使用 `&` (交叉类型) 来实现类型的“继承”和“扩展”
type AdminInfoList = Info & {
role: string;
lastLogin: Date;
};

// 使用 `export type` 将这些类型导出,以便在其他文件中使用
export { type Info, type AdminInfoList };

type vs. interface for Extension:

  • 使用 type: 我们通过交叉类型 & 来合并两个类型,例如 type C = A & B
  • 使用 interface: 我们可以通过 extends 关键字来实现继承,例如 interface C extends A { /* B's properties */ }。在大多数场景下,两者都能达到相似的效果,选择哪种主要取决于团队的编码规范和个人偏好。

6.2.2. 第二步:在组件中导入并使用共享类型

现在,我们可以在不同的组件中,像导入一个模块一样,导入并使用我们刚刚定义的共享类型。

创建 UserInfo 组件

这个组件只关心基础的用户信息。

文件路径: src/components/UserInfo.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react'
// 从我们的全局类型文件中导入 `Info` 类型
import { type Info } from '../types'

// 定义 UserInfo 组件的 props 类型
// 它需要一个 `user` prop,这个 prop 的类型必须符合我们导入的 `Info` 接口
type UserInfoProps = {
user: Info
}

const UserInfo: React.FC<UserInfoProps> = ({ user }) => {
return (
<div className="p-4 bg-gray-100 rounded-lg shadow-md mb-4">
<h2 className="text-xl font-bold text-gray-800">普通用户信息</h2>
<p>ID: {user.id}</p>
<p>姓名: {user.name}</p>
<p>邮箱: {user.email}</p>
</div>
)
}

export default UserInfo

React.FC 是什么?
React.FC (或 React.FunctionComponent) 是一个内置的 TypeScript 类型,用于定义函数组件。它提供了一些基础的类型定义(例如 children),虽然在现代 React 中,我们更推荐直接像 const MyComponent = ({...}: MyProps) => {} 这样定义组件,但在许多代码库中您仍然会看到 React.FC 的使用。

1
2
3
const UserInfo = ({ user }: UserInfoProps) => {
}
export default UserInfo

创建 AdminInfo 组件

这个组件需要更详细的管理员信息。

文件路径: src/components/AdminInfo.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react'
// 从全局类型文件中导入 `AdminInfoList` 类型
import { type AdminInfoList } from '../types'

type AdminInfoProps = {
admin: AdminInfoList
}

const AdminInfo = ({ admin }: AdminInfoProps) => {
return (
<div className="p-4 bg-blue-100 rounded-lg shadow-md">
<h2 className="text-xl font-bold text-blue-800">管理员信息</h2>
<p>ID: {admin.id}</p>
<p>姓名: {admin.name}</p>
<p>邮箱: {admin.email}</p>
<p>角色: {admin.role}</p>
<p>上次登录: {admin.lastLogin.toLocaleString('zh-CN')}</p>
</div>
)
}

export default AdminInfo

6.2.3. 第三步:在 App.tsx 中组合使用

最后,我们在主应用中创建符合这些类型的数据,并将其传递给相应的组件。TypeScript 会在后台默默地为我们检查所有的数据结构是否正确。

文件路径: src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import UserInfo from './components/UserInfo'
import AdminInfo from './components/AdminInfo'
// 同时导入组件和类型
import { type Info, type AdminInfoList } from './types'

const App = () => {
// 创建一个符合 `Info` 类型的用户数据
const user: Info = {
id: 1,
name: '张三',
email: 'zhangsan@example.com',
}

// 创建一个符合 `AdminInfoList` 类型的管理员数据
const admin: AdminInfoList = {
id: 2,
name: '李四',
email: 'lisi@example.com',
role: '管理员',
lastLogin: new Date(),
}

return (
<div className="container mx-auto p-8">
<UserInfo user={user} />
<AdminInfo admin={admin} />
</div>
)
}

export default App

总结:
通过将共享的类型定义抽离到单独的文件中,我们实现了 类型层面的代码复用。这种做法极大地提升了大型应用的可维护性:

  • 单一事实来源: 当用户数据结构需要变更时(例如增加一个 age 字段),我们只需要修改 src/types.ts 文件,所有使用该类型的组件都会立刻得到类型检查的提示,确保我们不会遗漏任何需要修改的地方。
  • 代码的清晰性与自文档化: import { type Info } from '../types' 这行代码清晰地告诉了任何阅读者,UserInfo 组件依赖于一个全局定义的数据结构。

6.3. Hooks 的类型推断与约束

我们已经掌握了如何为组件的“输入” (props) 添加类型。现在,我们将焦点转向组件的“内部”:如何为 useState hook 管理的状态添加类型,确保我们的组件不仅接收的数据是正确的,其内部维护的数据也同样安全可靠。

6.3.1. 基础:useState 的类型推断

TypeScript 最强大的功能之一就是类型推断。在大多数简单场景下,我们甚至不需要为 useState 显式地指定类型,因为它足够“聪明”,能够根据我们提供的 初始值 自动推断出状态的类型。

文件路径: src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { useState } from "react";
import UserProfile from "./components/UserProfile";
import TodoList from "./components/TodoList";

const App = () => {
// 场景1:初始值为数字 `0`
// TypeScript 自动推断出 `count` 的类型是 `number`
// `setCount` 的类型是 `React.Dispatch<React.SetStateAction<number>>`
// 这意味着 `setCount` 只能接收数字或一个返回数字的函数
const [count, setCount] = useState(0);

const increment = () => {
setCount((prevCount) => prevCount + 1);
};

// 尝试传递一个字符串给 setCount,会立即得到一个类型错误
// setCount("hello"); // ❌ 类型“string”的参数不能赋给类型“SetStateAction<number>”的参数

return (
<div className="container mx-auto p-8 space-y-8">
<div>
<h1 className="text-2xl font-bold">计数器: {count}</h1>
<button onClick={increment} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded">
增加
</button>
</div>

<UserProfile />
<TodoList />
</div>
);
};

export default App;

对于字符串、布尔值等基础类型,TypeScript 的类型推断都能完美工作,我们无需做任何额外的事情。

6.3.2. 进阶:显式指定复杂状态的类型

当我们的状态是一个复杂的对象或数组时,最佳实践是先使用 interfacetype 定义这个状态的“形状”,然后将它作为泛型参数传递给 useState

场景一:状态为对象 (Object)

让我们构建一个用户个人资料的组件,其状态是一个包含多个字段的对象。

文件路径: src/components/UserProfile.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { useState } from "react";

// 1. 定义状态对象的形状
interface UserProfileData {
name: string;
age: number;
email: string;
}

const UserProfile = () => {
// 2. 将 UserProfileData 作为泛型传递给 useState
// 并提供一个符合该类型的初始值
const [profile, setProfile] = useState<UserProfileData>({
name: "",
age: 0,
email: "",
});

const updateName = (name: string) => {
setProfile((prevProfile) => ({ ...prevProfile, name }));
};

// ... 其他更新函数 ...

return (
<div className="p-4 bg-gray-100 rounded-lg shadow-md space-y-4">
<h2 className="text-xl font-bold text-gray-800">用户个人资料</h2>
<div className="flex flex-col space-y-2">
<input
type="text"
placeholder="姓名"
value={profile.name}
onChange={(e) => updateName(e.target.value)}
className="p-2 border rounded"
/>
<input
type="number"
placeholder="年龄"
value={profile.age > 0 ? profile.age : ""}
onChange={(e) => setProfile(p => ({...p, age: Number(e.target.value)}))}
className="p-2 border rounded"
/>
<input
type="email"
placeholder="邮箱"
value={profile.email}
onChange={(e) => setProfile(p => ({...p, email: e.target.value}))}
className="p-2 border rounded"
/>
</div>
<div className="mt-4 p-2 bg-gray-200 rounded">
<h3 className="font-semibold">资料预览:</h3>
<p>姓名: {profile.name}</p>
<p>年龄: {profile.age}</p>
<p>邮箱: {profile.email}</p>
</div>
</div>
);
};

export default UserProfile;

场景二:状态为对象数组 (Array of Objects)

现在,我们来构建一个 Todo List,其状态是一个由多个 Todo 对象组成的数组。

文件路径: src/components/TodoList.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { useState } from 'react'

interface Todo {
id: number
task: string
completed: boolean
}
function TodoList({ id, task, completed }: Todo) {
// 2. 将 `Todo[]` (表示一个 Todo 对象的数组) 作为泛型传递给 useState
const [todos, setTodos] = useState<Todo[]>([])

const addTodo = (task: string) => {
const newTodo: Todo = {
id: Date.now(), // 使用时间戳作为更可靠的唯一 ID
task,
completed: false,
}
// 现在 TypeScript 知道 newTodo 符合 Todo 类型,可以安全地添加到数组中
setTodos((prevTodos) => [...prevTodos, newTodo])
}

return (
<div className="p-4 bg-gray-100 rounded-lg shadow-md">
<h2 className="text-xl font-bold text-gray-800">待办事项列表</h2>
<button
onClick={() => addTodo('学习 TypeScript')}
className="my-2 px-4 py-2 bg-green-500 text-white rounded"
>
添加任务
</button>
<ul className="list-disc pl-5">
{todos.map((todo) => (
<li
key={todo.id}
className={todo.completed ? 'line-through text-gray-500' : ''}
>
{todo.task}
</li>
))}
</ul>
</div>
)
}

export default TodoList

6.3.3. 特殊场景:初始状态为 null

在处理异步数据时,我们常常遇到的一个场景是:数据在初始时不存在,需要等 API 返回。此时,状态的类型可能是 Data | null。这就必须使用显式泛型了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 假设我们有一个 User 类型
interface User {
id: number;
name: string;
}

function UserData() {
// 我们必须明确告诉 TypeScript,`user` 的类型
// 可能是 `User` 对象,也可能是 `null`
const [user, setUser] = useState<User | null>(null);

useEffect(() => {
fetchUserData().then(data => {
setUser(data); // `data` 符合 `User` 类型,可以赋值
});
}, []);

if (!user) {
return <p>加载中...</p>;
}

// 在这里,TypeScript 知道 `user` 不再是 null,可以安全地访问 `user.name`
return <h1>欢迎, {user.name}</h1>;
}
**总结**:
  • 对于 基础类型string, number, boolean),依赖 useState类型推断 即可。
  • 对于 对象和数组,最佳实践是先 定义类型 (type/interface),然后 显式地将其作为泛型 传递给 useState,如 useState<MyType>({...})useState<MyType[]>([])
  • 当状态的初始值和后续值的类型不完全一致时(最常见的是 null -> object),必须使用联合类型泛型,如 useState<User | null>(null)

6.4. 事件、表单与 useRef 的精确类型

我们已经学会了如何为 propsstate 添加类型,现在我们将把类型安全的“保护网”撒向 React 应用中负责交互的“神经末梢”——事件处理函数、表单以及用于 DOM 引用的 useRef

6.4.1. useRef 的类型化

我们在 4.2 节已经学习了 useRef 的用法,但当时我们并未关注其类型。在 TypeScript 中,为 useRef 提供正确的类型至关重要,这能确保我们在访问 .current 属性时,能够获得该 DOM 元素所有的方法和属性的类型提示。

文件路径: src/components/FocusInput.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { useRef } from 'react'

const FocusInput = () => {
// 关键:为 useRef 提供一个泛型,指明它将引用一个 HTMLInputElement 元素。
// 初始值为 `null`,因为在组件首次渲染时,DOM 元素还不存在。
const inputRef = useRef<HTMLInputElement>(null)
const handleFocus = () => {
// 使用可选链操作符 `?.` 是一个好习惯。
// 它能确保即使 `inputRef.current` 为 `null`,代码也不会报错。
inputRef.current?.focus()
// 现在,当您输入 `inputRef.current.` 时,
// 编辑器会自动提示 `.focus()`, `.value`, `.select()` 等所有 input 元素的方法和属性!
}

return (
<div className="p-4 bg-gray-100 rounded-lg shadow-md">
<h2 className="text-xl font-bold text-gray-800 mb-2">聚焦搜索框</h2>
<input
type="text"
ref={inputRef}
placeholder="点击按钮来聚焦"
className="p-2 border rounded mr-2"
/>
<button
onClick={handleFocus}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
聚焦输入框
</button>
</div>
)
}

export default FocusInput;

6.4.2. 事件处理函数的精确类型

在之前的章节中,我们可能为了方便而忽略了事件对象的类型。现在,我们将学习如何从 @types/react 中导入精确的事件类型,告别 any,拥抱类型安全。

文件路径: src/components/EventHandling.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React from 'react'; // 必须导入 React 才能使用其内置的事件类型

const EventHandling = () => {
// 为鼠标点击事件提供精确类型:React.MouseEvent
// 泛型 `<HTMLButtonElement>` 指明了事件源自于一个 button 元素
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log("按钮被点击了!", e.currentTarget);
// 现在 `e.` 会提示所有鼠标事件相关的属性,如 `e.clientX`, `e.preventDefault()` 等
};

// 为鼠标移入事件提供类型
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
console.log("鼠标进入了 div!", e.currentTarget);
};

return (
<div
onMouseEnter={handleMouseEnter}
className="p-4 bg-gray-100 rounded-lg shadow-md mt-4"
>
<h2 className="text-xl font-bold text-gray-800 mb-2">事件类型示例</h2>
<button
onClick={handleClick}
className="px-4 py-2 bg-green-500 text-white rounded"
>
点我!
</button>
</div>
);
};

export default EventHandling;

常用事件类型:

多敲多记即可

  • 鼠标事件: React.MouseEvent
  • 表单/输入框事件: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  • 表单提交事件: React.FormEvent<HTMLFormElement>
  • 键盘事件: React.KeyboardEvent
  • 焦点事件: React.FocusEvent

6.4.3. 类型化的表单处理

现在,我们将所有知识融会贯通,构建一个完全类型安全的联系人表单。我们将为表单的 state、输入框的 onChange 事件以及表单的 onSubmit 事件都提供精确的类型。

文件路径: src/components/ContactForm.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import { useState } from 'react'

// 1. 为表单的 state 定义类型
interface ContactFormState {
name: string
email: string
}

const ContactForm = () => {
// 2. 将类型应用于 useState
const [formData, setFormData] = useState<ContactFormState>({
name: '',
email: '',
})

// 3. 为输入框的 `onChange` 事件提供精确类型
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// 首先从事件对象 e.target 中解构出 name 和 value 属性,这两个属性分别代表了输入框的名称和用户输入的值。
const { name, value } = e.target
// 使用这种语法的原因是,它可以动态地根据输入框的 name 属性来更新对应的 formData 对象中的值。这样可以避免写多个 if-else 语句来处理不同输入框的变化,使代码更加简洁和易于维护。
// 例如,如果 name 变量的值是 "username",那么 [name]: value 就相当于 username: value;如果 name 是 "email",就相当于 email: value。
setFormData((prevState) => ({ ...prevState, [name]: value }))
}

// 4. 为表单的 `onSubmit` 事件提供精确类型
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
console.log('表单已提交:', formData)
// 在这里可以处理表单提交逻辑,例如发送数据到 API
alert(`你好, ${formData.name}!`)
}


return (
<form
onSubmit={handleSubmit}
className="p-4 bg-gray-100 rounded-lg shadow-md mt-4 space-y-4"
>
<h2 className="text-xl font-bold text-gray-800">类型化表单示例</h2>
<div>
<label className="block mb-1 font-semibold">姓名:</label>
<input
type="text"
placeholder='请输入你的姓名'
name="name" // `name` 属性必须与 state 中的键匹配
value={formData.name}
onChange={handleChange}
className="p-2 border rounded w-full"
/>
</div>
<div>
<label className="block mb-1 font-semibold">邮箱:</label>
<input
type="email"
placeholder='请输入你的邮箱'
name="email"
value={formData.email}
onChange={handleChange}
className="p-2 border rounded w-full"
/>
</div>
<button type="submit" className="w-full px-4 py-2 bg-purple-500 text-white rounded">
提交
</button>
</form>
);
}

export default ContactForm;

总结:
通过为 useRef、事件和表单处理添加精确的类型,我们为应用的交互层构建了一道坚不可摧的“防火墙”。这不仅能防止因类型不匹配导致的运行时错误,更能极大地提升我们的开发效率——编辑器会成为我们最可靠的伙伴,为我们提供精准的自动补全和实时的错误检查。


6.5. 高级 Hooks 的类型化

在本章的最后一部分,我们将攻克那些负责“全局”和“复杂”逻辑的 Hooks 的类型化问题,特别是 Context APIuseReducer。为它们提供类型,就像是为我们应用的“数据高速公路”和“状态机引擎”设置了精准的交通规则,能从根本上保证数据流动的安全与可预测性。

6.5.1. Context API 的类型化

我们在 4.1 节已经学习了如何使用 Context 来避免属性钻探。现在,我们将为其添加 TypeScript 类型,确保我们在整个应用中共享的数据始终符合我们预期的“契约”。

类型化 Context 通常分为三步:定义 Context 数据的形状 -> 创建带类型的 Context -> 创建带类型的 Provider。

场景一:简单的值共享 (计数器)

让我们先从一个共享简单计数值的 Context 开始。

文件路径: src/contexts/CounterContext.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { createContext, useState, type ReactNode, type FC } from "react";

// 1. 定义 Context 将要共享的数据和方法的“形状”
interface CounterContextProps {
count: number;
increment: () => void;
decrement: () => void;
}

// 2. 创建 Context,并为其提供一个符合接口的默认值
// 这个默认值主要用于类型推断和在没有 Provider 的情况下单独测试消费者组件
export const CounterContext = createContext<CounterContextProps>({
count: 0,
increment: () => {},
decrement: () => {},
});

// 3. 创建一个类型化的 Provider 组件
// 它负责管理真实的状态,并将状态通过 Context.Provider 传递下去
interface CounterProviderProps {
children: ReactNode;
}

export const CounterProvider: FC<CounterProviderProps> = ({ children }) => {
const [count, setCount] = useState(0);

const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);

return (
<CounterContext.Provider value={{ count, increment, decrement }}>
{children}
</CounterContext.Provider>
);
};

现在,我们可以在应用中使用这个类型安全的 Context

文件路径: src/App.tsx (包裹 Provider)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import CounterDisplay from "./components/CounterDisplay";
import { CounterProvider } from "./contexts/CounterContext";

export default function App() {
return (
// 在应用的顶层(或任何需要共享状态的子树的根部)包裹 Provider
<CounterProvider>
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">类型化的 Context 示例</h1>
<CounterDisplay />
</div>
</CounterProvider>
);
}

文件路径: src/components/CounterDisplay.tsx (消费 Context)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useContext } from "react";
import { CounterContext } from "../contexts/CounterContext";

const CounterDisplay: React.FC = () => {
// `useContext` 会返回我们在 Provider 的 value 中传递的对象
// TypeScript 知道它的类型是 `CounterContextProps`
const { count, increment, decrement } = useContext(CounterContext);

return (
<div className="p-4 bg-gray-100 rounded-lg shadow-md">
<p className="text-2xl">Count: {count}</p>
<div className="space-x-2 mt-2">
<button onClick={increment} className="px-4 py-2 bg-green-500 text-white rounded">Increment</button>
<button onClick={decrement} className="px-4 py-2 bg-red-500 text-white rounded">Decrement</button>
</div>
</div>
);
};

export default CounterDisplay;

场景二:处理可能为 undefined 的 Context

在某些设计模式中,我们希望强制要求消费者组件必须被包裹在 Provider 内部,否则就应该报错。我们可以通过将 createContext 的初始值设为 undefined 来实现这一点。

文件路径: src/contexts/SharedInputContext.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { createContext, type ReactNode, useState, FC, useContext } from "react";

// 1. 定义 Context 数据的类型
type SharedInputContextData = {
value: string;
setValue: (newValue: string) => void;
};

// 2. 创建 Context,初始值为 undefined
// 我们明确告诉 TypeScript,这个 Context 的值可能是 `MyContextData`,也可能是 `undefined`
const SharedInputContext = createContext<SharedInputContextData | undefined>(undefined);

// 3. 创建 Provider 组件(与之前类似)
type SharedInputContextProviderProps = {
children: ReactNode;
};

export const SharedInputContextProvider: FC<SharedInputContextProviderProps> = ({ children }) => {
const [value, setValue] = useState<string>("");
return (
<SharedInputContext.Provider value={{ value, setValue }}>
{children}
</SharedInputContext.Provider>
);
};

// 4. (最佳实践) 创建一个自定义 Hook 来消费 Context
// 这个 Hook 内部处理了 undefined 的情况,让消费者组件更干净
export const useSharedInput = () => {
const context = useContext(SharedInputContext);
if (context === undefined) {
throw new Error("useSharedInput must be used within a SharedInputContextProvider");
}
return context;
};

现在,消费者组件的代码将变得更加优雅和健壮。

文件路径: src/components/SharedInputDisplay.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useSharedInput } from "../contexts/SharedInputContext"; // 导入自定义 Hook

const SharedInputDisplay = () => {
// 使用我们的自定义 Hook,不再需要自己处理 undefined 的情况
const { value, setValue } = useSharedInput();

return (
<div className="p-4 bg-blue-100 rounded-lg shadow-md mt-4">
<p className="text-xl">共享的值: {value}</p>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
className="mt-2 p-2 border rounded w-full"
placeholder="在这里输入..."
/>
</div>
);
};

export default SharedInputDisplay;

总结:
Context 提供类型,是构建可扩展、类型安全的 React 应用的基石。通过创建一个自定义 Hook 来消费 Context,我们可以将“检查 Context 是否存在”的逻辑封装起来,为所有消费者组件提供一个更简洁、更安全的 API。


6.5.2. useReducer 的类型化

我们在 2.3.4 节已经了解了 useReducer 在处理复杂状态逻辑时的优势。现在,我们将为它的三个核心要素——stateactionreducer 函数——都加上精确的类型定义。这能将 useReducer 的优势发挥到极致,让我们的状态管理代码变得坚如磐石。

类型化 useReducer 通常分为三步:定义 State 和 Action 的类型 -> 创建类型化的 Reducer 函数 -> 在组件中使用。

第一步:定义 State 和 Action 的类型

最佳实践是将 reducer 相关的类型和逻辑抽离到单独的文件中,这让我们的代码结构更清晰,也使得 reducer 逻辑可以被独立测试。

文件路径: src/reducers/counterReducer.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. 定义状态 state 的“形状”
export type CounterState = {
count: number;
};

// 2. 使用“可辨识联合类型” (Discriminated Union) 来定义所有可能的 action
// 这是一种强大的 TypeScript 模式,`type` 属性就是那个“可辨识”的字段
type IncrementAction = {
type: "INCREMENT";
};

type DecrementAction = {
type: "DECREMENT";
};

// 如果有需要 payload 的 action,可以这样定义:
// type AddAction = {
// type: "ADD";
// payload: number;
// };

// 3. 将所有 action 类型合并为一个联合类型
export type CounterAction = IncrementAction | DecrementAction; // | AddAction

第二步:创建类型化的 Reducer 函数

现在,我们在同一个文件中创建 reducer 函数,并为它的参数和返回值应用我们刚刚创建的类型。

文件路径: src/reducers/counterReducer.ts (继续)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 1. 定义状态 state 的“形状”
export type CounterState = {
count: number
}

// 2. 使用“可辨识联合类型” (Discriminated Union) 来定义所有可能的 action
// 这是一种强大的 TypeScript 模式,`type` 属性就是那个“可辨识”的字段
type IncrementAction = {
type: 'INCREMENT'
}

type DecrementAction = {
type: 'DECREMENT'
}

// 如果有需要 payload 的 action,可以这样定义:
// type AddAction = {
// type: "ADD";
// payload: number;
// };

// 3. 将所有 action 类型合并为一个联合类型
export type CounterAction = IncrementAction | DecrementAction // | AddAction

// 4. 创建 reducer 函数,并为其参数和返回值提供精确的类型
export const counterReducer = (
state: CounterState,
action: CounterAction
): CounterState => {
switch (action.type) {
case 'INCREMENT':
// TypeScript 在这里知道 action 的类型是 IncrementAction
// 并且知道返回值必须符合 CounterState 的形状
return { count: state.count + 1 }

case 'DECREMENT':
return { count: state.count - 1 }

default:
// 如果 action.type 不匹配任何 case,保持状态不变
return state
}
}

第三步:在组件中使用类型化的 useReducer

现在,我们在组件中使用 useReducer 时,TypeScript 会利用我们之前定义的类型,提供完美的类型推断和安全检查。

文件路径: src/components/Counter.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { useReducer } from "react";
// 导入 reducer 函数和 state 类型
import { counterReducer, CounterState } from "../reducers/counterReducer";

// 定义组件的初始状态,它必须符合 `CounterState` 类型
const initialState: CounterState = { count: 0 };

const Counter = () => {
// `useReducer` 会根据 `counterReducer` 和 `initialState` 的类型,
// 自动推断出 `state` 的类型是 `CounterState`,
// `dispatch` 的类型是 `React.Dispatch<CounterAction>`
const [state, dispatch] = useReducer(counterReducer, initialState);

const increment = () => {
// `dispatch` 的参数必须符合我们定义的 `CounterAction` 联合类型
dispatch({ type: "INCREMENT" });
// dispatch({ type: "INCREASE" }); // ❌ 类型错误! "INCREASE" 不在联合类型中
};

const decrement = () => {
dispatch({ type: "DECREMENT" });
};

return (
<div className="p-4 bg-gray-100 rounded-lg shadow-md mt-4">
<h2 className="text-2xl font-bold">Count: {state.count}</h2>
<div className="space-x-2 mt-2">
<button onClick={increment} className="px-4 py-2 bg-green-500 text-white rounded">Increment</button>
<button onClick={decrement} className="px-4 py-2 bg-red-500 text-white rounded">Decrement</button>
</div>
</div>
);
};

export default Counter;

App.tsx (整合组件)

文件路径: src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from "react";
import Counter from "./components/Counter";

const App: React.FC = () => {
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold">React + TypeScript: useReducer 示例</h1>
<Counter />
</div>
);
};

export default App;

总结:
useReducer 添加类型,是构建复杂、可预测、易于维护的状态管理系统的关键。通过使用“可辨识联合类型”来定义 Actions,我们可以在 reducer 函数的 switch 语句中享受到 TypeScript 强大的类型收窄能力,它能确保我们在处理每一种 action 时,都能安全地访问其特有的属性(如 payload),从而在编码阶段就杜绝大量的潜在逻辑错误。


6.5.3. useEffect 的类型化实践:构建类型安全的数据获取组件

到目前为止,我们已经为 props, state, events, refs, context, 和 reducer 都添加了类型。现在,我们将把这些能力整合起来,构建一个完全类型安全的异步数据获取组件。

useEffect 本身不需要特殊的类型定义,但它所 引发 的副作用——特别是那些与 useState 交互的副作用——正是 TypeScript 大显身手的地方。

项目目标
我们将构建一个 UserList 组件,它会在挂载时从 JSONPlaceholder API 获取用户列表,并以表格的形式展示出来。整个过程将是完全类型安全的。

image-20250925133719947

核心概念巩固

  • interface: 为 API 返回的数据结构定义清晰的类型契约。
  • useState 泛型: 使用联合类型 (User[] | null) 来处理异步数据的不同阶段。
  • useEffect: 在组件挂载时安全地执行异步数据获取。
  • 类型断言与守卫: 在 catch 块中安全地处理错误类型。
  • Tailwind CSS: 为表格添加简洁、美观的样式。

项目结构

1
2
3
4
5
# src/
├── components/
│ └── UserList/
│ └── UserList.tsx # 唯一的组件文件
└── App.tsx # 应用主入口

1. UserList.tsx (核心组件)
在这个组件中,我们将完成从定义数据类型到获取、存储和渲染数据的完整闭环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import { useEffect, useState } from "react";

// 1. 使用 `interface` 定义 API 返回的用户数据结构
interface User {
id: number;
name: string;
username: string;
email: string;
phone: string;
}

const UserList = () => {
// 2. 为所有状态提供精确的类型
const [users, setUsers] = useState<User[]>([]); // 状态可以是 User 对象的数组
const [loading, setLoading] = useState<boolean>(true); // 加载状态是布尔值
const [error, setError] = useState<string | null>(null); // 错误状态可以是字符串或 null

useEffect(() => {
const fetchUsers = async () => {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users"
);
if (!response.ok) {
throw new Error("网络响应失败,请稍后再试");
}
// 3. 告诉 TypeScript `response.json()` 的返回值将是 `User[]` 类型
const data: User[] = await response.json();
setUsers(data);
} catch (error) {
// 4. 安全地处理错误类型
// 使用 `instanceof Error` 来检查捕获到的 `error` 是否是一个真正的错误对象
if (error instanceof Error) {
setError(error.message);
} else {
setError("发生了一个未知错误");
}
} finally {
setLoading(false);
}
};

fetchUsers();
}, []); // 空依赖数组确保只在挂载时执行一次

if (loading) return <div className="text-center p-8">加载中...</div>;
if (error) return <div className="text-center p-8 text-red-500">错误: {error}</div>;

return (
<div className="overflow-x-auto">
<table className="min-w-full bg-white shadow-md rounded-lg">
<thead className="bg-gray-800 text-white">
<tr>
<th className="py-3 px-4 text-left">姓名</th>
<th className="py-3 px-4 text-left">用户名</th>
<th className="py-3 px-4 text-left">邮箱</th>
<th className="py-3 px-4 text-left">电话</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b hover:bg-gray-100">
{/* TypeScript 知道 `user` 是 User 类型,所以可以安全地访问其属性 */}
<td className="py-3 px-4">{user.name}</td>
<td className="py-3 px-4">{user.username}</td>
<td className="py-3 px-4">{user.email}</td>
<td className="py-3 px-4">{user.phone}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

export default UserList;

2. App.tsx (应用入口)

1
2
3
4
5
6
7
8
9
10
11
12
import UserList from "./components/UserList/UserList";

const App = () => {
return (
<main className="container mx-auto p-8">
<h1 className="text-4xl font-bold mb-6 text-center"> 用户列表 </h1>
<UserList />
</main>
);
};

export default App;

第六章总结:TypeScript 的价值
恭喜您!我们已经完成了从 JavaScript 到 TypeScript 的全面升级。通过本章的学习,我们不再仅仅是“使用”React,而是以一种更专业、更严谨、更安全的方式来“构建”React 应用。

我们为 propsstatehooksevents 都添加了精确的类型。这层“类型铠甲”将在未来的开发中,为您抵挡无数潜在的 Bug,提升代码的可维护性,并最终让您成为一名更自信、更高效的 React 工程师。