本文通过一个完整的 Todo 应用项目,由浅入深地解析 React 的核心概念——组件化、响应式、Hooks、组件通信以及前端本地存储的工程化实践。
目录
1. 引言:一个 Todo 应用涵盖的 React 知识体系
Todo 应用是前端学习中的经典项目——看似简单,实则涵盖了 React 生态中最核心的知识体系。本项目基于 Vite + React 19 构建,以纯 JavaScript(非 TypeScript)实现,聚焦于 React 本身的机制与思想,帮助开发者在无类型系统干扰的情况下,深入理解 React 的运行原理。
项目的 README 将知识点梳理为三条主线:
- React 知识:组件化、响应式、数据驱动、Hooks(useState / useEffect)、JSX、Fragment、组件通信;
- 开发流程:组件思想 → 组件树规划 → 父子通信设计 → 状态管理布局;
- 本地存储:localStorage 的读写操作,以及与 useEffect 结合实现自动持久化。
下面,我们沿着这三条主线,结合项目源码逐层展开。
2. React 基础核心知识
2.1 组件化:一切皆组件
React 的核心哲学是 组件化——将界面拆分为独立、可复用的组件,每个组件负责一个明确的功能区域。本项目的组件结构如下:
App(父组件,状态持有者)
├── TodoStats 统计栏:展示总数、活跃数、已完成数
├── TodoList 列表:渲染每一条待办事项
└── TodoInput 输入框:新增待办事项的表单
每个组件是一个独立的 .jsx 文件,拥有自己的 JSX 结构、样式引用和导出声明。这种拆分方式带来的好处是显而易见的:
- 单一职责:
TodoStats只负责展示统计数字,不关心数据来源;TodoInput只负责收集用户输入,不关心数据最终如何渲染; - 可测试性:每个组件可以独立测试,输入确定的 props,预期确定的渲染结果;
- 可维护性:当统计栏需要增加"已完成百分比"时,只需修改
TodoStats一个文件,不会波及其他组件。
2.2 响应式与数据驱动
React 是 响应式 框架——当数据(state)发生变化时,界面会自动重新渲染,开发者无需手动操作 DOM。这就是 数据驱动视图 的核心理念。
在 App.jsx 中,todos 数组是整个应用的数据核心:
const [todos, setTodos] = useState([{
id: 1,
text: '做饭',
completed: false
}, {
id: 2,
text: '学习',
completed: false
}, {
id: 3,
text: '打豆豆',
completed: false
}]);
当 setTodos 被调用时(新增、删除、切换完成状态),React 会自动对比新旧状态,计算出最小的 DOM 修改量并高效更新界面。开发者只需要关注"数据是什么",而非"如何修改 DOM"。
衍生状态是数据驱动思想的进一步体现——某些值不需要单独存储,而是从已有状态计算得出:
const activeCount = todos.filter(t => !t.completed).length;
const completedCount = todos.length - activeCount;
activeCount 和 completedCount 不是 useState 声明的独立状态,而是每次渲染时从 todos 实时计算出来的。这保证了它们与 todos 永远保持一致,不会出现"数据改了但统计没变"的 bug——这就是单一数据源的优势。
2.3 JSX 与 Fragment
JSX 是 JavaScript 的语法扩展,允许在 JS 代码中书写类似 HTML 的标记。它本质上是对 React.createElement() 的语法糖,经过 Babel/Vite 编译后生成标准的 JavaScript 对象。
TodoList.jsx 中,JSX 与 JavaScript 逻辑无缝交织:
<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)}>删除</button>
</li>
))
)
}
</ul>
这段代码展示了 JSX 的几个关键特性:
- 表达式嵌入:
{}内可以写任意 JavaScript 表达式——三元运算符、数组 map、函数调用等; - 条件渲染:
todos.length === 0 ? ... : ...实现了空状态的 UI 分支; - 列表渲染:
todos.map(...)将数据数组映射为 JSX 元素数组,每个元素需要唯一的key属性来帮助 React 高效 diff; - 事件绑定:
onChange、onClick直接绑定处理函数,遵循驼峰命名(而非 HTML 的onchange); - className:使用
className而非class,因为class是 JavaScript 的保留字。
Fragment(片段)是另一个实用的 React 概念——当组件需要返回多个兄弟元素,但又不想引入额外的 DOM 节点时,可以用 <></> 或 <React.Fragment> 包裹。虽然本项目未显式使用 Fragment,但理解其存在对组件开发很有价值。
3. Hooks 深度解析
3.1 useState:状态管理基石
useState 是 React 中最基础、最常用的 Hook,用于在函数组件中声明响应式状态。项目中几乎所有可变数据都由 useState 管理。
基本用法:
// App.jsx —— 对象数组状态
const [todos, setTodos] = useState([{ id: 1, text: '做饭', completed: false }]);
// TodoInput.jsx —— 字符串状态
const [inputValue, setInputValue] = useState('');
不可变更新模式是理解 useState 的关键。React 要求状态更新必须是全新的对象引用,而非修改原对象:
// ✅ 正确:创建新数组,新对象排在最前面
const addTodo = (text) => {
if (text.trim() === '') return;
setTodos([
{ id: +Date.now(), text, completed: false },
...todos
]);
};
// ✅ 正确:map 返回新数组,只替换匹配项
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
));
};
// ✅ 正确:filter 返回新数组(过滤掉目标项)
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
三种操作对应三种不可变模式:展开运算符 + 新元素、map + 展开、filter。注意,这些方法始终返回新数组,原数组不会被修改——这是 React 正确触发重渲染的前提。
懒执行是 README 特别提到的一个细节。useState 支持传入一个函数作为初始值计算器:
// 普通写法:每次渲染都执行 expensiveComputation()
const [data, setData] = useState(expensiveComputation());
// 懒执行:只在首次渲染时执行一次
const [data, setData] = useState(() => expensiveComputation());
当初始值的计算成本较高时(如从 localStorage 读取并解析 JSON),使用函数形式可以避免每次渲染重复计算,提升性能。项目中 useState(localStorage.getItem('key') || defaultValue) 就是一个典型的适用场景。
3.2 useEffect:副作用与生命周期
useEffect 是 React 管理"副作用"的入口。所谓副作用,是指在组件渲染之外的任何操作——包括网络请求、DOM 操作、定时器、以及本例中的 localStorage 写入。
项目在 App.jsx 中有意写入了四个 useEffect 示例,用于展示不同依赖数组下的行为差异:
// 一:依赖 [count] —— 挂载后执行,且 count 每次变化后也执行
useEffect(() => {
console.log('挂载后执行');
console.log('count 更新后执行');
}, [count]);
// 二:依赖 [] —— 只在挂载后执行一次
useEffect(() => {
console.log('只会在挂载后执行');
}, []);
// 三:依赖 [todos] —— 挂载后执行,且 todos 每次变化后也执行
// 核心功能:将 todos 自动同步到 localStorage
useEffect(() => {
console.log('挂载后执行');
console.log('todos 更新后执行');
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
// 四:不传第二个参数 —— 挂载后执行,且每次更新都执行(很少使用)
useEffect(() => {
console.log('只会在挂载后执行');
console.log('每次更新都执行');
});
这四个例子分别对应了 useEffect 的四类生命周期行为:
| 依赖数组 | 执行时机 | 对应类组件生命周期 | 典型场景 |
|---|---|---|---|
[dep] | 挂载后 + 依赖项变化后 | componentDidMount + componentDidUpdate(条件) | 数据变更后同步到存储、重新请求接口 |
[] | 仅挂载后 | componentDidMount | 初始化请求数据、注册全局事件监听 |
| 不传 | 挂载后 + 每次更新后 | componentDidMount + componentDidUpdate | 极少使用,性能风险大 |
| 返回清理函数 | 卸载前 / 依赖变化前 | componentWillUnmount | 清除定时器、取消订阅、关闭连接 |
README 中"副作用"一词的含义在此得以印证:渲染副作用不是"负面影响",而是"伴随渲染产生的附带操作"。把 todos 存到 localStorage 并不直接影响界面,但它是应用完整功能链中必不可少的一环——这就是副作用的典型场景。
useEffect 的执行时序也值得注意:React 会在浏览器完成 DOM 更新后,才异步执行 useEffect 的回调。这意味着副作用不会阻塞页面渲染——用户始终先看到界面,后发生副作用的处理逻辑。
4. 组件通信与单向数据流
4.1 核心原则:子组件不能直接修改父组件状态
React 的数据流是严格单向的:数据从父组件通过 props 流向子组件,子组件不能直接修改父组件的状态。如果子组件需要触发数据变更,必须通过父组件传入的回调函数来"通知"父组件,由父组件自己执行修改。
这条原则在 README 中被反复强调:
子组件不可以直接修改父组件的数据状态的,只能通过自定义事件通知父组件,父组件修改后,子组件自动更新。
这是 React 保证数据一致性的核心机制——状态始终由持有它的组件负责修改,其他组件只能"申请修改"。
4.2 三大子组件的通信实践
整个应用的状态管理围绕着 todos 数组展开,父组件 App 是数据的唯一持有者,三个子组件各司其职:
(1)TodoStats —— 纯数据展示 + 附带操作
// 父组件传入:
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
// TodoStats 内部:
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>
);
};
TodoStats 是一个典型的展示型组件:它自身没有状态,所有数据通过 props 传入。它唯一触发的操作是"清除已完成"——通过调用父组件传入的 onClearCompleted 回调,将意图告知父组件,由父组件执行实际的 setTodos 操作。
(2)TodoList —— 列表渲染 + 双回调
// 父组件传入:
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
// TodoList 内部:遍历渲染,每个 item 绑定两个操作
{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>
))}
TodoList 是典型的列表容器组件。它接收完整的 todos 数组负责渲染,但修改操作(切换完成、删除)都必须通过 onToggle 和 onDelete 回调返回父组件。注意回调传递的是 todo.id,而非整个 todo 对象——接口越精简,耦合度越低。
(3)TodoInput —— 私有状态 + 提交通知
// 表单交互组件
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?"
autoFocus
/>
<button type="submit">Add</button>
</form>
);
};
TodoInput 的设计值得仔细分析——它是唯一拥有私有状态的子组件:
inputValue是输入框的临时文本,它不需要被其他组件共享,因此放在子组件内部是最合适的选择;- 当表单提交时,
handleSubmit调用e.preventDefault()阻止页面刷新,然后通过onAdd(inputValue)将文本传给父组件,最后清空输入框; - 这种"内部管理临时状态,提交时通知父组件"的模式,在不需要实时共享编辑状态时是最佳实践,因为它将输入框的交互细节封装在了组件内部。
对比前文中 TypeScript 项目的三版演进,这里的 TodoInput 正对应了"第二版:子组件私有状态自治"的架构选择。
通信示意图:
┌─────────────────────────────┐
│ App(父组件) │
│ │
│ useState(todos) │
│ addTodo / toggleTodo │
│ deleteTodo / clearCompleted │
│ activeCount / completedCount │
│ │
└──┬──────────┬──────────┬─────┘
props + 回调 │ props │ props │ props + 回调
┌──────────────────┴┐ ┌───────┴──────┐ ┌───┴──────────────┐
│ TodoInput │ │ TodoList │ │ TodoStats │
│ │ │ │ │ │
│ useState(input) │ │ 展示列表 │ │ 展示统计数字 │
│ 提交 → onAdd() │ │ 切换 → onToggle()│ 清除 → onClear()│
└───────────────────┘ └──────────────┘ └──────────────────┘
4.3 组件树:架构规划先行
README 将"规划组件树"提升为开发流程的第二步,位于"组件思想"之后、实际编码之前。这不是偶然的——在写代码之前先画好组件结构,能够显著减少后期的重构成本,对 Vibe Coding(AI 辅助编程)尤其重要:清晰的结构描述能帮助 AI 更准确地理解意图。
本项目的组件树简洁而清晰:
App
├── TodoStats —— 统计信息栏
├── TodoList —— 待办列表(依赖 todos/onToggle/onDelete)
└── TodoInput —— 输入表单(依赖 onAdd)
职责分配原则:
- App:唯一的数据持有者和操作者,负责所有业务逻辑;
- 子组件:纯粹的视图层,通过 props 接收数据,通过回调发出修改请求。
5. 前端本地存储实践
5.1 localStorage 基础操作
浏览器提供了多种客户端存储方案,本项目涉及两种:
| 存储方案 | 容量 | 数据格式 | 适用场景 |
|---|---|---|---|
| localStorage | 约 5MB | key-value 字符串 | 配置信息、关键数据、用户偏好 |
| IndexedDB | 远大于 5MB | 结构化对象 | 大量数据、复杂查询、离线应用 |
本项目使用 localStorage 持久化 todos 数据,核心操作只有两个 API:
// 写入:将 JavaScript 对象转为 JSON 字符串后存储
localStorage.setItem('todos', JSON.stringify(todos));
// 读取:取出字符串后解析为 JavaScript 对象
const savedTodos = JSON.parse(localStorage.getItem('todos'));
JSON.stringify 和 JSON.parse 是 localStorage 使用中必不可少的一对搭档——localStorage 只能存储字符串,任何对象都需要序列化。
一个值得注意的细节是:localStorage.getItem 在 key 不存在时返回 null,而 JSON.parse(null) 也返回 null,因此项目中可以安全地使用 JSON.parse(localStorage.getItem('todos')) 而无需额外的 null 检查。但在生产实践中,建议提供一个回退默认值:
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : defaultTodos;
});
这里同时运用了 useState 的懒执行特性——初始化函数只在组件首次挂载时执行一次,避免了每次渲染都读取 localStorage 的性能浪费。
5.2 useEffect + localStorage:自动持久化
读取只是初始化的一环,自动写入才是持久化的灵魂。项目通过 useEffect 对 todos 的依赖监听,实现了"数据变化即保存"的自动同步:
useEffect(() => {
console.log('挂载后执行');
console.log('todos 更新后执行');
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
工作流程:
- 组件挂载时:
useState从 localStorage 读取初始数据; - 用户操作时:
addTodo/toggleTodo/deleteTodo/clearCompleted等函数调用setTodos,更新内存中的状态; - React 重渲染后:
useEffect检测到[todos]依赖项已变化,执行回调,将最新数据写入 localStorage; - 下次访问时:步骤 1 读取到上次保存的数据,实现"刷新不丢失"。
这种模式无需在每次 setTodos 调用后手动写 localStorage.setItem——响应式机制保证了一处状态变更,自动触发持久化,既简洁又不容易遗漏。
useEffect 在此处的第二个参数 [todos] 也非常关键:如果写成 [count],则只在 count 变化时存储,todos 变化不会触发;如果写成 [],则只在挂载时执行一次,后续的数据变更不再持久化;如果不传,则每次渲染都写入(包括那些与 todos 无关的渲染),这会导致不必要的 I/O 开销。选择合适的依赖数组,既是正确性的保证,也是性能优化的重要手段。
6. 开发流程与组件化思维
README 将开发流程总结为一条清晰的路线:
组件思想 → 规划组件树 → 父子通信设计 → 编码实现
这个流程的核心价值在于 "先设计,后编码":
- 组件思想:接受"界面 = 组件树"的世界观,将页面看作组件的嵌套组合;
- 规划组件树:画出目录结构和组件嵌套关系——本项目中
src/compontents/目录下三个文件各对应一个子组件,结构一目了然; - 父子通信设计:确定哪些状态放在父组件(共享状态),哪些放在子组件(私有状态),以及通过哪些回调函数连接两端;
- 编码实现:在前三步的基础上编写代码,此时每个文件的职责已经明确,编码过程变成"填充细节"而非"边写边想"。
这四步流程对 Vibe Coding(AI 辅助编程)尤其有价值。在与 AI 协作时,如果给出一个结构清晰的设计方案("我需要三个子组件,分别负责统计、列表和输入,状态由父组件统一管理"),AI 能够一次性生成高质量的代码;而如果需求模糊,AI 生成的代码往往需要多轮迭代修正。清晰的规划是高效协作的前提。
7. 项目工程化概览
本项目使用 Vite 作为构建工具,配置极为简洁:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});
与 TypeScript 项目不同,这是一个纯 JavaScript 项目,不需要 tsconfig 和类型声明文件。这在小型项目、快速原型和教学场景中带来了更低的心智负担——开发者可以专注于 React 本身,而不用分心处理类型系统。
package.json 脚本:
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
}
build 命令中只有 vite build,没有 TypeScript 项目的 tsc -b 类型检查步骤——JS 项目无需编译类型,可以直接打包。这使得开发反馈循环更短,适合快速迭代和原型验证。
入口文件 main.jsx 中的注释:
createRoot(document.getElementById('root')).render(
// 做一次执行,再做一次测试 同样的数据,组件一摸一样
<App />
);
这段注释指向了 React 18+ 的 StrictMode 行为——在开发模式下,React 会故意执行两次渲染来检测副作用是否正确。如果代码中有不恰当的副作用(如在渲染函数体内修改全局变量),第二次执行会暴露问题。项目未显式使用 <StrictMode> 包裹,这是一个教学取舍:先理解核心机制,再学习严格模式的检测能力。
8. 总结
通过这个 Todo 应用的完整代码,本文梳理了 React 核心开发的完整知识链:
-
组件化是 React 的根基。将界面拆分为职责单一的组件,每个组件对应一个清晰的 UI 功能区域,既便于开发也便于维护。
-
响应式与数据驱动让开发者从 DOM 操作中解放出来。关注数据的变化,界面会自动跟随更新。衍生状态(如
activeCount)进一步减少了冗余状态,保证了数据一致性。 -
useState 是状态管理的基石。不可变更新模式(新数组、新对象)是正确触发重渲染的前提;懒执行特性可用于优化初始化性能。
-
useEffect 统一了副作用管理。通过不同依赖数组的组合,覆盖了挂载后、更新后、卸载前等场景。
useEffect + localStorage的模式实现了数据的自动持久化,简洁而高效。 -
单向数据流是 React 的数据铁律。父组件持有状态并负责修改,子组件通过 props 接收数据、通过回调发出修改请求——这条规则保证了应用状态在任何时刻都是可追溯、可预测的。
-
先设计后编码的开发流程,在 AI 辅助编程时代愈加重要。清晰的组件树规划和通信设计,是高效协作的前提。
-
localStorage + useEffect 的组合,为前端应用提供了轻量级的持久化方案——从初始化读取到变更自动写入,整个链路简洁闭环。
本文基于项目 README.md 及全部源代码文件编写,所有代码示例均提取自该项目的实际文件。