第五章 React 实战进阶:4 大阶段 10 个项目,系统巩固状态管理、交互式 UI 与 API 数据交互能力


第五章: 实战演练:从零构建 React 应用

摘要: 理论是基石,但只有通过亲手构建,知识才能真正内化为能力。在本章中,我们将告别孤立的知识点,进入一系列由简到繁的实战项目。我们的目标不是简单地“复刻”功能,而是通过每一个项目,有针对性地巩固、融合并深化前四章所学的核心概念——从 State 管理到副作用处理,再到自定义 Hooks 的应用。学完本章,您将具备独立构建功能完备的 React 组件和小型应用的能力。


在本章中,我们将遵循一条精心设计的技能升级路径,逐步解锁更复杂的应用场景:

  1. 阶段一:状态管理基石: 我们将从最核心的 useState 开始,通过构建 计数器待办事项列表,彻底掌握对数字、数组等基础数据结构的状态管理。
  2. 阶段二:交互式 UI 构建: 接着,我们将挑战 颜色切换器隐藏式搜索框 等项目,专注于 UI 状态的管理,创造更丰富的用户交互。
  3. 阶段三:异步数据流与 API 交互: 然后,我们将通过 餐饮 API 项目,首次引入 useEffect 处理网络请求,打通 React 应用与服务器的数据链路。

请相信我,每一节我都安排来不同的知识点,完全遵循最佳实践与之前学习过的所有知识点


5.1. 阶段一:状态管理基石

5.1.1. 项目实战:计数器 (Counter)

这是我们 React 实战之旅的第一站。计数器虽小,却蕴含了 React 数据驱动视图的核心思想。我们将通过它,将 useState 和事件处理的理论知识,转化为指尖上的代码。同时,我们将引入并实践 SCSS Modules,这是一种能将 SCSS 的强大功能与组件化样式隔离完美结合的最佳实践。

image-20250924150625150

实战准备:为项目添加 SCSS Modules 支持

1.6 节,我们已经为项目配置了 Tailwind CSS。现在,我们将学习另一种强大的样式方案:SCSS Modules。它允许我们在组件层面编写 SCSS,并自动确保样式不会泄露到其他组件,完美对标 Vue 的 <style scoped>

第一步:安装 SCSS 编译器
如果尚未安装,请确保您的项目已添加 sass 依赖。

1
pnpm add -D sass

第二步:理解 SCSS Modules 的工作方式
Vite 已为我们内置了 CSS Modules 的支持。我们只需遵循一个简单的命名约定:将样式文件命名为 [ComponentName].module.scss

当你这样做时:

  1. Vite 会将这个 SCSS 文件中的所有类名进行哈希处理,生成一个独一无二的类名(例如 .title 变成 .Counter_title__aB3xY)。
  2. 当你 import 这个文件时,它会返回一个 JavaScript 对象,键是你原始的类名,值是哈希后的唯一类名。

这种机制从根本上解决了 CSS 全局污染的问题。

项目目标

我们将构建一个简单的计数器应用,包含一个显示的数字、一个“增加”按钮和一个“减少”按钮。

  • useState: 用于管理计数器的数字状态。
  • 事件处理: onClick 事件绑定与处理函数的编写。
  • SCSS Modules: 实现组件级别的样式封装。

项目结构与代码解析

我们将采用“组件文件夹”的最佳实践来组织代码,将与 Counter 组件相关的所有文件都放在同一个地方。

1
2
3
4
5
6
# src/
├── components/
│ └── Counter/
│ ├── Counter.tsx # 组件逻辑
│ └── Counter.module.scss # 组件专属样式
└── App.tsx # 应用主入口

1. Counter.module.scss (组件样式)

首先,我们来编写样式。注意看我们是如何使用 SCSS 的嵌套和变量功能的。

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
/* Counter.module.scss */

.container {
text-align: center;

.numberDisplay {
font-size: 6rem;
color: #ffffff;
}
}

.buttonContainer {
width: 40rem;
display: flex;
justify-content: space-around;
margin-top: 5rem;
}

