第六章: TypeScript:为 React 应用注入类型之魂
摘要: 在前面的章节中,我们已经使用 JavaScript 构建了多个功能完备的 React 应用。我们已经体会到了 React 的强大,但同时也可能感受到了 JavaScript 动态类型带来的一些“不安全感”。随着应用规模的扩大,我们如何确保传递给组件的数据总是正确的?如何避免因为一个简单的拼写错误或类型不匹配而导致的运行时 Bug?本章将给出答案:TypeScript。
为什么要在学完 React 基础后再深入 TypeScript?
这是一个经过精心设计的学习路径。我们之所以先用 JavaScript 学习 React,是为了让您能够 专注于 React 自身的核心思想——组件化、状态管理、Hooks 等,而不被类型定义的语法分散注意力。
现在,您已经对 React 的“骨架”了然于胸,是时候为其穿上“铠甲”了。TypeScript 这层铠甲将为我们带来:
- 代码的健壮性: 在代码 运行前(编译阶段)就能发现大量的潜在错误,而不是等到用户在浏览器中遇到问题。
- 开发体验的飞跃: 享受无与伦比的编辑器自动补全、类型提示和重构能力,让我们写代码更快、更自信。
- 团队协作的基石: 类型定义本身就是最精准、最不会过时的“文档”。任何接手我们代码的同事都能立即明白一个组件需要什么数据,返回什么结果。
在本章中,我们将系统性地学习如何将 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
|
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 }
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 开发中的 黄金标准:代码既简洁又类型安全。
现在,让我们亲手实践一下。我们将创建一个可复用的 Button
组件,它需要接收 label
、onClick
和 disabled
三个 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';
type ButtonProps = { label: string; onClick: () => void; disabled?: boolean; };
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 CardProps = { children: ReactNode; };
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
, email
的 user
对象。
如果我们为每个组件都重复定义一次这个 user
对象的类型,就会违反 DRY (Don’t Repeat Yourself) 原则,导致代码冗余和维护困难。本节,我们将学习如何创建可复用的、可组合的类型,从根本上解决这个问题。
6.2.1. 第一步:创建全局类型定义文件
最佳实践是将那些需要在多个地方共享的类型,抽离到一个或多个专门的 .ts
文件中。这通常放在一个 types
或 interfaces
目录下。
让我们首先创建一个全局的类型定义文件。
文件路径: 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 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'
import { type Info } from '../types'
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'
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 = () => { const user: Info = { id: 1, name: '张三', email: 'zhangsan@example.com', }
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 = () => { const [count, setCount] = useState(0);
const increment = () => { setCount((prevCount) => prevCount + 1); };
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. 进阶:显式指定复杂状态的类型
当我们的状态是一个复杂的对象或数组时,最佳实践是先使用 interface
或 type
定义这个状态的“形状”,然后将它作为泛型参数传递给 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";
interface UserProfileData { name: string; age: number; email: string; }
const UserProfile = () => { 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) { const [todos, setTodos] = useState<Todo[]>([])
const addTodo = (task: string) => { const newTodo: Todo = { id: Date.now(), task, completed: false, } 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
| interface User { id: number; name: string; }
function UserData() { const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetchUserData().then(data => { setUser(data); }); }, []);
if (!user) { return <p>加载中...</p>; } 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
的精确类型
我们已经学会了如何为 props
和 state
添加类型,现在我们将把类型安全的“保护网”撒向 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 = () => { const inputRef = useRef<HTMLInputElement>(null) const handleFocus = () => { inputRef.current?.focus() }
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';
const EventHandling = () => { const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { console.log("按钮被点击了!", e.currentTarget); };
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'
interface ContactFormState { name: string email: string }
const ContactForm = () => { const [formData, setFormData] = useState<ContactFormState>({ name: '', email: '', })
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const { name, value } = e.target setFormData((prevState) => ({ ...prevState, [name]: value })) }
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() console.log('表单已提交:', formData) 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 API
和 useReducer
。为它们提供类型,就像是为我们应用的“数据高速公路”和“状态机引擎”设置了精准的交通规则,能从根本上保证数据流动的安全与可预测性。
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";
interface CounterContextProps { count: number; increment: () => void; decrement: () => void; }
export const CounterContext = createContext<CounterContextProps>({ count: 0, increment: () => {}, decrement: () => {}, });
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 ( <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 = () => { 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";
type SharedInputContextData = { value: string; setValue: (newValue: string) => void; };
const SharedInputContext = createContext<SharedInputContextData | undefined>(undefined);
type SharedInputContextProviderProps = { children: ReactNode; };
export const SharedInputContextProvider: FC<SharedInputContextProviderProps> = ({ children }) => { const [value, setValue] = useState<string>(""); return ( <SharedInputContext.Provider value={{ value, setValue }}> {children} </SharedInputContext.Provider> ); };
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";
const SharedInputDisplay = () => { 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
在处理复杂状态逻辑时的优势。现在,我们将为它的三个核心要素——state
、action
和 reducer
函数——都加上精确的类型定义。这能将 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
| export type CounterState = { count: number; };
type IncrementAction = { type: "INCREMENT"; };
type DecrementAction = { type: "DECREMENT"; };
export type CounterAction = IncrementAction | DecrementAction;
|
第二步:创建类型化的 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
| export type CounterState = { count: number }
type IncrementAction = { type: 'INCREMENT' }
type DecrementAction = { type: 'DECREMENT' }
export type CounterAction = IncrementAction | DecrementAction
export const counterReducer = ( state: CounterState, action: CounterAction ): CounterState => { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }
case 'DECREMENT': return { count: state.count - 1 }
default: 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";
import { counterReducer, CounterState } from "../reducers/counterReducer";
const initialState: CounterState = { count: 0 };
const Counter = () => { const [state, dispatch] = useReducer(counterReducer, initialState);
const increment = () => { dispatch({ type: "INCREMENT" }); };
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 获取用户列表,并以表格的形式展示出来。整个过程将是完全类型安全的。

核心概念巩固
interface
: 为 API 返回的数据结构定义清晰的类型契约。useState
泛型: 使用联合类型 (User[] | null
) 来处理异步数据的不同阶段。useEffect
: 在组件挂载时安全地执行异步数据获取。- 类型断言与守卫: 在
catch
块中安全地处理错误类型。 - Tailwind CSS: 为表格添加简洁、美观的样式。
项目结构
1 2 3 4 5
| ├── 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";
interface User { id: number; name: string; username: string; email: string; phone: string; }
const UserList = () => { const [users, setUsers] = useState<User[]>([]); const [loading, setLoading] = useState<boolean>(true); const [error, setError] = useState<string | null>(null);
useEffect(() => { const fetchUsers = async () => { try { const response = await fetch( "https://jsonplaceholder.typicode.com/users" ); if (!response.ok) { throw new Error("网络响应失败,请稍后再试"); } const data: User[] = await response.json(); setUsers(data); } catch (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 应用。
我们为 props
、state
、hooks
和 events
都添加了精确的类型。这层“类型铠甲”将在未来的开发中,为您抵挡无数潜在的 Bug,提升代码的可维护性,并最终让您成为一名更自信、更高效的 React 工程师。