在 React 开发中,Todos 任务清单是一个经典且实用的入门项目。它涵盖了列表渲染、条件展示、表单处理、状态统计、批量操作以及数据持久化等常见场景。更重要的是,通过这个项目,我们可以深刻理解 React 的核心思想:单向数据流和状态提升。
不同于 Vue 的双向绑定,React 强调数据从父组件向下流动(props),事件从子组件向上冒泡(回调函数)。这种设计让数据流向可预测,调试更简单,适合大规模应用。
本文将基于 React 18 + Vite + Stylus,完全实现一个功能完整的 Todos 应用。主要功能包括:
- 添加任务
- 切换完成状态
- 删除任务
- 清除已完成任务
- 统计活跃/已完成数量
- localStorage 自动持久化
重点讲解:
- 状态提升(lifting state up)
- 父子组件通信(props + 回调)
- 兄弟组件间接通信
- 单向数据流的优势
- useState 与 useEffect 的实用技巧
项目完整代码结构
先给出整体结构和关键代码
App.jsx(父组件,主状态管理)
jsx
import { useState, useEffect } from 'react';
import './styles/app.styl';
import TodoInput from './components/TodoInput';
import TodoList from './components/TodoList';
import TodoStats from './components/TodoStats';
function App() {
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
const addTodo = (text) => {
if (!text.trim()) return;
setTodos([...todos, {
id: Date.now(),
text: text.trim(),
completed: false,
}]);
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.length - activeCount;
const totalCount = todos.length;
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
return (
<div className="todo-app">
<h1>My TodoList</h1>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onDelete={deleteTodo}
onToggle={toggleTodo}
/>
<TodoStats
total={totalCount}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
);
}
export default App;
TodoInput.jsx(输入组件)
jsx
import { useState } from 'react';
export default function TodoInput({ onAdd }) {
const [inputValue, setInputValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim()) {
onAdd(inputValue.trim());
setInputValue('');
}
};
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="输入任务,按回车或点击添加"
/>
<button type="submit">Add</button>
</form>
);
}
TodoList.jsx(列表组件)
jsx
export default function TodoList({ todos, onDelete, onToggle }) {
return (
<ul className="todo-list">
{todos.length === 0 ? (
<li className="empty">No todos yet!</li>
) : (
todos.map(todo => (
<li
key={todo.id}
className={todo.completed ? 'completed' : ''}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>X</button>
</li>
))
)}
</ul>
);
}
TodoStats.jsx(统计组件)
jsx
export default function TodoStats({ total, active, completed, onClearCompleted }) {
return (
<div className="todo-stats">
<p>Total: {total} | Active: {active} | Completed: {completed}</p>
{completed > 0 && (
<button onClick={onClearCompleted}>Clear Completed</button>
)}
</div>
);
}
React 单向数据流的精髓
React 的核心哲学是:数据从上向下流动,事件从下向上冒泡。
在传统原生开发或 jQuery 时代,我们习惯直接操作 DOM:
JavaScript
// 传统方式
document.querySelector('input').addEventListener('submit', () => {
const li = document.createElement('li');
// ...大量 DOM 操作
});
这种方式在复杂应用中极易失控。React 通过声明式渲染解决了这个问题:我们只描述“数据是什么样子”,React 会自动计算差异并更新 DOM。
在本项目中,所有状态都集中在父组件 App 中:
- todos 数组是唯一数据源
- 子组件只接收 props(数据 + 回调函数)
- 子组件不能直接修改 todos,只能调用父组件传来的回调(如 onAdd、onDelete)
这正是 状态提升(lifting state up) 的典型应用。当多个子组件需要共享或修改同一份数据时,最简单的可靠方式就是把状态提升到最近的共同父组件。
父子组件通信详解
父 → 子:通过 props 传递数据和方法
jsx
<TodoList todos={todos} onDelete={deleteTodo} onToggle={toggleTodo} />
- todos 是只读数据
- onDelete 和 onToggle 是父组件定义的修改方法
子组件接收后直接使用,不能修改 todos(因为是不可变数据模式)。
子 → 父:通过回调函数上报事件
以 TodoInput 为例:
jsx
const handleSubmit = (e) => {
e.preventDefault();
onAdd(inputValue); // 调用父组件传入的回调
setInputValue('');
};
子组件只负责收集用户输入,然后“上报”给父组件,由父组件决定如何更新状态。这种设计确保了数据的唯一可信来源。
兄弟组件通信:间接通过父组件
TodoInput、TodoList、TodoStats 三个组件互为兄弟,它们都需要访问或影响 todos 数据。
直接让兄弟组件互相引用是反模式,会造成耦合。正确的做法是:
- 父组件持有共享状态
- 通过 props 将数据/方法下发给需要的子组件
- 子组件通过回调修改状态,父组件更新后重新渲染所有子组件
这样,TodoStats 中的统计数字会自动随着 TodoInput 添加或 TodoList 删除而更新,无需额外通信代码。
单向绑定 vs 双向绑定
React 没有像 Vue 的 v-model 那样的内置双向绑定,而是采用“受控组件”模式:
jsx
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
这种方式看似繁琐,但性能更好(避免不必要的双向同步),并且状态完全可控。所有 UI 状态都有明确来源,便于调试和测试。
数据持久化:useEffect + localStorage
jsx
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
每次 todos 变化时自动保存。初始化时从 localStorage 读取,避免刷新丢失数据。这是一个典型的“副作用”场景,useEffect 是最佳选择。
总结
通过这个 Todos 项目,我们完整实践了 React 中最核心的几个概念:
- 状态提升:共享状态上移到共同父组件
- 单向数据流:props down, events up
- 受控组件:明确的状态来源
- 副作用管理:useEffect 处理持久化
这些原则不仅是 Todos 项目的基础,也是构建任何复杂 React 应用的关键。掌握它们,你就能写出可维护、可预测的高质量代码。