.actionButton {
padding: 10px 20px;
border-radius: 50px;
font-size: 2rem;
background: #141517;
color: #fff;
cursor: pointer;
border: none;
min-width: 60px; // 保证按钮大小一致

&:hover { // SCSS 的 & 嵌套语法
background: #2a2c2e;
}
}

2. 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
import React, { useState } from "react";
// 导入 SCSS Modules 文件,styles 是一个包含唯一类名的对象
import styles from "./Counter.module.scss";

function Counter() {
const [count, setCount] = useState(0);

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

return (
// 使用 styles 对象中的类名
<div className={styles.container}>
<h1 className={styles.numberDisplay}>{count}</h1>

<section className={styles.buttonContainer}>
<button onClick={increment} className={styles.actionButton}>
+
</button>
<button onClick={decrement} className={styles.actionButton}>
-
</button>
</section>
</div>
);
}

export default Counter;

3. App.tsx (应用入口)

最后,App.tsx 的职责是渲染我们的 Counter 组件,并提供一个全局的背景色(这里我们可以使用 Tailwind,展示两种样式方案的共存)。

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

function App() {
return (
// 使用 Tailwind CSS 提供全局样式
<main className="bg-black min-h-screen flex flex-col justify-center items-center">
<Counter />
</main>
);
};

export default App;

🤔 思考与扩展

现在你已经完成了一个使用 SCSS Modules 的计数器,尝试挑战一下自己:

  1. 添加重置功能: 增加一个“重置”按钮,点击后让计数器归零。
  2. 设置边界: 修改 decrement 函数,使得计数器的值不能小于 0。
  3. 动态样式: 当 count 大于 10 时,让数字的颜色变为绿色;小于 0 时,变为红色。你需要动态地拼接 styles 对象中的类名。

修改样式为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.container {
text-align: center;

.numberDisplay {
font-size: 6rem;
color: #ffffff;

&.positive {
color: #22c55e; // 绿色,当count > 10时
}

&.negative {
color: #ef4444; // 红色,当count < 0时
}
}
}

修改代码内容为:

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
import React, { useState } from 'react'
// 导入 SCSS Modules 文件,styles 是一个包含唯一类名的对象
import styles from './Counter.module.scss'

function Counter() {

// ...保持不变
// 动态生成类名
const getNumberDisplayClass = () => {
let className = styles.numberDisplay

if (count > 10) {
className += ` ${styles.positive}`
} else if (count === 0) {
className += ` ${styles.negative}`
}

return className
}

return (
// 使用 styles 对象中的类名
<div className={styles.container}>
<h1 className={getNumberDisplayClass()}>{count}</h1>
</div>
)
}

export default Counter

5.1.2. 项目实战:待办事项列表 (Todo List)

如果说“计数器”是 useState 的入门,那么“待办事项列表”就是我们掌握数组状态管理的第一次大考。在这个项目中,我们将学会如何以 React 的方式(不可变地)对一个列表进行增加和删除操作,这是构建动态应用的核心技能。

image-20250924180025036

项目目标

我们将构建一个经典的 Todo List 应用。用户可以:

  1. 在输入框中输入任务。
  2. 点击“提交”按钮,将新任务添加到列表中。
  3. 点击每项任务旁的“X”按钮,从列表中删除该任务。

核心概念巩固

  • useState: 管理输入框的字符串状态,以及待办事项的数组状态。
  • 数组的不可变更新: 使用 concatfilter 等方法来更新数组,而非直接修改。
  • 列表渲染: 使用 .map() 方法动态渲染列表,并为每一项提供唯一的 key
  • 受控组件: 将 input 输入框的 value 与 React state 绑定。

项目结构与代码解析
我们将继续遵循“组件文件夹”的最佳实践。

1
2
3
4
5
6
7
8
# src/
├── components/
│ ├── Counter/
│ │ └── ...
│ └── Todo/
│ ├── Todo.tsx # 组件逻辑
│ └── Todo.module.scss # 组件专属样式
└── App.tsx # 应用主入口

1. Todo.module.scss (组件样式)

首先,我们按照设计图将样式定义完毕

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
.container {
background: #fcfff3;
padding: 50px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);

