用 React 构建一个 Todo 应用:从状态管理到组件通信的清晰实践
很多人在学习 React 时,常被 state、props、组件通信、useEffect等概念搞得一头雾水。 其实,这些抽象概念完全可以通过一个具体的小项目来理解——比如一个 TodoList(待办事项)应用。
本文将带你从零开始,用最基础的 React 特性(仅 useState 和useEffect),构建一个功能完整、结构清晰的 Todo 应用,并在过程中自然掌握 React 的核心思想。
先站在高处看全局:组件是怎么分工的?
整个 Todo 应用由 4 个组件组成:
App(父组件)
├── TodoList 列表展示
├── TodoInput 输入新增
└── TodoStats 统计信息
可以把它想象成一家小公司:
- App:老板,掌握所有核心数据
- TodoInput:前台,负责接收新任务
- TodoList:执行部门,展示和操作任务
- TodoStats:财务部,负责统计数据
一个核心原则:
数据只放在一个地方,由最上层的父组件统一管理。
🧱 整体结构:父组件是“中央控制器”
在这个 Todo 应用中,App.jsx 是整个应用的“大脑” —— 它负责:
- 持有所有待办事项(
todos数组) - 提供修改这些事项的方法(如添加、删除、标记完成等)
- 将数据和方法通过
props分发给子组件
我们先从最简单的状态初始化开始:
// App.jsx
import {
useState,
useEffect
} from 'react'
import './styles/app.styl'
import TodoList from './components/TodoList'
import TodoInput from './components/TodoInput'
import TodoStats from './components/TodoStats'
function App() {
const [todos, setTodos] = useState([]);
return (
<div className="todo-app">
<h1>My Todos</h1>
</div>
);
}
此时,todos 是一个空数组,没有任何任务。接下来,我们要让这个列表“活”起来。
生活比喻:就像一张空白的计划表——内容还没填,但格式已经准备好,只等你动手添加。
📥 输入新任务:TodoInput 组件
用户需要一个地方输入新任务。我们创建 TodoInput 组件,它不持有任何全局状态,只管理自己的输入框内容:
// TodoInput.jsx
import { useState } from 'react'
const TodoInput = (props) => {
const { onAdd } = props
// 参数 props 是父组件传递给它的所有属性的集合(onAdd)
const [inputValue, setInputValue] = useState('')
const handleSubmit = (e) => {
e.preventDefault();
onAdd(inputValue);
setInputValue('');
}
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
/>
<button type="submit">Add</button>
</form>
)
}
export default TodoInput
-
使用
useState创建一个 局部状态(local state) :inputValue:当前输入框中的文本内容setInputValue:更新该内容的函数
-
handleSubmit是表单提交时触发的事件处理函数。 -
e.preventDefault()阻止表单默认的页面刷新行为。 -
调用父组件传入的
onAdd函数,并将当前输入框的值inputValue作为参数传出去。 -
然后清空输入框(通过
setInputValue(''))。 -
onChange={...}:每当用户输入内容时,更新inputValue状态。
关键点在于:onAdd 是父组件传进来的函数。这意味着 TodoInput 自己不能决定“往哪里加任务”,它只是“上报”用户想加什么。
在 App 中使用它:
// App.jsx
const addTodo = (text) => {
setTodos([...todos, {
id: Date.now(), // 时间戳
text,//
completed: false,
}])
}
return (
<div className="todo-app">
<h1>My Todos</h1>
<TodoInput onAdd={addTodo} />
</div>
);
}
这就是 “子 → 父”通信:子组件通过调用
props中的函数,把用户意图传递给父组件。
📋 展示任务列表:TodoList 组件
有了任务,就要展示出来。TodoList 接收 todos 数组,并渲染每一项:
// TodoList.jsx
const TodoList = (props) => {
const { todos, onDelete, onToggle } = props;
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' : ''}>
<label >
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
</label>
<button onClick={() => onDelete(todo.id)}>X</button>
</li>
))
)
}
</ul>
)
}
export default TodoList
🔍 条件渲染:空状态 vs 有数据
{
todos.length === 0 ? (
<li className="empty">No todos yet!</li>
) : (
todos.map(todo => ( /* 渲染每个任务 */ ))
)
}
-
使用 三元运算符 实现条件渲染:
- 如果
todos为空数组(length === 0),显示友好提示:“No todos yet!” - 否则,遍历
todos并渲染每一项
- 如果
-
这是 React 中处理“空状态”的常见模式,避免用户面对空白屏幕感到困惑。
🔄 列表渲染:使用 .map() 生成元素
todos.map(todo => (
<li
key={todo.id}
className={todo.completed ? 'completed' : ''}
>
{/* 内容 */}
</li>
))
关键点 1:key={todo.id}
- React 要求列表中的每个元素必须有 唯一且稳定的
key。 todo.id通常由父组件在添加任务时生成(如Date.now()或 UUID),确保唯一性。- 作用:帮助 React 高效地 diff 和更新 DOM,避免不必要的重渲染。
⚠️ 错误做法:用数组索引
index作 key(在列表可能增删时会导致 UI 错乱)。
关键点 2:动态类名
className={todo.completed ? 'completed' : ''}
- 如果任务已完成(
completed: true),给<li>添加completed类。
注意:
- 每个任务都有复选框和删除按钮
- 点击复选框 → 调用
onToggle(id) - 点 X → 调用
onDelete(id)
父组件提供这两个方法:
🔄 1. 切换任务完成状态:toggleTodo
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
🔍 功能目标
- 找到
id匹配的任务项 - 将其
completed字段取反(true↔false) - 更新整个
todos状态
🗑️ 2. 删除任务:deleteTodo
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
🔍 功能目标
- 从
todos中移除id匹配的任务项 - 返回一个不包含该任务的新数组
这里再次强调:我们没有直接修改原数组,而是返回一个新数组。这是 React 状态更新的黄金法则——不可变性(Immutability) 。
在 App 中使用:
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
这就是 “父 → 子”通信:父组件通过
props把数据和行为传递下去。
📊 统计信息:TodoStats 组件
为了让用户清楚当前进度,我们加一个统计栏:
// TodoStats.jsx
const TodoStats = (props) => {
const { total,active, completed, onClearCompleted } = props
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>
)
}
export default TodoStats
🔹 条件渲染清除按钮
{
completed > 0 && (
<button ...>Clear Completed</button>
)
}
-
使用 逻辑与(
&&) 实现条件渲染:- 如果
completed > 0为true→ 渲染按钮 - 否则 → 不渲染(React 会忽略
false或null)
- 如果
-
用户体验优化:只有存在已完成任务时,才显示“清除”按钮,避免无效操作。
这些数字都来自 todos 的实时计算:
// App.jsx
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed))
}
const activeCount = todos.filter(todo => !todo.completed).length
const completedCount = todos.filter(todo => todo.completed).length
然后传给组件:
<TodoStats
total={todos.length}
active = {activeCount}
completed = {completedCount}
onClearCompleted={clearCompleted}
/>
🔗 组件通信全景图
现在,整个应用的数据流非常清晰:
-
父组件 (
App) :- 持有
todos状态 - 定义所有修改状态的方法
- 持有
-
子组件 (
TodoInput,TodoList,TodoStats) :- 只接收
props - 不直接修改
todos - 通过调用
props中的函数“请求”变更
- 只接收
这种模式叫 “状态提升” —— 把共享状态放到最近的共同父组件中,让数据流变得线性、可预测。
兄弟组件之间不直接通信!比如
TodoInput添加任务后,TodoList能自动更新,不是因为它们“对话”了,而是因为它们共用同一个todos状态,而这个状态由App管理。
展示结果:
💾 最后一步:持久化到本地存储
现在应用功能完整了,但刷新页面后任务会消失。我们只需在最后加上一行代码,就能让它“记住”你的任务:
// App.jsx
import { useState, useEffect } from 'react';
function App() {
const [todos, setTodos] = useState([]);
// ✅ 新增:监听 todos 变化,自动保存
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
// ... 其他代码不变
}
同时,为了让首次加载时能读取之前保存的任务,我们稍作调整:
const [todos, setTodos] = useState(() => {
// 初始化时尝试读取 localStorage
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
因为本地存储是副作用(side effect) ,不应该干扰核心状态逻辑。先把“内存中的 Todo 应用”做对,再考虑“持久化”,这是良好的开发习惯。
这时我们刷新页面就能看到上次保存的数据:
✅ 总结:清晰、可控、可扩展
通过这个小项目,我们掌握了 React 开发的核心思想:
✅ 总结:Todo 应用教会我们的 React 核心思想
- 状态集中:所有数据由
App统一管理,子组件只读不改。 - 单向数据流:父传数据/方法(props),子通过回调上报操作。
- 不可变更新:用
map、filter等生成新数组,绝不直接修改原状态。 - 组件各司其职:输入、列表、统计互不干扰,高内聚低耦合。
- 持久化最后加:先做对逻辑,再用
localStorage保存数据。
一个 Todo,串起 React 的最佳实践——简单,但完整。
你不需要复杂的工具,仅用 useState 和 useEffect,就能构建一个结构清晰、易于维护的应用。而这,正是 React 的魅力所在。