综合练习:待办清单应用
这一课你将能做到
- 用 React + Tailwind CSS 从零搭建一个功能完整的待办清单应用
- 实现添加、删除、完成切换、本地存储持久化等核心交互
- 用 Vitest 编写测试,确保核心逻辑可靠
为什么要学
前面学的都是「知识点」,这一课把它们串成「项目」。真实工作不是做单选题,而是面对一个模糊需求,自己拆功能、选技术、写代码、调样式、修 bug。这个项目虽然小,但覆盖了 React 开发的核心闭环:组件拆分 → 状态管理 → 事件处理 → 副作用 → 测试。
做完这个项目,你就有了一个能写进简历的作品,更重要的是,建立了「我能独立做出东西」的信心。
先建立图像
┌─────────────────────────────┐
│ 📝 待办清单 │
│ ┌───────────────────────┐ │
│ │ 输入任务... [添加] │ │ ← 输入区
│ └───────────────────────┘ │
│ ☑ 学习 React [删除] │ │
│ ☐ 写测试代码 [删除] │ ← 列表区
│ ☐ 部署到 Vercel [删除] │ │
│ │
│ 共 3 项,已完成 1 项 │ ← 统计区
└─────────────────────────────┘
组件拆分:
App
├── TodoInput(输入框 + 按钮)
├── TodoList
│ └── TodoItem × N(单条任务)
└── TodoStats(统计信息)
跟着做
步骤 1:创建项目
npm create vite@latest todo-app -- --template react
cd todo-app
npm install
# 安装 Tailwind v4
npm install tailwindcss @tailwindcss/vite
# 安装测试工具
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vitest/coverage-v8
配置 vite.config.js:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test/setup.js"
}
});
src/index.css:
@import "tailwindcss";
src/test/setup.js:
import "@testing-library/jest-dom";
步骤 2:数据结构与状态设计
先想清楚数据长什么样:
// 单条待办
{
id: 1,
text: "学习 React",
completed: false,
createdAt: "2026-10-06T10:00:00.000Z"
}
App 组件的状态:
todos:所有待办数组
inputValue:输入框当前值
步骤 3:实现 TodoInput 组件
src/components/TodoInput.jsx:
import { useState } from "react";
export default function TodoInput({ onAdd }) {
const [value, setValue] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
const text = value.trim();
if (!text) return; // 空内容不添加
onAdd(text);
setValue(""); // 清空输入框
};
return (
<form onSubmit={handleSubmit} className="flex gap-2 mb-6">
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="输入新任务..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500
dark:bg-gray-800 dark:border-gray-600 dark:text-white"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded-lg
hover:bg-blue-600 transition-colors font-medium"
>
添加
</button>
</form>
);
}
步骤 4:实现 TodoItem 组件
src/components/TodoItem.jsx:
export default function TodoItem({ todo, onToggle, onDelete }) {
return (
<div className="flex items-center gap-3 p-4 bg-white dark:bg-gray-800
rounded-lg shadow-sm border border-gray-200 dark:border-gray-700
hover:shadow-md transition-shadow">
{/* 完成切换 */}
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className="w-5 h-5 text-blue-500 rounded focus:ring-blue-500 cursor-pointer"
/>
{/* 任务文字,完成时加删除线 */}
<span
className={`flex-1 ${
todo.completed
? "line-through text-gray-400 dark:text-gray-500"
: "text-gray-800 dark:text-gray-200"
}`}
>
{todo.text}
</span>
{/* 删除按钮 */}
<button
onClick={() => onDelete(todo.id)}
className="px-3 py-1 text-sm text-red-500 hover:bg-red-50
dark:hover:bg-red-900/20 rounded transition-colors"
>
删除
</button>
</div>
);
}
步骤 5:实现 TodoStats 组件
src/components/TodoStats.jsx:
export default function TodoStats({ todos }) {
const total = todos.length;
const completed = todos.filter((t) => t.completed).length;
if (total === 0) return null;
return (
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
共 {total} 项,已完成 {completed} 项,未完成 {total - completed} 项
</p>
);
}
步骤 6:组装 App 组件
import { useState, useEffect } from "react";
import TodoInput from "./components/TodoInput";
import TodoItem from "./components/TodoItem";
import TodoStats from "./components/TodoStats";
const STORAGE_KEY = "todo-app-todos";
function App() {
// 从 localStorage 读取初始数据
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
});
// 数据变化时自动保存
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}, [todos]);
const addTodo = (text) => {
const newTodo = {
id: Date.now(),
text,
completed: false,
createdAt: new Date().toISOString()
};
setTodos([...todos, newTodo]);
};
const toggleTodo = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 py-8 px-4">
<div className="max-w-md mx-auto">
<h1 className="text-3xl font-bold text-center mb-8 text-gray-800 dark:text-white">
📝 待办清单
</h1>
<TodoInput onAdd={addTodo} />
<div className="space-y-3">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
))}
</div>
{todos.length === 0 && (
<p className="text-center text-gray-400 dark:text-gray-500 mt-8">
暂无任务,添加一个吧
</p>
)}
<TodoStats todos={todos} />
</div>
</div>
);
}
export default App;
步骤 7:编写测试
import { render, screen, fireEvent } from "@testing-library/react";
import App from "./App";
// 每个测试前清空 localStorage
beforeEach(() => {
localStorage.clear();
});
describe("待办清单应用", () => {
it("应该显示标题和输入框", () => {
render(<App />);
expect(screen.getByText("📝 待办清单")).toBeInTheDocument();
expect(screen.getByPlaceholderText("输入新任务...")).toBeInTheDocument();
});
it("应该能添加新任务", () => {
render(<App />);
const input = screen.getByPlaceholderText("输入新任务...");
const button = screen.getByText("添加");
fireEvent.change(input, { target: { value: "学习 React" } });
fireEvent.click(button);
expect(screen.getByText("学习 React")).toBeInTheDocument();
expect(input.value).toBe(""); // 输入框清空
});
it("应该能切换任务完成状态", () => {
render(<App />);
const input = screen.getByPlaceholderText("输入新任务...");
fireEvent.change(input, { target: { value: "写测试" } });
fireEvent.click(screen.getByText("添加"));
const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox);
expect(screen.getByText("写测试")).toHaveClass("line-through");
});
it("应该能删除任务", () => {
render(<App />);
const input = screen.getByPlaceholderText("输入新任务...");
fireEvent.change(input, { target: { value: "要删除的任务" } });
fireEvent.click(screen.getByText("添加"));
fireEvent.click(screen.getByText("删除"));
expect(screen.queryByText("要删除的任务")).not.toBeInTheDocument();
});
it("空输入不应该添加任务", () => {
render(<App />);
fireEvent.click(screen.getByText("添加"));
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
});
});
讲清楚要点
- 状态提升:
todos 放在 App 组件(共同父组件),通过 props 把数据和回调传给子组件。这是 React 的标准模式。
- 不可变更新:添加用
[...todos, new],删除用 filter,修改用 map——始终返回新数组,不原地修改。
- localStorage 持久化:
useState 的懒初始化读取初始值,useEffect 监听变化自动保存。刷新页面数据不丢。
- key 用唯一 ID:用
Date.now() 生成 id 做 key,不用数组索引——删除中间项时索引会变,导致状态错乱。
- 受控表单:输入框的
value 绑定 state,onChange 更新 state,提交后清空——这就是受控组件模式。
你可能会懵的地方
-
为什么用 Date.now() 做 id?
简单场景够用。生产环境用 crypto.randomUUID() 或 nanoid,避免同一毫秒添加两条任务时 id 冲突。
-
localStorage 能存多少数据?
一般 5-10MB,只存字符串。存对象要 JSON.stringify,读取要 JSON.parse。敏感数据不要存这里(不加密)。
-
useState 的函数参数是什么?
useState(() => ...) 是懒初始化:函数只在首次渲染时执行一次。直接从 localStorage 读数据时用这个,避免每次渲染都解析 JSON。
-
为什么测试里要 localStorage.clear()?
测试之间要相互独立。前一个测试写入的数据会影响后一个测试,beforeEach 清空保证每个测试从干净状态开始。
-
怎么加编辑功能?
给 TodoItem 加「编辑」按钮,点击进入编辑模式(input 替换 span),保存时调用 onUpdate(id, newText)。核心还是 map 更新数组。
错法对照
| 错法 | 问题 | 正解 |
|---|
todos.push(newTodo) | 原地修改,React 不重新渲染 | setTodos([...todos, newTodo]) |
| 用数组索引做 key | 删除中间项后状态错乱 | 用唯一 id 做 key |
| 输入框不写 value | 非受控,无法清空 | value + onChange 受控组件 |
| useEffect 直接写 async | 不支持 async 回调 | 内部定义 async 函数再调用 |
| localStorage 存对象不序列化 | 存成 "[object Object]" | JSON.stringify / JSON.parse |
| 测试间共享 localStorage | 测试互相污染 | beforeEach 清空 |
| 空字符串也添加任务 | 产生空待办 | trim 后判空 |
卡点急救
- 测试报「localStorage is not defined」:jsdom 环境支持 localStorage,确认
vitest.config.js 里 environment: "jsdom"。
- 添加后页面没更新:检查是否直接修改了数组(push),必须用 setTodos 返回新数组。
- 刷新后数据丢失:检查 useEffect 依赖数组是否包含 todos;检查 localStorage 的 key 是否一致。
- dark 模式没生效:确认 html 标签有
dark 类,或系统偏好是深色。
面试常问
-
React 中如何管理列表数据?
状态放数组,操作用不可变方法(map/filter/展开运算符),key 用唯一 ID,状态提升到最近共同父组件。
-
localStorage 的优缺点?
优点:简单、同步 API、持久化。缺点:只存字符串、容量小、同步阻塞、无过期机制。大量数据用 IndexedDB,敏感数据不要存前端。
-
怎么设计组件拆分?
单一职责:一个组件只做一件事。可复用、可测试、易理解。拆分过细会增加 props 传递成本,拆分太粗会导致组件臃肿——这个项目拆成 4 个组件是平衡点。
-
如何优化大列表性能?
虚拟滚动(react-window)、React.memo 缓存列表项、useCallback 缓存回调函数。几百条以内不用优化。
自测三问
- 为什么
todos.map(t => t.id === id ? {...t, completed: !t.completed} : t) 能正确更新状态,而直接改 t.completed 不行?
- 这个项目中数据流向是怎样的?(谁持有 state,谁接收 props,谁触发回调)
- 如果要加「筛选:全部/未完成/已完成」功能,你会怎么设计?
小结一句话
这个项目把碎片知识串成了完整闭环:组件拆分定结构、状态驱动管数据、localStorage 做持久、测试保质量——这就是前端开发的标准工作流。
延伸
- 挑战升级:给项目加编辑功能、拖拽排序、分类标签、截止日期
- 下一课:部署预览——把项目发布到 Vercel,让全世界访问。