我们今天要做的,是一个再普通不过的小项目------待办事项(Todo List)应用 。
它看起来简单:输入任务、添加、勾选完成、删除、清空已完成项。但正是这种"简单",恰恰是理解 React 核心思想的最佳载体。
一、如果把所有代码都写在 App.jsx 中,会怎样?
假设我们不拆分组件,直接在 App.jsx 里写完所有的逻辑:
javascript
function App() {
const [todos, setTodos] = useState([]);
// 添加、删除、切换......全部在这里处理
return (
<div>
<input onKeyPress={...} />
<button onClick={...}>Add</button>
<ul>
{todos.map(...)}
</ul>
</div>
);
}
这会带来什么问题?
可读性下降,维护成本飙升。
当我们需要修改"输入框样式"时,得翻遍整个文件找 input;
当我们要支持"编辑任务"功能时,又要在这堆代码中插入新逻辑;
更别说以后加搜索、分类、拖拽排序了......
所以,第一个问题来了:
我们能不能把这个应用拆成几个独立的组件?每个组件负责什么?
二、组件拆解:职责分离才是王道
经过一番思考,我们可以将这个应用划分为三个清晰的部分:
TodoInput------ 负责用户输入和添加新任务;TodoList------ 负责展示所有任务,并提供勾选和删除操作;TodoStats------ 展示统计信息(总数、未完成数),并提供"清空已完成"的按钮。
这样做的好处显而易见:
- 每个组件只关心自己的 UI 和行为;
- 修改一处不会影响其他部分;
- 未来复用也更容易(比如另一个项目也需要一个输入框)。
那么下一个问题就来了:
这些组件都需要访问同一个数据 todos,那谁来管理这个数据呢?
三、状态归属:谁该拥有 todos 的控制权?
如果我们让 TodoInput 自己维护 todos,那 TodoList 就无法知道当前有哪些任务了;
如果 TodoList 管理,那 TodoInput 又没法添加新任务。
于是我们得出结论:
todos必须由最顶层的App组件统一管理。
因为它是唯一能同时向所有子组件传递数据和回调函数的地方。
这其实就是 React 的核心设计哲学之一:单向数据流 + 状态提升(State Hoisting) 。
App 是"数据源",通过 props 把数据传给子组件,子组件通过回调通知父组件更新。
四、深入分析:TodoInput 组件为什么封装 onAdd?
我们来看 TodoInput 的实现:
ini
const TodoInput = (props) => {
const { onAdd } = props;
const [inputValue, setInputValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onAdd(inputValue);
setInputValue('');
};
return (
<form onSubmit={handleSubmit}>
<input value={inputValue} onChange={(e) => setInputValue(e.target.value)} />
<button type="submit">Add</button>
</form>
);
};
这里有个关键点:我们没有直接在按钮上绑定 onAdd,而是先封装了一个 handleSubmit 函数。
这是为什么?
如果我们直接写 <button onClick={() => onAdd(inputValue)}>Add</button> 呢?
听起来也没错,但有两个隐患:
- 无法阻止默认提交行为 :表单提交会导致页面刷新(除非手动调用
preventDefault()); - 无法统一处理后续逻辑:比如提交后清空输入框,必须重复写两次(按钮和回车)。
而使用 <form onSubmit> 则天然解决了这两个问题:
- 浏览器原生支持"回车提交";
- 所有提交逻辑集中在一个函数中,避免重复。
所以,
handleSubmit不只是"包装一下",而是为了统一处理提交流程,确保用户体验一致、代码简洁可靠。
五、TodoList:如何高效地渲染和交互?
ini
const TodoList = ({ todos, onDelete, onToggle }) => {
return (
<ul>
{todos.length === 0 ? (
<li className="empty">No todos yet!</li>
) : (
todos.map(todo => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
</label>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
))
)}
</ul>
);
};
这里有几个值得思考的设计:
key={todo.id}:React 需要稳定键值来识别列表项,防止重新渲染时出现闪烁;checked={todo.completed}:使用受控组件,保证 UI 与状态同步;onChange触发onToggle:不是直接改completed,而是通知父组件更新状态,保持单一数据源。
有人可能会问:"为什么不直接在
input上写checked={todo.completed}并监听onChange?"答案是:可以!但必须配合
useState和setTodos来更新状态,否则就是"不受控组件",违背 React 设计原则。
六、TodoStats:极简但不可忽视的统计层
javascript
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
return (
<div className="todo-stats">
<p>Total: {total} | Active: {active} | Completed: {completed}</p>
{completed > 0 && (
<button onClick={onClearCompleted} className="clear-btn">
Clear Completed
</button>
)}
</div>
);
};
这个组件看似简单,但它承担着两个重要角色:
- 信息聚合:将原始数据转化为有意义的统计;
- 行为触发:提供"清空已完成"的入口。
它的存在告诉我们:即使是最小的功能模块,也应该被抽象为独立组件,才能更好地组织代码结构。
七、终极难题:页面刷新后数据全丢?怎么办?
现在我们面临一个现实问题:
每次刷新页面,
todos都变成空数组了!
这是因为在浏览器中,JavaScript 的内存是临时的,一旦关闭页面或刷新,状态就会丢失。
那有没有办法让数据持久化?
当然有------localStorage。
但我们不想在每次 addTodo、deleteTodo、toggleTodo 时都手动调用 localStorage.setItem(),那样太繁琐。
那有没有更优雅的方式?
答案是:结合 useEffect 使用!
javascript
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
这段代码的意思是:
"只要
todos发生变化,就自动保存到 localStorage。"
这带来了巨大的便利:
- 不需要在每一个方法里重复写存储逻辑;
- 数据变更即保存,几乎无感知;
- 符合"副作用"处理的最佳实践。
而且我们还可以在初始化时从 localStorage 加载数据:
ini
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
这样一来,打开页面时就能恢复上次的状态,用户体验大大提升。
八、总结:这不是一个简单的 Todo 应用
表面上看,这是一个入门级的练习项目。但实际上,它涵盖了 React 开发中的多个核心思想:
| 概念 | 在本项目中的体现 |
|---|---|
| 组件化 | 拆分为 Input/List/Stats 三个独立组件 |
| 状态管理 | todos 由 App 统一管理,通过 props 传递 |
| 受控组件 | 输入框用 value 和 onChange 控制,确保数据一致性 |
| 表单提交优化 | 使用 form onSubmit 实现统一提交逻辑 |
| 状态持久化 | 结合 localStorage 和 useEffect 实现数据保存 |
| 不可变性 | 使用 map、filter 创建新数组,而非修改原数组 |