React 实战项目
学了前面 12 篇语法,是时候把它们串起来做点真东西了。这一章给三个完整、可运行的项目——Todo App、天气查询、useReducer + Context 购物车,全部用 Function Component + Hooks 写,覆盖了 React 实际开发最常碰到的三类需求:本地状态管理 + 持久化、异步数据获取、全局状态。建议你把代码复制到本地 Vite 项目里亲手跑一遍,再改一改需求,这才是真正"上手"的感觉。
三个项目的依赖只有 react 和 react-dom——任何 Vite + React 项目模板都自带。每个组件都贴了完整的 import 和 export default,复制到 src/App.jsx 替换掉默认内容即可运行。
实战 1:Todo App(useState + useEffect + 组件拆分)
需求:做一个待办清单,支持增、删、改、勾选完成,顶部三个按钮过滤"全部 / 未完成 / 已完成",刷新页面数据不丢(localStorage 持久化),并把不同部分拆成子组件。
涉及知识:useState(增删改查、惰性初始化)、useEffect(自动写回 localStorage)、不可变更新、组件拆分与 props 回调、条件渲染与列表渲染。
// app.jsx — Todo App:增删改查 + 过滤 + localStorage 持久化 + 组件拆分
import { useState, useEffect } from "react";
// ---- 子组件 1:单条 todo(本地状态:是否在编辑)----
function TodoItem({ todo, onToggle, onEdit, onDelete }) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(todo.text);
const save = () => {
onEdit(todo.id, text.trim()); // 把编辑结果交给父组件
setEditing(false);
};
return (
<li>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
{editing ? (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={save}>保存</button>
<button onClick={() => setEditing(false)}>取消</button>
</>
) : (
<>
<span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
{todo.text}
</span>
<button onClick={() => setEditing(true)}>编辑</button>
<button onClick={() => onDelete(todo.id)}>删除</button>
</>
)}
</li>
);
}
// ---- 子组件 2:过滤按钮组 ----
function Filters({ filter, setFilter }) {
const items = [
{ key: "all", label: "全部" },
{ key: "active", label: "未完成" },
{ key: "done", label: "已完成" },
];
return (
<div>
{items.map((it) => (
<button
key={it.key}
onClick={() => setFilter(it.key)}
style={{ fontWeight: filter === it.key ? "bold" : "normal" }}
>
{it.label}
</button>
))}
</div>
);
}
// ---- 根组件:管理所有 todo 数据,把回调通过 props 传下去 ----
export default function TodoApp() {
// 惰性初始化:首次渲染从 localStorage 读
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem("todos");
return saved ? JSON.parse(saved) : [];
});
const [text, setText] = useState("");
const [filter, setFilter] = useState("all");
// 副作用:todos 一变就写回 localStorage(持久化)
useEffect(() => {
localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]);
// 增删改查:全部用"不可变更新"——返回新数组,而不是 push/splice
const add = () => {
const v = text.trim();
if (!v) return;
setTodos([...todos, { id: Date.now(), text: v, done: false }]);
setText("");
};
const toggle = (id) =>
setTodos(todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
const edit = (id, newText) =>
setTodos(todos.map((t) => (t.id === id ? { ...t, text: newText } : t)));
const remove = (id) => setTodos(todos.filter((t) => t.id !== id));
// 过滤:根据 filter 决定显示哪些
const visible = todos.filter((t) =>
filter === "all" ? true : filter === "active" ? !t.done : t.done
);
const remaining = todos.filter((t) => !t.done).length;
return (
<div>
<h2>Todo List</h2>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="输入待办..."
/>
<button onClick={add}>添加</button>
<Filters filter={filter} setFilter={setFilter} />
<ul>
{visible.map((t) => (
<TodoItem
key={t.id}
todo={t}
onToggle={toggle}
onEdit={edit}
onDelete={remove}
/>
))}
</ul>
<p>共 {todos.length} 条,未完成 {remaining} 条</p>
</div>
);
}要点讲解:
- 组件拆分:把"
TodoItem(单条 + 编辑态)"和"Filters(过滤按钮组)"独立成子组件,根组件只管数据和回调。这是 React 写大应用的基本功——状态下沉,视图拆分。 - 状态在哪,改状态的函数就在哪:根组件持有
todos,所以add / toggle / edit / remove都定义在根组件,通过props传给子组件调用。子组件只负责"告诉父组件发生了什么",不直接改数据。 - 不可变更新:每次 setState 都返回新数组 / 新对象(
[...todos, x]、todos.map(...)、{ ...t, done: !t.done }),不要push/splice。React 是靠"引用是否变了"判断要不要重渲染的。 useState(() => init)惰性初始化:首次渲染时跑一次,从 localStorage 读取已存数据。直接传值useState(readFromStorage())会在每次渲染都调一次,浪费——传函数则只在首次执行。useEffect持久化:依赖数组写[todos],意思是"todos 一变就自动把最新值写回 localStorage"。这样数据流是单向的:state 是唯一数据源,localStorage 只是它的镜像,逻辑非常清晰。TodoItem的本地状态:是否在编辑(editing)、输入框临时值(text),只有这一条 todo 关心,放到子组件内部就够了,不必挤到根组件。- 过滤逻辑:不修改原数组,而是派生一个
visible——这是 React 推崇的"把状态降到最少,其余从已有状态算出来"思路。
实战 2:天气查询(useEffect + fetch + async/await)
需求:输入城市名,点查询显示当前温度、天气描述和风速。要求正确处理加载中、查询失败 / 找不到城市两种状态,而不是请求没回来时显示一个空白页面。
涉及知识:useEffect(依赖变化触发请求)、fetch + async/await、try/catch 错误处理、四态状态机(idle / loading / error / ok)、清理函数避免内存泄漏。
接口用 Open-Meteo(open-meteo.com),免费、无需注册、无 API Key、CORS 友好,浏览器直接 fetch 即可。流程是两步:先用 Geocoding API 把城市名换成经纬度,再用 Forecast API 取实时天气。
// weather.jsx — 天气查询:useEffect + fetch + async/await + 三态(loading/error/ok)
// 用 Open-Meteo(免 key、免注册、CORS 友好):https://open-meteo.com
import { useState, useEffect } from "react";
const GEO_URL = "https://geocoding-api.open-meteo.com/v1/search";
const WX_URL = "https://api.open-meteo.com/v1/forecast";
// WMO weather_code → 中文描述(节选,完整表见官网)
const CODE_TEXT = {
0: "晴", 1: "晴", 2: "多云", 3: "阴",
45: "雾", 48: "雾凇",
51: "毛毛雨", 53: "毛毛雨", 55: "毛毛雨",
61: "小雨", 63: "中雨", 65: "大雨",
71: "小雪", 73: "中雪", 75: "大雪",
80: "阵雨", 81: "阵雨", 82: "暴雨",
95: "雷暴", 96: "雷暴", 99: "雷暴",
};
export default function Weather() {
const [city, setCity] = useState("北京"); // 输入框的值
const [query, setQuery] = useState("北京"); // 真正触发查询的关键字
const [data, setData] = useState(null);
const [status, setStatus] = useState("idle"); // idle | loading | error | ok
const [error, setError] = useState("");
// query 一变就自动发请求
useEffect(() => {
if (!query) return;
let cancelled = false; // 防止组件卸载后还 setState
const run = async () => {
setStatus("loading");
setError("");
try {
// 第 1 步:城市名 → 经纬度
const geoResp = await fetch(
`${GEO_URL}?name=${encodeURIComponent(query)}&count=1&language=zh`
);
if (!geoResp.ok) throw new Error("地理查询失败");
const geo = await geoResp.json();
if (!geo.results || geo.results.length === 0) {
throw new Error("找不到这个城市");
}
const { latitude, longitude, name, country } = geo.results[0];
// 第 2 步:经纬度 → 实时天气
const wxResp = await fetch(
`${WX_URL}?latitude=${latitude}&longitude=${longitude}` +
`¤t=temperature_2m,weather_code,wind_speed_10m`
);
if (!wxResp.ok) throw new Error("天气查询失败");
const wx = await wxResp.json();
if (cancelled) return;
setData({
place: `${name}, ${country}`,
temp: wx.current.temperature_2m,
code: wx.current.weather_code,
wind: wx.current.wind_speed_10m,
});
setStatus("ok");
} catch (e) {
if (cancelled) return;
setError(e.message);
setStatus("error");
}
};
run();
// 清理函数:组件卸载或下一次 effect 运行前调用
return () => { cancelled = true; };
}, [query]);
return (
<div>
<h2>天气查询</h2>
<form
onSubmit={(e) => {
e.preventDefault();
setQuery(city.trim());
}}
>
<input value={city} onChange={(e) => setCity(e.target.value)} />
<button type="submit">查询</button>
</form>
{status === "loading" && <p>加载中...</p>}
{status === "error" && (
<p style={{ color: "red" }}>错误:{error}</p>
)}
{status === "ok" && data && (
<div>
<h3>{data.place}</h3>
<p>温度:{data.temp} °C</p>
<p>天气:{CODE_TEXT[data.code] ?? "未知"}</p>
<p>风速:{data.wind} km/h</p>
</div>
)}
</div>
);
}要点讲解:
- 四态状态机:用一个
status字符串(idle / loading / error / ok)管理整个请求生命周期,渲染时根据它显示"加载中 / 错误 / 数据"。比每个状态都开一个useState更不容易漏掉分支。 - 两个关键字:输入框 vs 查询关键字:
city是输入框里的临时值(每次按键都变),query是用户点了查询按钮后才更新的值。这样输入到一半不会触发请求——只有提交或回车才触发。 useEffect的依赖:写成[query],意思是"query一变就重新发请求"。首次挂载时query有初值也会自动跑一次,所以页面打开就有"北京"的天气。async/await:比.then()链好读得多,流程看起来像同步代码。但整个 async 函数必须包在try/catch里,否则一个 reject 就成未捕获异常。- 清理函数防止内存泄漏:组件卸载或
query再次变化时,旧请求可能还没回来。用let cancelled = false+return () => { cancelled = true },在 setState 前判断一下——避免对已卸载组件 setState 报错。 - 错误边界:网络请求的失败原因很多(网络断、城市名拼错、服务器挂),分别抛不同 message,统一在
catch里取e.message显示给用户,比一句"出错了"友好。 - WMO weather_code:Open-Meteo 用世界气象组织的标准代码(
0晴、63中雨、95雷暴…),用一个对象CODE_TEXT做映射,展示时再?? "未知"兜底。
实战 3:useReducer + Context(计数器 + 购物车全局状态)
需求:页面有两个互相独立的模块——计数器和购物车,都依赖同一份全局状态。用 useState + props 逐层传会非常啰嗦,这里用 useReducer 集中管理状态,再用 Context 跨层下发,任何子组件都能直接拿 state 和 dispatch。
涉及知识:useReducer(reducer 纯函数、action 类型、dispatch)、createContext / useContext 跨层共享、自定义 Hook 封装 Context 访问、Provider 包裹根。
// shop.jsx — useReducer + Context:跨组件全局状态(计数器 + 购物车)
// 展示比 useState 更适合"复杂状态 / 多个互相依赖的子状态"的方案
import { useReducer, useContext, createContext } from "react";
// ---- 1. 定义 reducer:纯函数 (state, action) → newState ----
const initialState = {
count: 0, // 计数器
cart: [], // 购物车:[{ id, name, price, qty }]
};
function shopReducer(state, action) {
switch (action.type) {
case "INC":
return { ...state, count: state.count + 1 };
case "DEC":
return { ...state, count: state.count - 1 };
case "ADD": {
// 同款商品已存在就 +1,否则新增一条
const item = action.payload;
const found = state.cart.find((x) => x.id === item.id);
if (found) {
return {
...state,
cart: state.cart.map((x) =>
x.id === item.id ? { ...x, qty: x.qty + 1 } : x
),
};
}
return { ...state, cart: [...state.cart, { ...item, qty: 1 }] };
}
case "REMOVE":
return { ...state, cart: state.cart.filter((x) => x.id !== action.id) };
default:
return state; // 未知 action 不变(防错)
}
}
// ---- 2. 创建 Context,装 state 和 dispatch ----
const ShopContext = createContext(null);
// ---- 3. Provider 组件:用 useReducer 初始化,放进 Context ----
export function ShopProvider({ children }) {
const [state, dispatch] = useReducer(shopReducer, initialState);
return (
<ShopContext.Provider value={{ state, dispatch }}>
{children}
</ShopContext.Provider>
);
}
// ---- 4. 自定义 Hook:子组件靠它读全局状态 ----
function useShop() {
const ctx = useContext(ShopContext);
if (!ctx) throw new Error("useShop 必须在 <ShopProvider> 里用");
return ctx;
}
// 计数器组件:派发 INC / DEC
function Counter() {
const { state, dispatch } = useShop();
return (
<div>
<h3>计数器:{state.count}</h3>
<button onClick={() => dispatch({ type: "DEC" })}>-</button>
<button onClick={() => dispatch({ type: "INC" })}>+</button>
</div>
);
}
// 购物车组件:派发 ADD / REMOVE,展示列表与合计
function Cart() {
const { state, dispatch } = useShop();
const total = state.cart.reduce((s, x) => s + x.price * x.qty, 0);
const add = (item) => dispatch({ type: "ADD", payload: item });
return (
<div>
<h3>购物车</h3>
<button onClick={() => add({ id: 1, name: "苹果", price: 5 })}>加苹果</button>
<button onClick={() => add({ id: 2, name: "香蕉", price: 3 })}>加香蕉</button>
<ul>
{state.cart.map((x) => (
<li key={x.id}>
{x.name} ¥{x.price} × {x.qty} = ¥{x.price * x.qty}{" "}
<button onClick={() => dispatch({ type: "REMOVE", id: x.id })}>
删除
</button>
</li>
))}
</ul>
<p>合计:¥{total}</p>
</div>
);
}
// ---- 5. 根组件:用 Provider 把整棵树包起来 ----
export default function ShopApp() {
return (
<ShopProvider>
<h2>useReducer + Context 示例</h2>
<Counter />
<Cart />
</ShopProvider>
);
}要点讲解:
- 为什么是 useReducer 而不是 useState:当状态结构复杂(多个字段互相依赖)、或更新逻辑有分支(购物车加同款要 +1 而不是新增),把"怎么改"抽到 reducer 里比散落一堆
setX清晰得多。reducer 是纯函数,可单独测试,行为可预测。 - action 是"意图",不是"操作指令":我们派发的是
{ type: "ADD", payload: item }这种"发生了什么",而不是{ type: "PUSH_TO_CART", item }这种"该怎么做"。reducer 决定怎么做,组件不需要知道——这就是 Flux / Redux 的核心思路。 - 不可变更新是硬要求:reducer 里同样要返回新对象(
{ ...state, cart: ... }),不能state.cart.push(item)。React 会比较新旧 state 的引用,直接改原对象它感知不到。 - Context 解决"prop drilling":
state和dispatch放进ShopContext.Provider的value里,任意层级的子组件用useContext直接取,不必从根组件一层层 props 传下来。 - 自定义 Hook
useShop:把useContext(ShopContext)+ 容错判断(if (!ctx) throw ...)封装成useShop()。子组件用它而非直接用useContext,代码更短、错误信息更友好。 - Provider 在最外层:
ShopApp根组件用<ShopProvider>把整棵树包起来,里面的Counter和Cart才能取到 Context。真实项目里 Provider 通常放在应用根(甚至main.jsx),让整棵树都能访问。 - 何时该升级到 Redux / Zustand:本项目用内置
useReducer + Context就够了。但当状态被极高频读写时,Context 的"任意 value 变化都重渲染所有消费者"会变成性能瓶颈,这时上 Redux Toolkit 或 Zustand 更合适。
小结
三个项目对应了 React 实际开发的三大支柱:本地状态 + 副作用(Todo)、异步数据(天气)、全局状态(购物车)。它们都用到了前面 12 篇学的东西——函数组件、props、Hooks、列表渲染、不可变更新——只是组合起来解决真实需求。
建议你至少把其中一个改造成自己的版本:给 Todo 加"按标题搜索"和"按优先级排序";给天气加"5 天预报"和"切换城市收藏";给购物车加"修改数量"和"结算清单"——做过一遍比读十遍更扎实。等这三个项目你都改过、跑通,就算真正"会写 React"了。
恭喜!到这里 React 系列就真正完结了。接下来可以挑一个方向深入:Next.js(全栈 SSR)、React Native(写手机 App)、状态管理(Redux Toolkit / Zustand)、性能优化(memo / useMemo / useCallback)——边做边学,进步最快。
← 上一篇 React 生命周期与 useEffect
返回 React 教程目录