input {
padding: 15px;
border: none;
outline: none;
background: #f5f9eb;
width: 300px;
margin-right: 10px;
border-radius: 4px;
}

button {
background: #454545;
padding: 10px 20px;
outline: none;
border: none;
color: #fff;
cursor: pointer;
transition: background-color 0.2s;

&:hover {
background: #606060;
}
}
}

.todosList {
margin-top: 3rem;
padding-left: 0; // 移除 ul 的默认 padding
}

.todoItem {
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
background: #f5f9eb;
padding: 7px 5px;
margin-top: 10px;
font-family: sans-serif;
border-radius: 4px;

.closeButton {
padding: 5px 10px;
background: #e53e3e; // 红色背景

&:hover {
background: #c53030;
}
}
}

2. Todo.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
import { useState } from 'react'
import styles from './Todo.module.scss'

// 定义 Todo 项的类型,这是 TypeScript 的最佳实践
interface TodoItem {
id: number
text: string
}

function Todo() {
// 状态1: 管理待办事项列表,初始为空数组
const [todos, setTodos] = useState<TodoItem[]>([])
// 状态2: 管理输入框的值,初始为空字符串
const [input, setInput] = useState('')

// 处理添加新 Todo 的逻辑
const handleAddTodo = () => {
// 防止添加空任务
if (!input.trim()) return

const newTodo: TodoItem = {
text: input,
// 注意:在真实应用中,绝不能使用随机数做 ID!
// 这里为了简化,我们使用时间戳作为唯一 ID。
id: Date.now(),
}

// 使用 concat 创建一个新数组来更新 state,保证不可变性
setTodos(todos.concat(newTodo))

// 清空输入框
setInput('')
}

// 处理删除 Todo 的逻辑
const removeTodo = (id: number) => {
// 使用 filter 创建一个不包含目标 id 的新数组
setTodos(todos.filter((t) => t.id !== id))
}

return (
<div className={styles.container}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddTodo()
}
}}
placeholder="新任务..."
/>

<button onClick={handleAddTodo}>提交</button>

<ul className={styles.todosList}>
{todos.map(({ text, id }) => (
<li key={id} className={styles.todoItem}>
<span>{text}</span>
<button
className={styles.closeButton}
onClick={() => removeTodo(id)}
>
X
</button>
</li>
))}
</ul>
</div>
)
}


export default Todo;

3. App.tsx (应用入口)

和上一个项目一样,App.tsx 的职责就是渲染我们的核心组件。

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

function App() {
return (
// 使用 Tailwind CSS 提供全局样式
<main className="bg-gray-100 min-h-screen flex justify-center items-center">
<Todo />
</main>
);
}

export default App;

🤔 思考与扩展
这个 Todo List 已经具备了核心功能,但我们还可以让它更强大。试试看:

  1. 切换完成状态:为每个 TodoItem 增加一个 completed 属性。点击任务文本时,切换其完成状态,并给已完成的任务添加一条删除线样式。
  2. 显示任务计数:在列表上方或下方,显示“总共有 X 个任务”或“还剩 Y 个未完成任务”。

第一步:修改 Todo.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* Todo.module.scss */
.todoItem {
/* ... 其他样式 ... */

.todoText { // 为文本增加一个包裹元素
cursor: pointer;
&.completed {
text-decoration: line-through;
color: #999;
}
}
}

.taskCount {
margin-top: 1rem;
color: #777;
}

第二步:升级 Todo.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
77
import { useState } from "react";
import styles from "./Todo.module.scss";

// 升级类型定义
interface TodoItem {
id: number;
text: string;
completed: boolean; // 新增 completed 状态
}

function Todo() {
const [todos, setTodos] = useState<TodoItem[]>([]);
const [input, setInput] = useState("");

const handleSubmit = () => {
if (!input.trim()) return;
const newTodo: TodoItem = {
id: Date.now(),
text: input,
completed: false, // 新任务默认为未完成
};
setTodos(todos.concat(newTodo));
setInput("");
};

const removeTodo = (id: number) => {
setTodos(todos.filter((t) => t.id !== id));
};

// 新增:切换完成状态的函数
const toggleComplete = (id: number) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};

