第五章: 实战演练:从零构建 React 应用
摘要: 理论是基石,但只有通过亲手构建,知识才能真正内化为能力。在本章中,我们将告别孤立的知识点,进入一系列由简到繁的实战项目。我们的目标不是简单地“复刻”功能,而是通过每一个项目,有针对性地巩固、融合并深化前四章所学的核心概念——从 State 管理到副作用处理,再到自定义 Hooks 的应用。学完本章,您将具备独立构建功能完备的 React 组件和小型应用的能力。
在本章中,我们将遵循一条精心设计的技能升级路径,逐步解锁更复杂的应用场景:
- 阶段一:状态管理基石: 我们将从最核心的
useState
开始,通过构建 计数器 和 待办事项列表,彻底掌握对数字、数组等基础数据结构的状态管理。 - 阶段二:交互式 UI 构建: 接着,我们将挑战 颜色切换器、隐藏式搜索框 等项目,专注于 UI 状态的管理,创造更丰富的用户交互。
- 阶段三:异步数据流与 API 交互: 然后,我们将通过 餐饮 API 项目,首次引入
useEffect
处理网络请求,打通 React 应用与服务器的数据链路。
请相信我,每一节我都安排来不同的知识点,完全遵循最佳实践与之前学习过的所有知识点
5.1. 阶段一:状态管理基石
5.1.1. 项目实战:计数器 (Counter)
这是我们 React 实战之旅的第一站。计数器虽小,却蕴含了 React 数据驱动视图的核心思想。我们将通过它,将 useState
和事件处理的理论知识,转化为指尖上的代码。同时,我们将引入并实践 SCSS Modules,这是一种能将 SCSS 的强大功能与组件化样式隔离完美结合的最佳实践。

实战准备:为项目添加 SCSS Modules 支持
在 1.6
节,我们已经为项目配置了 Tailwind CSS。现在,我们将学习另一种强大的样式方案:SCSS Modules。它允许我们在组件层面编写 SCSS,并自动确保样式不会泄露到其他组件,完美对标 Vue 的 <style scoped>
。
第一步:安装 SCSS 编译器
如果尚未安装,请确保您的项目已添加 sass
依赖。
第二步:理解 SCSS Modules 的工作方式
Vite 已为我们内置了 CSS Modules 的支持。我们只需遵循一个简单的命名约定:将样式文件命名为 [ComponentName].module.scss
。
当你这样做时:
- Vite 会将这个 SCSS 文件中的所有类名进行哈希处理,生成一个独一无二的类名(例如
.title
变成 .Counter_title__aB3xY
)。 - 当你
import
这个文件时,它会返回一个 JavaScript 对象,键是你原始的类名,值是哈希后的唯一类名。
这种机制从根本上解决了 CSS 全局污染的问题。
项目目标
我们将构建一个简单的计数器应用,包含一个显示的数字、一个“增加”按钮和一个“减少”按钮。
useState
: 用于管理计数器的数字状态。- 事件处理:
onClick
事件绑定与处理函数的编写。 - SCSS Modules: 实现组件级别的样式封装。
项目结构与代码解析
我们将采用“组件文件夹”的最佳实践来组织代码,将与 Counter
组件相关的所有文件都放在同一个地方。
1 2 3 4 5 6
| ├── 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
|
.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 { 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";
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 ( <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 ( <main className="bg-black min-h-screen flex flex-col justify-center items-center"> <Counter /> </main> ); };
export default App;
|
🤔 思考与扩展
现在你已经完成了一个使用 SCSS Modules 的计数器,尝试挑战一下自己:
- 添加重置功能: 增加一个“重置”按钮,点击后让计数器归零。
- 设置边界: 修改
decrement
函数,使得计数器的值不能小于 0。 - 动态样式: 当
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; }
&.negative { color: #ef4444; } } }
|
修改代码内容为:
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'
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 ( <div className={styles.container}> <h1 className={getNumberDisplayClass()}>{count}</h1> </div> ) }
export default Counter
|
5.1.2. 项目实战:待办事项列表 (Todo List)
如果说“计数器”是 useState
的入门,那么“待办事项列表”就是我们掌握数组状态管理的第一次大考。在这个项目中,我们将学会如何以 React 的方式(不可变地)对一个列表进行增加和删除操作,这是构建动态应用的核心技能。

