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>
  );
}

要点讲解:

实战 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}` +
            `&current=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>
  );
}

要点讲解:

实战 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>
  );
}

要点讲解:

小结

三个项目对应了 React 实际开发的三大支柱:本地状态 + 副作用(Todo)、异步数据(天气)、全局状态(购物车)。它们都用到了前面 12 篇学的东西——函数组件、props、Hooks、列表渲染、不可变更新——只是组合起来解决真实需求。

建议你至少把其中一个改造成自己的版本:给 Todo 加"按标题搜索"和"按优先级排序";给天气加"5 天预报"和"切换城市收藏";给购物车加"修改数量"和"结算清单"——做过一遍比读十遍更扎实。等这三个项目你都改过、跑通,就算真正"会写 React"了。

恭喜!到这里 React 系列就真正完结了。接下来可以挑一个方向深入:Next.js(全栈 SSR)、React Native(写手机 App)、状态管理(Redux Toolkit / Zustand)、性能优化(memo / useMemo / useCallback)——边做边学,进步最快。

← 上一篇 React 生命周期与 useEffect

返回 React 教程目录

✈️💬