// 派生状态:计算未完成的任务数量
const incompleteCount = todos.filter(todo => !todo.completed).length;

return (
<div className={styles.container}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="新任务..."
/>
<button onClick={handleSubmit}>提交</button>

{/* 显示任务计数 */}
<p className={styles.taskCount}>还剩 {incompleteCount} 个任务未完成</p>

<ul className={styles.todosList}>
{todos.map(({ text, id, completed }) => (
<li key={id} className={styles.todoItem}>
<span
className={`${styles.todoText} ${completed ? styles.completed : ''}`}
onClick={() => toggleComplete(id)}
>
{text}
</span>
<button
className={styles.closeButton}
onClick={() => removeTodo(id)}
>
X
</button>
</li>
))}
</ul>
</div>
);
}

export default Todo;

5.2. 阶段二:交互式 UI 构建

5.2.1. 项目实战:颜色切换器

在这个项目中,我们将学习如何以最地道、最高效的方式,利用 Tailwind CSS 内置的强大功能来构建一个可持久化的主题切换器。这将是一次深刻的范式转变,让我们告别繁琐的类名拼接,拥抱声明式的 UI 样式构建。

img

第一步:配置 Tailwind CSS 的深色模式策略

Tailwind 的 dark: 变体默认使用 prefers-color-scheme 媒体查询,跟随用户的操作系统设置。为了实现手动切换,我们需要将其配置为,在V4版本最新的配置方法变为了在css中配置,所以我们也按照他的规范来

打开项目根目录的 index.css 文件,并修改它:

文件路径: index.css

1
2
3
4
@import 'tailwindcss';

/* Tailwind CSS v4 暗黑模式配置 */
@variant dark (&:is(.dark *));

这个简单的改动告诉 Tailwind:“当 <html> 元素上有一个 dark 类时,所有带 dark: 前缀的工具类都将生效。”

第二步:构建主题切换组件

现在,我们可以编写组件了。注意看 TSX 中的 className 有多么简洁和富有表现力。

文件路径: src/components/ThemeToggler/ThemeToggler.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
import React, { useState, useEffect } from 'react';

// 定义一个类型,让代码更严谨
type Theme = 'light' | 'dark';

function ThemeToggler() {
// 最佳实践:从 localStorage 初始化 state,避免页面刷新时闪烁
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme') as Theme | null;
// 如果没有保存的主题,则根据系统偏好设置
if (savedTheme) {
return savedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});

// 关键的 Effect:同步 React state 到 DOM 和 localStorage
useEffect(() => {
const root = document.documentElement; // 获取 <html> 元素
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]); // 依赖项是 theme,每当 theme 变化时,此 effect 就会重新运行

const handleThemeToggle = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};

return (
// 现在,样式切换完全由 Tailwind 根据 <html> 上的 .dark 类自动处理
<section className="h-screen w-screen flex flex-col justify-center items-center bg-white text-[#1b1b1b] dark:bg-[#1b1b1b] dark:text-[#ffa31a] transition-colors duration-500">
<button
onClick={handleThemeToggle}
className="absolute top-5 right-5 px-4 py-2 bg-transparent cursor-pointer rounded-md border-2 border-[#1b1b1b] dark:border-[#ffa31a] transition-colors duration-500"
>
{theme === 'light' ? "切换至暗黑主题" : "切换至明亮主题"}
</button>

<div className="text-center">
<h1
className="text-6xl md:text-8xl leading-tight"
style={{ fontFamily: "'Bungee Outline', cursive" }}
>
欢迎来到 <br /> 真实世界..
</h1>
</div>
</section>
);
}

export default ThemeToggler;

第三步:App.tsxindex.html
这部分保持不变,App.tsx 负责渲染 ThemeToggler

1
2
3
4
5
6
7
8
import ThemeToggler from "./components/ThemeToggler/ThemeToggler";

function App() {
// App 组件返回 ThemeToggler,使其成为页面上唯一显示的内容。
return <ThemeToggler />;
}

export default App;