项目目标
我们将构建一个经典的 Todo List 应用。用户可以:
- 在输入框中输入任务。
- 点击“提交”按钮,将新任务添加到列表中。
- 点击每项任务旁的“X”按钮,从列表中删除该任务。
核心概念巩固
useState
: 管理输入框的字符串状态,以及待办事项的数组状态。- 数组的不可变更新: 使用
concat
和 filter
等方法来更新数组,而非直接修改。 - 列表渲染: 使用
.map()
方法动态渲染列表,并为每一项提供唯一的 key
。 - 受控组件: 将 input 输入框的
value
与 React state 绑定。
项目结构与代码解析
我们将继续遵循“组件文件夹”的最佳实践。
1 2 3 4 5 6 7 8
| ├── 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; }
.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'
interface TodoItem { id: number text: string }
function Todo() { const [todos, setTodos] = useState<TodoItem[]>([]) const [input, setInput] = useState('')
const handleAddTodo = () => { if (!input.trim()) return
const newTodo: TodoItem = { text: input, id: Date.now(), }
setTodos(todos.concat(newTodo))
setInput('') }
const removeTodo = (id: number) => { 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 ( <main className="bg-gray-100 min-h-screen flex justify-center items-center"> <Todo /> </main> ); }
export default App;
|
🤔 思考与扩展
这个 Todo List 已经具备了核心功能,但我们还可以让它更强大。试试看:
- 切换完成状态:为每个
TodoItem
增加一个 completed
属性。点击任务文本时,切换其完成状态,并给已完成的任务添加一条删除线样式。 - 显示任务计数:在列表上方或下方,显示“总共有 X 个任务”或“还剩 Y 个未完成任务”。
第一步:修改 Todo.module.scss
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| .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; }
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 样式构建。

