scss
text
编辑
src/
├── App.jsx
├── components/
│ ├── TodoInput.jsx // 添加任务输入框
│ ├── TodoList.jsx // 任务列表展示
│ └── TodoStats.jsx // 统计信息与清除已完成
└── styles/
└── app.styl // 全局样式(使用 Stylus)
整个应用围绕 "父组件持有状态,子组件通过 props 接收数据和回调" 的单向数据流原则构建。
📦 父组件:App.jsx ------ 状态管理中心
javascript
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() {
// ✅ 关键:初始化时从 localStorage 读取数据,形成持久化闭环的第一步
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,
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(t => !t.completed).length
const completedCount = todos.length - activeCount
// ⚠️ 注意:这段代码仅完成「写入」,必须配合初始化读取才能实现完整持久化
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos))
}, [todos])
return (
<div className="todo-app">
<h1>My Todo List</h1>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onDelete={deleteTodo}
onToggle={toggleTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
)
}
export default App
🔍 本地存储的完整闭环:读 + 写缺一不可
你可能会问:仅靠 useEffect 中的 localStorage.setItem 能实现本地存储吗?
答案是:不能。
useEffect部分只负责"写入" :当todos变化时,自动同步到localStorage。- 但缺少"读取"环节 :页面刷新后,若不从
localStorage恢复数据,todos会重置为空数组,导致数据丢失。
✅ 完整持久化 = 初始化读取 + 变化时写入
javascript
js
编辑
// 1. 初始化读取(关键!)
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
// 2. 变化时写入(你已有的代码)
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
💡 本质 :这是一个"内存 ↔ 本地存储"的双向同步闭环。只有两者都存在,才能实现"刷新不丢数据" 。
⚠️ 注意事项
- JSON 序列化限制 :
todos中只能包含可序列化的数据(字符串、数字、布尔值、普通对象/数组),不能包含函数、Symbol 等。 - 存储容量 :
localStorage通常限制为 5MB,适合轻量级数据。 - 性能优化 :避免因状态引用变化导致
useEffect频繁触发(可通过useMemo或深比较优化)。
✅ 状态管理的核心原则:子组件不能直接修改数据
你的理解完全正确:子组件不可以直接修改父组件的状态,只能"提交修改请求" 。这是 React 单向数据流的基石。
为什么这样设计?
- 状态不可变性(Immutability)
React 要求状态更新必须通过setState返回新对象,而非直接修改原对象。若子组件直接操作props.todos.push(...),既违反此原则,也无法触发重渲染。 - 状态变更可追溯
所有修改逻辑集中在父组件(如addTodo,deleteTodo),便于调试、测试和维护。 - 解耦与复用
子组件只需关心"何时触发",无需关心"如何修改",降低耦合度。
工作流程比喻
- 父组件 = 仓库管理员
持有todos(货物清单),掌握所有操作权限(增删改查),并负责同步到localStorage(纸质台账)。 - 子组件 = 前台接待员
无权直接操作仓库,只负责接收用户指令(点击按钮),并通过预设的"热线电话"(回调函数如onAdd)将请求转达给管理员。 - 流程 :
用户操作 → 子组件调用回调 → 父组件更新状态 → 触发重渲染 + 同步本地存储 → 子组件接收新props并更新视图。
✅ 这种设计让整个应用状态清晰、逻辑集中、易于扩展。
📝 子组件 1:TodoInput ------ 输入新任务
javascript
jsx
编辑
import { useState } from 'react'
const TodoInput = ({ 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)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
)
}
export default TodoInput
🔍 深入理解两个核心机制
1. 为什么需要 e.preventDefault()?
- 根本原因 :
<form>提交会触发浏览器默认行为------刷新页面。 - 若不阻止,刚添加的 todo 会因页面刷新而丢失。
- 关键原则 :
e.preventDefault()的使用取决于事件是否有默认行为,与"是否是添加操作"无关。
✅ 不需要 preventDefault 的添加场景:
ini
jsx
编辑
// 按钮点击(无默认行为)
<button onClick={() => { onAdd(text); }}>添加</button>
// 输入框回车监听(非表单提交)
<input onKeyDown={(e) => {
if (e.key === 'Enter') onAdd(inputValue);
}} />
2. 为什么 setInputValue('') 能清空输入框?
因为这是 受控组件(Controlled Component) :
value={inputValue}:输入框显示由 React 状态驱动。onChange:用户输入时同步更新状态。- 执行
setInputValue('')→ 状态变空 → 组件重渲染 → 输入框显示为空。
❌ 非受控组件(用
ref)无法通过此方式清空,需直接操作 DOM。
📋 子组件 2:TodoList ------ 渲染任务项
ini
jsx
编辑
const TodoList = ({ todos, onDelete, onToggle }) => {
return (
<ul className="todo-list">
{todos.length === 0 ? (
<li><p className="empty">No todos, yet!</p></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)}>×</button>
</li>
))
)}
</ul>
)
}
export default TodoList
🔍 删除逻辑深度解析
1. filter 如何实现"删除"?
filter返回新数组,保留todo.id !== id的项。- 目标项因
id相等被过滤掉 → 间接实现删除。 - 符合 React 不可变更新原则。
2. 为什么依赖 id 的唯一性?
id: Date.now()仅在创建时执行一次,作为该 todo 的永久标识。- 删除时传递的是原始 id,不是当前时间戳。
- 因此
todo.id !== id能精准匹配唯一目标。
⚠️ 风险 :若多个 todo 共享相同 id,会导致误删。生产环境建议用
uuid。
3. "唯一 id" 与 "id 不同" 是否矛盾?
不矛盾!
- 唯一 id:确保每个 todo 有唯一身份(目标项只有一个)。
- id ≠ 目标 id :是
filter的筛选条件(保留非目标项)。
🧩 类比:从 [小明(id=1), 小红(id=2)] 中删除小红 → 筛选"id ≠ 2" → 结果:[小明]
📊 子组件 3:TodoStats ------ 显示统计 & 清除操作
javascript
jsx
编辑
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>
)
}
export default TodoStats
✅ 条件渲染提升用户体验。
🔁 组件通信机制总结
| 通信方向 | 实现方式 |
|---|---|
| 父 → 子 | props 传递数据 |
| 子 → 父 | props 传递回调函数 |
| 兄弟组件 | 通过父组件状态中转 |
核心:数据自上而下,事件自下而上。
🧪 注意事项与最佳实践
- 状态不可变性:始终返回新数组/对象。
- ID 生成可靠性 :高频场景用
uuid替代Date.now()。 - 表单防空提交 :
disabled={!inputValue.trim()} - 性能优化 :
React.memo+useCallback(按需)
🔍 拓展思考:状态管理方案对比
| 方案 | 适用场景 | 优缺点 |
|---|---|---|
| 状态提升 | 小型应用 | ✅ 简单;❌ 状态集中 |
| Context | 跨多层组件 | ✅ 避免 props drilling;❌ 更新粒度粗 |
| Zustand/Redux | 大型应用 | ✅ 强大;❌ 成本高 |
✅ Todo 应用:状态提升 + localStorage 最佳。
✅ 总结要点
- ✅ 本地存储 = 初始化读取 + 变化时写入,缺一不可。
- ✅
e.preventDefault()仅用于阻止浏览器默认行为(如表单刷新)。 - ✅ 受控组件 是
setInputValue('')清空输入框的根本原因。 - ✅
filter删除 依赖 唯一且不变的 ID,本质是"保留非目标项"。 - ✅ 子组件不能直接修改状态,只能通过回调"提交请求",父组件统一处理。
- ✅ 父组件持有状态 是实现状态共享与持久化的简洁载体,但核心是完整的持久化闭环。