🤔 思考与扩展
我们已经实现了一个生产级的、可持久化的主题切换器。现在的代码已经非常优秀,但作为追求卓越的开发者,我们还能再优化一步吗?

  1. 提取为自定义 Hook: 目前,主题管理的逻辑(useStateuseEffectlocalStorage)都耦合在 ThemeToggler 组件内部。我们能否将这整套逻辑提取到一个可复用的 useTheme 自定义 Hook 中,让 ThemeToggler 组件只负责 UI 呈现?

是的,这正是自定义 Hook 的完美应用场景!

第一步:创建 useTheme 自定义 Hook

文件路径: src/hooks/useTheme.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
import { useState, useEffect } from 'react'

// 定义一个类型,让代码更严谨
type Theme = 'light' | 'dark'

export function useTheme() {
// 最佳实践:从 localStorage 初始化 state,避免页面刷新时闪烁
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme') as Theme | null
// 如果没有保存的主题,则根据系统偏好设置
if (savedTheme) {
return savedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
})

// 关键的 Effect:同步 React state 到 DOM 和 localStorage
useEffect(() => {
const root = document.documentElement // 获取 <html> 元素
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
localStorage.setItem('theme', theme)
}, [theme]) // 依赖项是 theme,每当 theme 变化时,此 effect 就会重新运行

const handleThemeToggle = () => {
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'))
}

return { theme, handleThemeToggle }
}

第二步:在 ThemeToggler.tsx 中使用自定义 Hook

1
2
3
4
5
6
7
8
9
10
import React from 'react';
import { useTheme } from '../hooks/useTheme'; // 导入自定义 Hook

function ThemeToggler() {
// 组件的逻辑被极大地简化了!它现在只关心“有什么”和“做什么”。
// 组件的逻辑被极大地简化了!它现在只关心“有什么”和“做什么”。
const { theme, handleThemeToggle } = useTheme()
}

export default ThemeToggler;

通过自定义 Hook,我们实现了逻辑与视图的终极分离useTheme Hook 现在是一个完全独立的、可移植的、可在任何组件中使用的“主题管理引擎”。


5.2.2. 项目实战:隐藏式搜索框

在掌握了如何通过状态切换整个页面主题后,我们现在将注意力集中到一个更具体的交互上:如何通过点击一个图标,平滑地、动态地展示一个输入框。这个项目是练习 React 状态与 CSS 过渡动画 相结合的绝佳机会。

img

项目目标
我们将构建一个初始状态只显示一个搜索图标的界面。当用户点击该图标时,图标消失,一个输入框以平滑的过渡效果出现,同时背景变为深色。点击输入框以外的区域,则恢复初始状态。

核心概念巩固

  • useState: 管理 UI 的可见性状态(显示图标还是输入框)。
  • 条件渲染: 使用三元运算符在 JSX 中根据状态渲染不同的元素。
  • 事件处理: onClick 事件的精确使用,包括事件冒泡的处理。
  • Tailwind CSS: 熟练运用其过渡 (transition)、透明度 (opacity) 和宽度 (width) 等工具类,以纯 CSS 的方式实现动画效果。

项目结构与代码解析
我们将继续使用 Tailwind CSS,保持组件的内聚性。

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

实战准备:安装图标库
为了使用搜索图标,我们需要一个图标库。react-icons 是一个非常流行且易于使用的选择。

1
pnpm add react-icons

1. HiddenSearchBar.tsx (核心组件)
我们将创建这个组件,完全利用 Tailwind CSS 的能力。

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
import { useState, useRef, useEffect } from "react";
import { FaSearch } from "react-icons/fa"; // 从 react-icons 库导入图标

