别光看教程!手把手拆解 React Todo 项目,一文吃透 5 个核心概念
教程看了不少,一写代码就卡住?这篇文章不从 API 讲起,而是带你从 0 拆解一个真实项目,把 React 最核心的 5 个概念串成一张网。
前言
学 React 最常见的困境是:看视频教程时觉得"哦,挺简单的",一关掉视频自己写,连 useState 的括号往哪放都想不起来。
问题出在哪?零散的知识点没有形成网。
这篇文章用一个只有 60 多行的 Todo 项目,帮你把 React 入门阶段 5 个核心概念串成一条完整的链路。每个概念都会讲清楚三个问题:
- 它是什么?(用生活化比喻)
- 在这个项目里怎么用的?(看真实代码)
- 底层为什么这样设计?(理解原理,才能举一反三)
读完你会收获:
- 🎯 彻底理解
useState------ 为什么需要第二参数setXxx - 📦 搞懂"父传子、子传父"的完整通信链路
- 🔄 明白
.map()/.filter()和"不可变数据"的关系 - 📋 理解"受控组件"这个面试高频考点
- 🧱 掌握"组件化思维"------拿到需求先想怎么拆
项目概览
这是一个经典的 Todo 待办事项应用,功能如下:
- 输入文字 → 添加一条待办任务
- 点击 ☑️ 勾选框 → 切换完成状态
- 点击 Remove → 删除任务
- 顶部显示 总计 / 进行中 / 已完成 数量
- 一键清除所有已完成的任务
组件树
拿到需求第一步:拆组件。这个项目的目录结构长这样:
css
src/
├── App.jsx ← 父组件,管理所有数据
├── main.jsx ← 入口文件
├── components/
│ ├── TodoInput.jsx ← 输入框 + 添加按钮
│ ├── TodoStates.jsx ← 数量统计 + 清除按钮
│ └── TodoList.jsx ← 任务列表
└── index.css / App.css ← 样式
用图表示组件关系:
scss
App (父组件 --- 大脑,持有所有数据状态)
├── TodoInput (子组件 --- 只负责"新增",不关心任务长什么样)
├── TodoStates (子组件 --- 只负责"展示统计",不关心数据怎么来)
└── TodoList (子组件 --- 只负责"渲染列表",不关心如何添加)
这就是组件化思维的核心:每个组件只做一件事,组合起来完成复杂功能。
概念一:组件化与组件树 --- 拿到需求第一步先拆
它是什么?
把页面拆成独立的、可复用的小块,每个小块就是一个组件。组件就像乐高积木------单个积木很简单,拼起来可以搭出任何东西。
这个项目怎么拆的?
一个 Todo 应用看起来简单,但如果不拆组件,所有代码塞在一个文件里:
jsx
// ❌ 不拆组件 --- 代码混在一起,改一处可能影响另一处
const App = () => {
// 输入框的逻辑
// 列表渲染的逻辑
// 统计数字的逻辑
// 删除的逻辑
// ...
return (
<div>
{/* 输入框 */}
{/* 统计 */}
{/* 列表 */}
</div>
)
}
拆了之后,App.jsx 变成"指挥官",子组件各司其职:
jsx
// ✅ 拆组件 --- App 只做协调,具体工作分派给子组件
const App = () => {
// App 只管理数据和核心逻辑
return (
<div>
<TodoInput onAdd={addTodo} />
<TodoStates total={...} active={...} completed={...} />
<TodoList todos={todos} onToggle={...} onRemove={...} />
</div>
)
}
拆组件的好处:
| 好处 | 说明 |
|---|---|
| 好维护 | 输入框出 bug → 去 TodoInput 找,不用翻几百行代码 |
| 可复用 | 同一个组件可以在不同页面用 |
| 易测试 | 每个组件独立,可以单独测试 |
| 便协作 | 不同的人开发不同的组件,互不干扰 |
概念二:useState --- 给组件装上"记忆芯片"
它是什么?
React 组件默认是"无记忆"的------每次渲染都是全新的。useState 就是给组件装上一块记忆芯片,让它在渲染之间能记住数据。
打个比方:没有 useState 的组件就像金鱼,每 7 秒重新认识一遍世界。有了 useState,它变成了大象------能记住很久以前的事。
代码中的使用
App.jsx 里最重要的一行:
jsx
const [todos, setTodos] = useState([
{ id: 1, text: '学习 React', completed: false },
{ id: 2, text: '学习 Vue', completed: false },
{ id: 3, text: '学习 Node.js', completed: true },
])
这行代码拆开看:
scss
const [todos, setTodos] = useState(初始值)
^^^^^^ ^^^^^^^^^ ^^^^^^^^^^^^
变量 修改函数 设置初始值
(只读) (唯一能改它的方法)
三个关键点:
todos是当前的数据,你可以读它(渲染列表、统计数量),但不能直接改它setTodos是唯一能修改todos的函数,调用它 → React 知道"状态变了" → 自动重渲染页面useState(...)括号里的值只在第一次渲染时生效,之后 React 用的是你的最新状态
底层逻辑:为什么不能直接改?
jsx
// ❌ 直接改 --- React 根本不知道你改了
todos.push({ id: 4, text: '新任务', completed: false })
// ✅ 通过 setTodos --- React 收到通知 → 重新渲染 → 页面更新
setTodos([...todos, { id: 4, text: '新任务', completed: false }])
React 的渲染机制是**"状态变了就重新画页面"**。直接赋值 todos = xxx 不会触发这个机制,就像你偷偷换了房间里的东西但不告诉任何人------UI 永远不会变。只有通过 setTodos,React 才能收到"状态变更通知"。
概念三:父子组件通信 --- 数据向下流,事件向上报
这是 React 里最重要的通信模式,没有之一。
核心规则(README 里的原话)
子组件是不可以直接修改父组件的数据状态的,只能通过自定义事件通知父组件,父组件修改后,子组件自动更新。
这句话就是 "状态提升(Lifting State Up)" 的精髓,拆成三步理解:
ini
第1步:数据在父组件
App 持有 todos 和所有修改方法(addTodo、toggleTodo、removeTodo)
第2步:父 → 子(数据下传,用 props)
<TodoList todos={todos} onToggle={toggleTodo} />
第3步:子 → 父(事件上报,用回调)
onClick={() => onToggle(todo.id)}
子组件调用 props 里收到的方法 → 父组件执行修改 → 自动重渲染
完整链路演示
以"勾选完成一条 todo"为例,跟踪完整的数据流:
scss
1. 用户点击 ☑️ 勾选框
│
2. TodoList:onChange={() => onToggle(todo.id)}
告诉父组件:"id=2 这条要翻转状态!"
│
3. App 的 toggleTodo(2) 被调用
setTodos(todos.map(todo =>
todo.id === 2 ? { ...todo, completed: !todo.completed } : todo
))
│
4. React 检测到 todos 变了 → 重新渲染 App
│
5. 新 todos 通过 props 流向 TodoList → 勾选框刷新 ✅
全程数据只在一个方向上流动:状态 → 渲染 → 用户操作 → 回调上报 → 修改状态 → 重新渲染。这个闭环就是 React 的核心哲学。
三种 props 传递模式
在实际代码中,父组件向子组件传的 props 分为三类:
| 类型 | 例子 | 说明 |
|---|---|---|
| 传数据 | todos={todos} |
子组件拿到后只读,用来渲染 |
| 传回调 | onAdd={addTodo} |
子组件需要"通知父组件"时调用 |
| 传计算结果 | total={todos.length} |
父组件算好,子组件直接展示 |
概念四:不可变数据 --- .map() 和 .filter() 为什么是你最好的朋友
它是什么?
React 有一条铁律:不要修改(mutate)现有的 state 对象/数组,而是用旧数据生成一份新的。
这个项目用三个方法完整展示了三种不可变更新模式:
模式 1:新增 --- [...旧数组, 新元素]
jsx
const addTodo = (text) => {
if (text.trim() === '') return // 防空白提交
setTodos([
{ id: Date.now(), text, completed: false },
...todos // 展开旧数组
])
}
每一步发生了什么:
bash
初始状态: [{id:1}, {id:2}, {id:3}]
1. { id: Date.now(), text, completed: false }
→ 新建一个 todo 对象,Date.now() 返回当前时间戳作为唯一 id
2. ...todos
→ 把旧数组的三个元素"摊开"
3. [新对象, 第1条, 第2条, 第3条]
→ 拼成新数组。新对象在最前面,所以最新的 todo 显示在最上面
4. setTodos(...)
→ 通知 React:"给你一个新数组,重新渲染吧"
模式 2:修改某一条 --- .map() + 三元 + 展开
这是最复杂的更新模式,但也是 React 开发中最常见的操作,值得仔细拆解:
jsx
const toggleTodo = (id) => {
setTodos(todos.map(todo => // ① 遍历旧数组
todo.id === id // ② 判断:当前这条是不是目标?
? { ...todo, completed: !todo.completed } // ③ 是 → 复制并翻转
: todo // ④ 否 → 原样保留
))
}
这 4 层嵌套逻辑,每一步单独看都很简单:
① .map(todo => ...)
.map() 遍历数组的每个元素,对每个元素执行回调,收集返回值形成新数组:
css
旧数组: [A, B, C]
↓ ↓ ↓ .map()
新数组: [A', B', C']
旧数组完好无损,你拿到的是一个全新的数组。
② todo.id === id --- 找到目标
假设调用 toggleTodo(2):
- 遍历到
{id:1}→1 === 2? →false - 遍历到
{id:2}→2 === 2? →true← 找到了! - 遍历到
{id:3}→3 === 2? →false
③ { ...todo, completed: !todo.completed } --- 不可变更新的王牌组合
这是 React 里最常见的"修改对象字段"写法,拆成两步:
yaml
原始 todo: { id: 2, text: '学习 Vue', completed: false }
Step 1: { ...todo }
→ { id: 2, text: '学习 Vue', completed: false } // 全量复制
Step 2: completed: !todo.completed
→ completed: !false
→ completed: true // 覆盖 completed 字段
最终: { id: 2, text: '学习 Vue', completed: true }
^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^
照抄 照抄 翻转了!
{ ...旧对象, 要改的字段: 新值 } 是 React 中最常用的模式------复制一份,只改需要改的。
④ : todo --- 不满足条件,原样返回
不是目标的 todo,不做任何处理,直接放进新数组。
模式 3:删除 --- .filter()
jsx
const removeTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
filter 的规则很简单:回调返回 true → 保留,返回 false → 丢弃。
bash
删除 id=2:
[id:1, id:2, id:3]
↓ ↓ ↓
true false true ← 只有 id=2 返回 false
↓ ↓
[id:1, id:3] ← id=2 被"过滤掉"了
小结:map vs filter
| 方法 | 作用 | 返回 | 比喻 |
|---|---|---|---|
.map() |
每条都保留,可以变形 | 和原数组一样长 | 炒菜 --- 食材都在,样子变了 |
.filter() |
符合条件的留下,其余丢弃 | 比原数组短或一样长 | 筛子 --- 大的留下,小的筛掉 |
两者都不修改原数组,返回新数组,完美符合 React 不可变数据要求。
概念五:受控组件 --- 面试必问,但理解起来很简单
它是什么?
普通 HTML 中,<input> 自己管理自己的值------你输入什么,它就显示什么,React 并不知道。
在 React 中,我们让 state 来管理 input 的值,input 变成纯粹"听指挥"的展示。这就是受控组件。
css
普通 input: 受控组件 input:
用户 ──→ input 用户 ──→ onChange ──→ setState ──→ state ──→ input value
(React 不知道值) (React 始终知道精确的值)
代码中的实现
TodoInput 里的 input 就是一个标准的受控组件:
jsx
const [inputValue, setInputValue] = useState('')
<input
value={inputValue} // ① 值由 state 控制
onChange={(e) => setInputValue(e.target.value)} // ② 输入时更新 state
/>
这个闭环长这样:
lua
初始: inputValue = "" → input 显示 ""
用户敲 "a" → onChange → setInputValue("a")
inputValue = "a" → input 显示 "a" ← 数据 = UI,完全同步
用户敲 "b" → onChange → setInputValue("ab")
inputValue = "ab" → input 显示 "ab"
为什么要用受控组件?
因为 React 需要"知道" input 的值是什么。举个例子:
jsx
const handleSubmit = (e) => {
e.preventDefault()
onAdd(inputValue) // ← 这里需要用 input 的值
setInputValue('') // ← 这里需要清空 input
}
如果 input 的值由浏览器自己管,React 就不知道用户输入了什么------handleSubmit 里拿不到 inputValue,也没法清空输入框。
受控组件的好处就是:React 始终拥有"真相的唯一来源"(Single Source of Truth)。
同样,TodoList 里的 checkbox 也是受控组件:
jsx
<input
type="checkbox"
checked={todo.completed} // 勾选状态由数据决定
onChange={() => onToggle(todo.id)} // 用户点击 → 翻转数据 → 重新渲染
/>
checkbox 自己不做主------它只是 todo.completed 这个布尔值的"显示器"。
总结:5 个概念如何连成一整张网
回顾这个 60 多行的 Todo 项目,5 个概念不是孤立的,它们互相配合:
python
① 组件化:把页面拆成 App、TodoInput、TodoStates、TodoList 四块
↓
② useState:在 App 里用 useState 管理 todos 数据("记忆芯片")
↓
③ 父子通信:通过 props 把数据传给子组件,通过回调把事件报给父组件
↓
④ 不可变更新:用 .map() / .filter() + 展开运算符 修改数组,不碰原数据
↓
⑤ 受控组件:input 的 value 和 checkbox 的 checked 由 state 驱动
这张网就是 React 入门阶段最核心的知识体系。掌握这 5 个概念,80% 的 React 入门项目你都能独立完成。
下一步可以探索的方向:
- 用
useEffect把 todos 存到 localStorage,实现数据持久化 - 用
useReducer替代多个useState,适合更复杂的状态逻辑 - 拆分更细的子组件,比如把每条
<li>抽成独立的TodoItem组件 - 用 Context API 替代 props 层层传递,理解"跨层级通信"
React 的核心从来不是记 API------理解了这几个概念,API 查文档就行。关键是思维方式的转变:你不再手动操作 DOM,而是描述"状态应该是什么样子",React 负责把状态变成界面。
你觉得这种拆解方式对你有帮助吗?欢迎在评论区交流你的学习心得 👏