引言
在日常开发中,Todo 应用是学习前端框架的"Hello World"级案例,它浓缩了组件化开发的核心模式:状态管理、父子通信、兄弟组件协作、受控组件以及副作用处理 。今天我们将基于一个使用 React + Vite + Stylus 构建的 Todo 项目,逐行解析其源码,并总结出可复用的最佳实践。文章会覆盖入口文件、根组件与三个功能组件,最后用表格对比不同组件的职责与数据流向,帮助大家真正掌握 React 的组件化思维。 完整项目链接:gitee.com/hong-strong...
项目总览:组件树与数据流
整个应用由四个组件构成:
scss
App (根组件)
├─ TodoInput (输入添加)
├─ TodoList (列表展示与操作)
└─ TodoStats (统计与批量清除)
数据流原则:
- 状态提升 :共享状态
todos存储在顶层组件App中,并通过props向下传递给子组件。 - 子→父通信 :子组件无法直接修改
todos,而是通过父组件传递的回调函数(如onAdd、onDelete)来"上报"修改意图,由父组件执行状态更新。 - 兄弟组件通信 :
TodoInput、TodoList、TodoStats之间没有直接联系,它们都通过与同一个父组件App交互实现间接通信。任何操作引发的状态变化都会自动反映到所有相关组件中。
这种模式保证了单一数据源 和可预测的状态更新,是 React 哲学的基石。
入口文件 main.jsx:React 18 的渲染方式
jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
逐行解读:
StrictMode:React 的严格模式,仅在开发环境下生效。它会对组件进行额外的检查,例如检测不安全的生命周期、过时的 API 以及意外的副作用 。包裹<App />有助于我们在开发阶段提前发现问题。createRoot:React 18 引入的新 API,替代了旧版的ReactDOM.render。它启用并发特性,为后续使用 Suspense、Transitions 等打下基础。document.getElementById('root'):挂载点,对应index.html中的div#root。.render(...):将 React 元素树渲染到真实 DOM 中。整个应用从这里启动。
tips:
StrictMode会让组件函数体、初始化函数等执行两次,所以在开发时会发现useEffect运行两次,这是刻意设计的,用于暴露副作用问题。
核心:App.jsx ------ 状态管理与业务逻辑
根组件是整个应用的"大脑",负责持有状态、定义修改方法、计算派生数据,以及处理副作用。
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() {
// 1. 状态初始化
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
})
// ...
}
4.1 状态初始化:惰性读取 localStorage
useState 传入了一个函数,而不是直接传值。这是 惰性初始化(Lazy Initial State) :该函数只在组件首次渲染时执行一次。如果直接传值,比如 useState(JSON.parse(localStorage.getItem('todos')) || []),每次渲染都会执行 localStorage.getItem 和 JSON.parse,即使其结果已被忽略,造成不必要的性能开销。惰性初始化避免了重复读取,是应对从外部存储恢复状态的标准写法。
当本地存储中没有 todos 时返回空数组 [],否则解析出已有的待办列表。这样用户刷新页面后数据不会丢失。
4.2 操作方法:不可变更新
所有修改方法都遵循 不可变数据(Immutable) 原则,不直接修改原数组,而是返回一个新数组:
jsx
const addTodo = (text) => {
setTodos([...todos, {
id: Date.now(),
text,
completed: false,
}])
}
- 使用展开运算符
...todos创建新数组,再附加一个新对象。id用时间戳生成,保证唯一性;completed初始为false。 - 优点:React 通过引用比较来判断状态是否变化,不可变更新确保每次调用都会触发重新渲染。
jsx
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
filter返回一个新数组,剔除指定id的项,实现删除。
jsx
const toggleTodo = (id) => {
setTodos(todos.map(todo => todo.id === id ? {
...todo,
completed: !todo.completed,
} : todo))
}
map遍历数组,找到匹配id的 todo,用对象展开...todo复制其余属性,并翻转completed状态。未匹配的项原样返回。
jsx
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed))
}
- 清除所有已完成项,同样通过
filter返回新数组。
4.3 派生状态与副作用
jsx
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
- 这两个变量并非
state,而是派生状态(Derived State) :它们完全由todos计算得出,无需额外维护。每当todos变化,函数组件重新执行,这两个值会自动更新。这避免了数据冗余和同步问题。
jsx
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos])
- 副作用处理 :当
todos变化时,将其序列化后存入localStorage。依赖数组[todos]保证仅在todos引用改变时执行,避免无限循环。注意:useEffect会在 DOM 更新后异步执行,不会阻塞渲染,因此不会影响交互流畅度。
4.4 组合视图
jsx
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>
)
- 通过
props向子组件传递数据 (todos、total等)和修改方法 (onAdd、onDelete等)。这些修改方法就是"自定义事件",子组件调用时相当于向父组件发送了操作请求。 - 这种设计保持了组件的纯净性:子组件只负责 UI 和触发行为,不关心状态如何存储与变更,实现了高内聚低耦合。
子组件解析
5.1 TodoInput:受控组件与表单提交
jsx
import { useState } from 'react'
const TodoInput = (props) => {
const { onAdd } = props;
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>
)
}
逐行解析:
const [inputValue, setInputValue] = useState(''):自有状态,管理输入框的文字。这里采用受控组件(Controlled Component) 模式:value由 React 状态决定,onChange更新状态,输入框的视图始终与状态同步。相对于 Vue 的v-model双向绑定,React 通过"值 + onChange"的组合实现单向数据流,性能与可预测性更好。handleSubmit:阻止表单默认提交行为(避免页面刷新),调用父组件传入的onAdd回调,将当前文本传递给App进行添加,然后清空输入框。清空动作由本地setInputValue完成,体现了局部状态的自治。- 子→父通信 :
onAdd(inputValue)就是子组件向父组件传递数据的唯一途径。
5.2 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>
)
}
逐行解析:
- 从
props解构出todos(数据)、onDelete、onToggle(操作回调)。 - 条件渲染 :当
todos.length === 0时显示空状态提示,否则渲染列表。空状态处理提升了用户体验。 - 列表渲染 :用
map遍历todos,给每个<li>设置唯一key(这里使用todo.id),这是 React 虚拟 DOM Diff 算法优化重排的基础。 className={todo.completed ? 'completed' : ''}动态绑定样式,通过样式类名展示完成/未完成状态。- 复选框 :使用受控组件模式,
checked={todo.completed}由父组件状态决定,onChange触发onToggle(todo.id)通知父组件切换完成状态。注意这里没有在子组件内修改todo.completed,完全遵循单一数据流。 - 删除按钮 :
onClick={() => onDelete(todo.id)},同样通过回调将删除意图上报给父组件。
5.3 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>
)
}
逐行解析:
- 接收四个
props:total、active、completed三个统计数据,以及onClearCompleted回调。这些数据完全来自父组件计算的派生状态,体现了数据流自上而下。 - 展示统计信息,用管道符分隔,简洁明了。
{completed > 0 && (...)}:短路逻辑实现条件渲染,仅当已完成数量大于 0 时才显示"Clear Completed"按钮。避免无意义操作,UI 更清爽。- 点击按钮触发
onClearCompleted,无参数,父组件据此清除所有已完成项。
数据流总结与表格分析
整个应用严格遵循 单向数据流,形成了清晰的数据生命周期:
perl
用户操作 → 子组件调用 props 回调 → 父组件更新 state → React 重新渲染
→ 子组件接收新 props → 视图更新
同时,通过 useEffect 将状态持久化到 localStorage,实现了 数据刷新不丢失。
下面用一张表格总结各组件的职责与通信方式:
| 组件 | 职责 | 接收的 Props | 自有 State | 触发的回调(子→父) |
|---|---|---|---|---|
| App | 持有全局状态、定义修改逻辑、持久化 | 无 | todos |
无(它是顶层) |
| TodoInput | 输入新待办,提交添加 | onAdd |
inputValue |
onAdd(text) |
| TodoList | 展示待办列表,提供完成/删除交互 | todos, onToggle, onDelete |
无 | onToggle(id), onDelete(id) |
| TodoStats | 显示统计信息,提供批量清除入口 | total, active, completed, onClearCompleted |
无 | onClearCompleted() |
关键设计要点:
- 状态提升 :
todos是唯一数据源,放在公共祖先App中,避免多组件状态不一致。 - 兄弟组件解耦 :
TodoInput添加事项后,无需直接通知TodoList或TodoStats;只因todos变化,这些组件通过接收新props自动更新。 - 不可变更新:所有状态更新都使用新数组,保证 React 能够正确检测变化并触发渲染。
- 受控组件 :
TodoInput的文本输入与TodoList的复选框都受 React 状态控制,杜绝 DOM 直接操作。 - 惰性初始化与副作用 :
useState的函数初始器避免重复读取存储,useEffect负责同步外部系统。
一些总结
-
性能优化 :如果
todos数量很大,可以在TodoList中使用React.memo包裹,避免无关 props 变化导致的重渲染。另外,可以用useCallback包裹回调函数,防止因函数引用变化导致子组件不必要的更新。 -
唯一 ID 生成 :当前使用
Date.now()在高并发快速添加时可能产生重复。在生产环境中可以改用crypto.randomUUID()或成熟库(如 nanoid)。 -
类型安全 :加入 TypeScript,为
todos和props定义接口,能大幅减少拼写错误并提升可维护性。 -
状态管理扩展 :若应用规模扩大,可以考虑使用
useReducer重构App的状态逻辑,将操作集中在 reducer 中,更便于测试和跟踪状态变化;或者引入 Context API 避免深层 props 传递(prop drilling),但小型 Todo 应用目前的模式已足够清晰。 -
自定义 Hook :可以将
useState+useEffect的持久化逻辑封装成useLocalStorageState自定义 Hook,提高复用性。
结语
通过这个 React Todo 应用,我们深入剖析了 组件化设计、状态提升、单向数据流、受控组件以及本地持久化 的核心实践。源码虽然精简,却覆盖了 React 开发中绝大部分的思维范式。掌握这些模式后,无论是构建表单系统、管理后台还是复杂交互页面,都能游刃有余。