function HiddenSearchBar() {
// 最佳实践:使用单一、清晰的状态来控制 UI 模式
const [isActive, setIsActive] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

// 当搜索框被激活时,自动聚焦到输入框
useEffect(() => {
if (isActive && inputRef.current) {
inputRef.current.focus();
}
}, [isActive]);

return (
// 使用 Tailwind 来处理背景色和过渡效果
// `group` 类是关键,它允许子元素根据父元素的状态改变样式
<div
className={`
group relative h-screen w-screen flex justify-center items-center
transition-colors duration-500
${isActive ? 'bg-gray-900' : 'bg-white'}
`}
>
{/* 搜索容器 */}
<div className="relative flex items-center">
{/* 输入框:使用 Tailwind 的 transition 和 apha/width 控制动画 */}
<input
ref={inputRef}
type="text"
placeholder="搜索..."
className={`
h-12 pl-5 pr-12 rounded-full outline-none text-lg bg-white
transition-all duration-700 ease-in-out
${isActive ? 'w-80 shadow-lg' : 'w-0 border-transparent'}
`}
/>
{/* 搜索图标:使用 Tailwind 的 absolute 定位 */}
<FaSearch
className={`
absolute right-4 text-gray-500 cursor-pointer
transition-transform duration-300
${isActive && 'hover:scale-125'}
`}
size={20}
onClick={() => setIsActive(!isActive)} // 点击图标切换状态
/>
</div>
</div>
);
}

export default HiddenSearchBar;

代码重构与最佳实践

  1. 单一状态来源: 原始代码使用了两个 useState (showInput, bgColor) 来管理本应由一个状态控制的 UI。我们将其重构为单一的 isActive 状态,使逻辑更清晰。
  2. CSS > JS: 原始代码通过 style 属性和 JS 来控制背景色,我们将其完全交给 Tailwind 的 dark: 变体(或在本例中是条件类名),让 CSS 负责样式,JS 负责状态,实现关注点分离。
  3. 动画实现: 我们放弃了原始 CSS 文件中的过渡,完全使用 Tailwind 的 transition, duration, ease-in-out, opacity, 和 width 工具类,以纯声明式的方式在 JSX 中实现了更复杂的动画,无需离开组件文件。
  4. 自动聚焦: 通过 useRefuseEffect,我们实现了在搜索框出现时自动聚焦的交互优化,提升了用户体验。

2. App.tsx (应用入口)

1
2
3
4
5
6
7
import HiddenSearchBar from "./components/HiddenSearchBar/HiddenSearchBar";

function App() {
return <HiddenSearchBar />;
}

export default App;

🤔 思考与扩展
这个组件已经非常酷了,但还有一个交互细节可以完善:

  1. 点击外部关闭: 目前,一旦搜索框被激活,只能通过再次点击图标来关闭。更符合用户直觉的行为是:点击搜索框以外的任何区域,都应该能关闭它。你能否实现这个功能?

是的,我们可以通过在根 div 上添加一个 onClick 事件处理器,并巧妙地利用事件冒泡来解决这个问题。

升级 HiddenSearchBar.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
import { useState, useRef, useEffect } from "react";
import { FaSearch } from "react-icons/fa";

function HiddenSearchBar() {
const [isActive, setIsActive] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null); // Ref for the main container

useEffect(() => {
if (isActive && inputRef.current) {
inputRef.current.focus();
}
}, [isActive]);

// 新增:处理点击外部关闭的逻辑
const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => {
// 只有当点击事件的目标是外部容器本身时,才关闭搜索框
if (e.target === containerRef.current) {
setIsActive(false);
}
};

return (
<div
ref={containerRef} // 附加 Ref
onClick={handleContainerClick} // 添加事件处理器
className={`
relative h-screen w-screen flex justify-center items-center
transition-colors duration-500
${isActive ? 'bg-gray-900' : 'bg-white'}
`}
>
<div className="relative flex items-center">
<input
ref={inputRef}
type="text"
placeholder="搜索..."
className={`
h-12 pl-5 pr-12 rounded-full outline-none text-lg bg-white
transition-all duration-700 ease-in-out
${isActive ? 'w-80 shadow-lg' : 'w-0 border-transparent'}
`}
/>
<FaSearch
className={`
absolute right-4 text-gray-500 cursor-pointer
transition-transform duration-300
${isActive ? 'hover:scale-125' : ''}
`}
size={20}
onClick={() => setIsActive(!isActive)}
/>
</div>
</div>
);
}

export default HiddenSearchBar;

