在前端开发的学习路径中,Todo List(待办事项清单)被称为“Hello World”级别的实战项目。虽然看起来简单,但它涵盖了 CRUD(增删改查)、组件拆分、状态管理等核心逻辑。
今天我们将通过一份基于 Vite + React + Stylus 的源码,深入拆解 React 的核心开发模式。
1. 项目架构与组件化思维
React 的核心思想是组件化。我们将 UI 拆分成独立的、可复用的部分。在这个项目中,我们的组件结构非常清晰:
App(父组件) : 整个应用的容器,负责“持有数据”和“管理逻辑”。它是唯一的数据源(Source of Truth) 。TodoInput(子组件) : 负责用户输入,将新任务传递给父组件。TodoList(子组件) : 负责展示任务列表,处理完成/删除操作。TodoStarts(子组件) : 负责展示统计信息(总数、剩余、已完成),并提供清除功能。
这种结构体现了 React 开发的一个重要原则:状态提升(Lifting State Up) 。因为 TodoList 需要展示数据,TodoStarts 需要统计数据,所以我们将数据统一放在它们的共同父组件 App 中管理。
2. 核心知识点拆解
2.1 State 管理与 Hooks 高级用法
在 App.jsx 中,我们使用了 useState 来管理任务列表数据。
// App.jsx
const [todos, setTodos] = useState(() => {
// 💡 知识点:Lazy Initialization (惰性初始化)
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
解析:
通常我们写 useState([]),但这里传入了一个函数。
- 为什么?
localStorage.getItem是同步操作,如果直接写在组件体内,每次组件重新渲染都会执行,影响性能。 - 惰性初始化:传入函数后,React 只会在组件首次渲染时执行该逻辑,后续渲染会跳过,这是性能优化的一个小技巧。
2.2 受控组件 (Controlled Components)
在 TodoInput.jsx 中,我们处理用户输入的方式与 Vue 的 v-model 不同。
// TodoInput.jsx
const [inputValue, setInputValue] = useState('');
// ... render
<input
type="text"
value={inputValue} // 1. 绑定状态
onChange={e => setInputValue(e.target.value)} // 2. 监听变化并更新状态
/>
解析:
React 提倡单向数据流。
-
输入框的值由 React state (
inputValue) 控制。 -
用户的输入触发
onChange。 -
setInputValue 更新 state,React 重新渲染组件,输入框显示新值。
这被称为受控组件,它保证了数据和视图的严格同步。
2.3 组件通信:父传子 (Props)
数据如何从 App 流向 TodoList?通过 Props。
// App.jsx (父组件)
<TodoList todos={todos} ... />
// TodoList.jsx (子组件)
const TodoList = (props) => {
const { todos } = props; // 解构获取数据
// ...
}
解析:
父组件将 todos 数组作为属性传递给子组件,子组件只能读取,严禁直接修改 Props。这是 React 数据流向单一性的铁律。
2.4 组件通信:子传父 (回调函数)
子组件想要修改数据怎么办?例如用户在 TodoInput 点击了添加,或者在 TodoList 点击了删除。
答案:父组件传递“修改数据的方法”给子组件。
场景一:添加任务
// App.jsx (父组件定义方法)
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }])
}
// 传递给子组件
<TodoInput onAdd={addTodo} />
// TodoInput.jsx (子组件调用)
const handleSubmit = (e) => {
e.preventDefault();
onAdd(inputValue); // 调用父组件传下来的函数
setInputValue('');
}
场景二:兄弟组件通信(间接)
TodoInput 添加的数据,最终显示在了 TodoList 中。它们之间没有直接联系,而是通过:
-
TodoInput通知App更新 state。 -
Appstate 变化,触发重新渲染。 -
App将新的todos传给TodoList。这就是状态共享的魅力。
2.5 交互逻辑详解:不可变数据的删除与更新
在 React 中,更新状态(State)有一个核心原则:永远不要直接修改 State,而是用新数据替换旧数据。这一点在删除和勾选操作中体现得淋漓尽致。
1. 删除功能:Filter 的妙用
点击删除按钮时,我们需要移除列表中的某一项。在 App.jsx 中,我们没有使用数组的 splice 方法(因为它会直接修改原数组),而是使用了 filter。
// App.jsx
const deleteTodo = (id) => {
// 💡 知识点:Immutable Update (不可变更新)
// filter 返回一个新数组,不包含被删除的那一项
setTodos(todos.filter(todo => todo.id !== id))
}
在 UI 组件 TodoList.jsx 中,我们需要注意事件绑定的写法:
// TodoList.jsx
<button onClick={() => onDelete(todo.id)}>Delete</button>
⚠️ 新手易错点:
一定要写成箭头函数 () => onDelete(todo.id)。
如果写成 onClick={onDelete(todo.id)},函数会在组件渲染时立即执行,导致无限循环报错。
2. 勾选切换:Map 与展开运算符
这是 React 数组更新中最常见的模式。当用户点击 Checkbox 时,我们需要把数组中特定 ID 的那一项的 completed 状态取反,同时保持其他项不变。
// App.jsx
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
// 遍历每一个 item,找到 id 匹配的那一个
todo.id === id ? {
...todo, // 💡 知识点:Spread Operator (展开运算符) 复制原有属性
completed: !todo.completed // 覆盖 completed 属性
} : todo // id 不匹配的项保持原样
))
}
逻辑解析:
todos.map:生成一个全新的数组,保证不修改原todos引用。...todo:利用 ES6 的展开运算符,将旧对象的所有属性(id, text)复制到新对象中。completed: !todo.completed:单独覆盖我们需要修改的属性。
在 TodoList.jsx 中,我们将这个逻辑绑定到 input 的 onChange 事件上,同时利用 checked 属性实现受控组件的双向绑定效果:
// TodoList.jsx
<input
type="checkbox"
checked={todo.completed} // 视图状态依赖数据
onChange={() => onToggle(todo.id)} // 数据更新依赖交互
/>
同时也利用这个状态来动态控制 CSS 类名,实现完成时的划线样式:
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
2.6 列表渲染与 Key
在 TodoList.jsx 中,我们使用 map 方法渲染列表:
// TodoList.jsx
todos.map(todo => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
{/* ...内容 */}
</li>
))
解析:
- Key 的重要性:React 需要
key来识别哪些元素改变了、添加了或删除了。这里我们使用了todo.id(时间戳) 作为唯一标识。切记不要使用数组索引(index)作为 key,这在列表顺序变化时会导致严重的渲染错误或性能问题。 - 条件渲染:我们使用三元运算符
todos.length === 0 ? (...) : (...)来处理空状态的展示。
2.7 副作用处理 (useEffect)
如何实现数据持久化,刷新页面数据不丢失?我们使用了 useEffect。
// App.jsx
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos])
解析:
useEffect用于处理副作用(Side Effects),比如数据获取、订阅、手动修改 DOM 等。- 依赖数组
[todos]:这意味着每当todos状态发生变化时,React 就会执行这个函数,将最新的数据同步到 LocalStorage。
2.8 逻辑复用与短路运算
在 TodoStarts.jsx 中,有一个很优雅的条件渲染写法:
// TodoStarts.jsx
{
completed > 0 && (
<button onClick={onClearCompleted}>Clear Completed</button>
)
}
解析:
利用 JavaScript 的逻辑与 (&&) 运算。只有当 completed > 0 为真时,后面的 Button 才会渲染。这比写 if/else 要简洁得多。
3. 总结
通过这个小小的 Todo List,我们完整实践了 React 开发的“黄金法则”:
- 数据驱动视图:UI 是 State 的投影 (
UI = f(State))。 - 单向数据流:数据向下流动 (Props),事件向上冒泡 (Callback)。
- 不可变性:在
App.jsx的deleteTodo等方法中,我们使用了filter和map返回新数组,而不是直接修改原数组 (push/splice),这对 React 识别状态变化至关重要。
// ❌ 错误示范
todos.push(newItem);
// ✅ 正确示范 (不可变更新)
setTodos([...todos, newItem]);
4. 项目源码
4.1 App.jsx
import { useState, useEffect } from 'react'
import './styles/app.styl'
import TodoList from './components/TodoList.jsx'
import TodoInput from './components/TodoInput.jsx'
import TodoStarts from './components/TodoStarts.jsx'
function App() {
// 子组件共享的数据状态
const [todos, setTodos] = useState(() => {
// 高级用法
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
// 子组件修改数据的方法
const addTodo = (text) => {
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(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
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} />
<TodoStarts total={todos.length} active={activeCount} completed={completedCount} onClearCompleted={clearCompleted}/>
</div>
)
}
export default App;
4.2 TodoInput.jsx
import { useState } from 'react'
const TodoInput = (props) => {
console.log(props);
const { onAdd } = props;
// react 不支持 Vue 中的 v-model 那样的双向绑定,react 认为这样性能不好
// react 只支持单向绑定,性能好 + onChange 实现数据和视图的同步
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;
4.3 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)}>Delete</button>
</li>
))
)
}
</ul>
)
}
export default TodoList;
4.4 TodoStarts.jsx
const TodoStarts = (props) => {
const { total, active, completed, onClearCompleted } = props;
return (
<div className="todo-starts">
<p>Total: {total} | Active: {active} | Completed: {completed}</p>
{
completed > 0 && (
<button onClick={onClearCompleted} className="clear-btn">Clear Completed</button>
)
}
</div>
)
}
export default TodoStarts;