第一步:配置 Tailwind CSS 的深色模式策略
Tailwind 的 dark:
变体默认使用 prefers-color-scheme
媒体查询,跟随用户的操作系统设置。为了实现手动切换,我们需要将其配置为,在V4版本最新的配置方法变为了在css中配置,所以我们也按照他的规范来
打开项目根目录的 index.css
文件,并修改它:
文件路径: index.css
1 2 3 4
| @import 'tailwindcss';
@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() { 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'; });
useEffect(() => { const root = document.documentElement; if (theme === 'dark') { root.classList.add('dark'); } else { root.classList.remove('dark'); } localStorage.setItem('theme', theme); }, [theme]);
const handleThemeToggle = () => { setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light')); };
return ( <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.tsx
和 index.html
这部分保持不变,App.tsx
负责渲染 ThemeToggler
1 2 3 4 5 6 7 8
| import ThemeToggler from "./components/ThemeToggler/ThemeToggler";
function App() { return <ThemeToggler />; }
export default App;
|
🤔 思考与扩展
我们已经实现了一个生产级的、可持久化的主题切换器。现在的代码已经非常优秀,但作为追求卓越的开发者,我们还能再优化一步吗?
- 提取为自定义 Hook: 目前,主题管理的逻辑(
useState
、useEffect
、localStorage
)都耦合在 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() { 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' })
useEffect(() => { const root = document.documentElement if (theme === 'dark') { root.classList.add('dark') } else { root.classList.remove('dark') } localStorage.setItem('theme', theme) }, [theme])
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';
function ThemeToggler() { const { theme, handleThemeToggle } = useTheme() }
export default ThemeToggler;
|
通过自定义 Hook,我们实现了逻辑与视图的终极分离。useTheme
Hook 现在是一个完全独立的、可移植的、可在任何组件中使用的“主题管理引擎”。
5.2.2. 项目实战:隐藏式搜索框
在掌握了如何通过状态切换整个页面主题后,我们现在将注意力集中到一个更具体的交互上:如何通过点击一个图标,平滑地、动态地展示一个输入框。这个项目是练习 React 状态与 CSS 过渡动画 相结合的绝佳机会。

项目目标
我们将构建一个初始状态只显示一个搜索图标的界面。当用户点击该图标时,图标消失,一个输入框以平滑的过渡效果出现,同时背景变为深色。点击输入框以外的区域,则恢复初始状态。
核心概念巩固
useState
: 管理 UI 的可见性状态(显示图标还是输入框)。- 条件渲染: 使用三元运算符在 JSX 中根据状态渲染不同的元素。
- 事件处理:
onClick
事件的精确使用,包括事件冒泡的处理。 - Tailwind CSS: 熟练运用其过渡 (
transition
)、透明度 (opacity
) 和宽度 (width
) 等工具类,以纯 CSS 的方式实现动画效果。
项目结构与代码解析
我们将继续使用 Tailwind CSS,保持组件的内聚性。
1 2 3 4 5
| ├── componebghnts/ │ └── HiddenSearchBar/ │ └── HiddenSearchBar.tsx └── App.tsx
|
实战准备:安装图标库
为了使用搜索图标,我们需要一个图标库。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";
function HiddenSearchBar() { const [isActive, setIsActive] = useState(false); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (isActive && inputRef.current) { inputRef.current.focus(); } }, [isActive]);
return ( <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;
|
代码重构与最佳实践:
- 单一状态来源: 原始代码使用了两个
useState
(showInput
, bgColor
) 来管理本应由一个状态控制的 UI。我们将其重构为单一的 isActive
状态,使逻辑更清晰。 - CSS > JS: 原始代码通过
style
属性和 JS 来控制背景色,我们将其完全交给 Tailwind 的 dark:
变体(或在本例中是条件类名),让 CSS 负责样式,JS 负责状态,实现关注点分离。 - 动画实现: 我们放弃了原始 CSS 文件中的过渡,完全使用 Tailwind 的
transition
, duration
, ease-in-out
, opacity
, 和 width
工具类,以纯声明式的方式在 JSX 中实现了更复杂的动画,无需离开组件文件。 - 自动聚焦: 通过
useRef
和 useEffect
,我们实现了在搜索框出现时自动聚焦的交互优化,提升了用户体验。
2. App.tsx
(应用入口)
1 2 3 4 5 6 7
| import HiddenSearchBar from "./components/HiddenSearchBar/HiddenSearchBar";
function App() { return <HiddenSearchBar />; }
export default App;
|
🤔 思考与扩展
这个组件已经非常酷了,但还有一个交互细节可以完善:
- 点击外部关闭: 目前,一旦搜索框被激活,只能通过再次点击图标来关闭。更符合用户直觉的行为是:点击搜索框以外的任何区域,都应该能关闭它。你能否实现这个功能?
是的,我们可以通过在根 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);
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.target
是 input
或 svg
元素,条件不满足,状态不会改变。这就完美地实现了我们的目标。
5.3. 阶段三:异步数据流与 API 交互
5.3.1. 项目实战:餐饮 API 项目 (Meals API Project)
欢迎来到我们实战之旅的全新阶段!到目前为止,我们构建的应用都只在“自己的世界里”运行,处理着我们预设的数据。现在,我们将打破这层壁垒,通过发起真实的 API 请求,让我们的 React 应用首次与广阔的互联网世界对话,获取并展示动态数据。

项目目标
我们将构建一个从 TheMealDB API 获取海鲜菜品数据,并以精美的卡片网格形式展示的页面。
核心概念巩固
useEffect
: 用于在组件首次渲染后执行数据获取这一“副作用”。useState
: 精准管理异步流程中的三种关键状态:loading
(加载状态)、error
(错误状态)和 data
(成功获取的数据)。- 异步操作: 引入并使用
axios
库,以 async/await
的现代语法发起网络请求。 - 条件渲染: 根据
loading
和 error
状态,为用户提供清晰的界面反馈。 - TypeScript 接口: 为 API 返回的数据定义类型,让我们的代码更健壮、更易于维护。
- Tailwind CSS: 完全使用原子化类名来构建一个响应式的卡片网格布局。
项目结构与代码解析
我们将继续采用简洁、内聚的组件结构。
1 2 3 4 5
| ├── components/ │ └── Meals/ │ └── Meals.tsx └── App.tsx
|
实战准备:安装 Axios
为了更便捷地处理网络请求,我们将安装广受欢迎的 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'
interface Meal { idMeal: string strMeal: string strMealThumb: string }
function Meals() { const [meals, setMeals] = useState<Meal[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => { 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 { setLoading(false) } }
fetchMeals() }, [])
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 ( <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 应用的关键一步。现在,我们可以思考如何让这个页面变得更强大:
- 添加搜索功能: 增加一个输入框,允许用户输入菜品名称进行搜索。当用户点击搜索按钮时,向
https://www.themealdb.com/api/json/v1/1/search.php?s=YOUR_SEARCH_QUERY
发起新的 API 请求,并更新列表。 - 提取自定义 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';
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(() => { 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); } catch (err) { setError(err as Error); } finally { setLoading(false); } };
fetchData(); }, [url]);
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";
interface Meal { idMeal: string; strMeal: string; strMealThumb: string; }
function Meals() { const [searchTerm, setSearchTerm] = useState<string>("Seafood"); const [query, setQuery] = useState<string>("Seafood");
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 组合能力的魅力所在。