现在,当用户点击灰色背景区域时,e.target 就是那个 div 本身,isActive 会被设为 false。而当用户点击输入框或图标时,e.targetinputsvg 元素,条件不满足,状态不会改变。这就完美地实现了我们的目标。


5.3. 阶段三:异步数据流与 API 交互

5.3.1. 项目实战:餐饮 API 项目 (Meals API Project)

欢迎来到我们实战之旅的全新阶段!到目前为止,我们构建的应用都只在“自己的世界里”运行,处理着我们预设的数据。现在,我们将打破这层壁垒,通过发起真实的 API 请求,让我们的 React 应用首次与广阔的互联网世界对话,获取并展示动态数据。

img

项目目标

我们将构建一个从 TheMealDB API 获取海鲜菜品数据,并以精美的卡片网格形式展示的页面。

核心概念巩固

  • useEffect: 用于在组件首次渲染后执行数据获取这一“副作用”。
  • useState: 精准管理异步流程中的三种关键状态:loading(加载状态)、error(错误状态)和 data(成功获取的数据)。
  • 异步操作: 引入并使用 axios 库,以 async/await 的现代语法发起网络请求。
  • 条件渲染: 根据 loadingerror 状态,为用户提供清晰的界面反馈。
  • TypeScript 接口: 为 API 返回的数据定义类型,让我们的代码更健壮、更易于维护。
  • Tailwind CSS: 完全使用原子化类名来构建一个响应式的卡片网格布局。

项目结构与代码解析
我们将继续采用简洁、内聚的组件结构。

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

实战准备:安装 Axios
为了更便捷地处理网络请求,我们将安装广受欢迎的 axios 库。

1
pnpm add axios

1. Meals.tsx (核心组件)
在这个组件中,我们将完成从数据请求、状态管理到最终 UI 渲染的完整流程。

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
77
78
79
80
81
82
83
import { useState, useEffect } from 'react'
import axios from 'axios'

// 最佳实践:为从 API 获取的数据定义 TypeScript 接口
// 这能提供强大的类型检查和编辑器自动补全,极大提升代码质量
interface Meal {
idMeal: string
strMeal: string
strMealThumb: string
}

function Meals() {
// 状态1: 存储从 API 获取的菜品列表,并指定其类型
const [meals, setMeals] = useState<Meal[]>([])

// 状态2: 管理加载状态,为用户提供清晰的反馈
const [loading, setLoading] = useState<boolean>(true)

// 状态3: 管理可能发生的错误
const [error, setError] = useState<Error | null>(null)

// useEffect 用于处理组件挂载后的数据获取副作用
useEffect(() => {
// 在 Effect 内部定义一个异步函数来获取数据
const fetchMeals = async () => {
try {
setLoading(true)
const response = await axios.get(
'https://www.themealdb.com/api/json/v1/1/filter.php?c=Seafood'
)
// 更新菜品数据状态
setMeals(response.data.meals)
} catch (err) {
// 如果发生错误,更新错误状态
setError(err as Error)
} finally {
// 无论请求成功或失败,最后都将加载状态设置为 false
setLoading(false)
}
}

fetchMeals()
}, []) // 空依赖数组 `[]` 确保此 effect 仅在组件首次挂载时运行一次

// 根据加载状态进行条件渲染
if (loading) {
return <p className="text-white text-2xl animate-pulse">正在加载菜品...</p>
}

// 根据错误状态进行条件渲染
if (error) {
return <p className="text-red-500 text-2xl">加载失败: {error.message}</p>
}

return (
// 使用 Tailwind CSS Grid 布局来创建响应式网格
// sm:小屏幕下,2列,lg:中屏幕下,3列,xl:大屏幕下,4列
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-4">
{meals.map(({ idMeal, strMeal, strMealThumb }) => (
<section
key={idMeal}
className="bg-white rounded-lg shadow-lg overflow-hidden transform transition-transform duration-300 hover:scale-105 cursor-pointer"
>
<img
src={strMealThumb}
alt={strMeal}
className="w-full h-48 object-cover"
/>
<div className="p-4">
<p className="font-bold text-gray-800 truncate">
{strMeal}
</p>
<p className="text-sm text-gray-500 mt-1">
#{idMeal}
</p>
</div>
</section>
))}
</div>
)
}

export default Meals

2. App.tsx (应用入口)
我们的 App 组件负责设置页面的整体布局和标题,并渲染 Meals 组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import Meals from "./components/Meals/Meals";

function App() {
return (
<main className="min-h-screen">
<div className="container mx-auto py-8">
<h1 className="text-4xl font-bold text-center text-black mb-8 tracking-wider">
海鲜菜单
</h1>
<Meals />
</div>
</main>
);
}

export default App;

🤔 思考与扩展
我们已经成功地从一个真实的 API 获取数据并展示出来,这是构建现代 Web 应用的关键一步。现在,我们可以思考如何让这个页面变得更强大:

  1. 添加搜索功能: 增加一个输入框,允许用户输入菜品名称进行搜索。当用户点击搜索按钮时,向 https://www.themealdb.com/api/json/v1/1/search.php?s=YOUR_SEARCH_QUERY 发起新的 API 请求,并更新列表。
  2. 提取自定义 Hook: 我们刚刚在 Meals.tsx 中编写的获取数据的逻辑(包含 loading, error, data 三个状态和 useEffect),是不是非常通用?它完全可以被封装成一个可复用的 useFetch 自定义 Hook!尝试将这部分逻辑提取出来,让 Meals 组件变得更纯粹。

第一步:创建 useFetch 自定义 Hook

文件路径: src/hooks/useFetch.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
import { useState, useEffect } from 'react';
import axios from 'axios';

// 我们可以让 Hook 变得更通用,通过泛型来接收任意数据类型
export function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
// 如果 url 为空,则不发起请求
if (!url) {
setLoading(false);
return;
}

const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await axios.get(url);
setData(response.data.meals || response.data); // 适配 meals API 的数据结构
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};

fetchData();
}, [url]); // 依赖项是 url,每当 url 变化时,Hook 会自动重新获取数据

return { data, loading, error };
}

第二步:在 Meals.tsx 中使用自定义 Hook 并添加搜索功能

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";
import { useFetch } from "../hooks/useFetch"; // 导入我们的自定义 Hook

interface Meal {
idMeal: string;
strMeal: string;
strMealThumb: string;
}

function Meals() {
const [searchTerm, setSearchTerm] = useState<string>("Seafood");
const [query, setQuery] = useState<string>("Seafood");

// 根据 searchTerm 构建 API URL
const apiUrl = searchTerm === "Seafood"
? `https://www.themealdb.com/api/json/v1/1/filter.php?c=Seafood`
: `https://www.themealdb.com/api/json/v1/1/search.php?s=${searchTerm}`;

// 一行代码,就完成了所有数据获取和状态管理的逻辑!
const { data: meals, loading, error } = useFetch<Meal[]>(apiUrl);

const handleSearch = () => {
setSearchTerm(query);
};

return (
<>
{/* 搜索栏 */}
<div className="flex justify-center mb-8">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索菜品..."
className="p-2 rounded-l-md w-1/3 text-black"
/>
<button onClick={handleSearch} className="bg-blue-500 text-white p-2 rounded-r-md">
搜索
</button>
</div>

{loading && <p className="text-white text-2xl animate-pulse">正在加载菜品...</p>}
{error && <p className="text-red-500 text-2xl">加载失败: {error.message}</p>}

{/* 当 meals 为 null 或空数组时显示提示 */}
{!loading && !error && (!meals || meals.length === 0) && (
<p className="text-yellow-400 text-2xl text-center">没有找到相关菜品。</p>
)}

<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-4">
{meals && meals.map(({ idMeal, strMeal, strMealThumb }) => (
<section key={idMeal} className="...">
{/* ... 卡片 JSX 保持不变 ... */}
</section>
))}
</div>
</>
);
}

export default Meals;

通过自定义 Hook,我们不仅让 Meals 组件的逻辑变得极其简洁,还创造了一个可以在应用中任何地方复用的数据获取“引擎”,这正是 React 组合能力的魅力所在。