前言
作为一个前端学习者,总有一个绕不开的练手项目------Todo List(待办事项) 。看似简单,实际上它把 React 最核心的几个概念全部串了起来:组件化、响应式状态、不可变更新、父子通信。
本文从一个零基础的视角出发,一步步拆解我是怎么写出这个 todos 项目的,文章中会覆盖:
- 组件树的规划思路
useState的用法和"懒执行"- JSX 的本质
- Props 单向数据流
- 父子组件通信的完整流程
map/filter/ 展开运算符在 React 状态更新中的运用- 受控组件与非受控组件
读完你会收获一个完整的 React 心智模型,而不只是"能写出代码"。
一、先有思路,再写代码------组件树的规划
拿到需求后,不要急着写代码。先用组件化的视角把页面拆解一下:
css
App(父组件,总管一切)
├── TodoInput → 输入框 + 添加按钮
├── Todolist → 待办列表展示
│ └── li × N → 每条待办(checkbox + 文字 + 删除按钮)
└── TodoStats → 统计信息 + 清除已完成按钮
对应的目录结构:
bash
src/
├── App.jsx # 父组件:持有数据状态,定义增删改逻辑
├── App.css # 全局样式
├── main.jsx # 入口文件,挂载到 DOM
└── components/
├── TodoInput.jsx # 表单交互组件
├── Todolist.jsx # 列表渲染组件
└── TodoStats.jsx # 统计展示组件
一个关键原则:数据状态由父组件管理,子组件们共享统一的数据状态。这意味着:
- 子组件不持有业务数据
- 子组件通过 props 接收数据
- 子组件想修改数据,只能通过回调函数通知父组件
这个原则贯穿整个项目,也是 React 单向数据流的核心。
二、从数据开始------App 父组件
所有业务数据都存在 App 里,用 useState 管理:
jsx
import { useState } from 'react'
const App = () => {
const [todos, setTodos] = useState([
{ id: 1, text: '学习react', completed: false },
{ id: 2, text: '睡觉', completed: false },
{ id: 3, text: '打豆豆', completed: true },
])
// ...
}
2.1 理解 useState
useState 是 React 最基础的 Hook,它做三件事:
- 声明一个状态变量 ---
todos,初始值是那个数组 - 给你一个更新函数 ---
setTodos,调用它来改变状态 - 触发重新渲染 --- 每次调用
setTodos,React 会用新值重新渲染组件
js
const [值, 更新函数] = useState(初始值)
什么是懒执行?
如果初始值很昂贵(比如复杂计算),你可以传一个函数给
useState:
jsconst [data, setData] = useState(() => 昂贵的初始计算())这个函数只会在组件首次创建时执行一次,后续重新渲染不会再次执行------这就是"懒执行"。我们这里初始值就是一个简单数组,直接传即可。
2.2 数据怎么组织?
每条 todo 是一个对象:
js
{ id: 数字, text: '事项描述', completed: 布尔值 }
用数组包起来,就是 todos。这就是平时说的"对象数组"------不是 JSON 字符串,JSON 是用来传输/存储的文本格式,代码里它就是普通的 JS 数组和对象。
三、新增一条 todo------不可变更新入门
jsx
const addTodo = (text) => {
if (text.trim() === '') return // 过滤空白输入
setTodos([
{
id: Date.now(), // 用时间戳生成唯一 id
text, // 简写,等同于 text: text
completed: false
},
...todos, // 把旧数据展开
])
}
3.1 为什么用 [] 包起来?
todos 本身是一个数组 ,setTodos 需要接收一个新数组来替换旧状态。所以:
js
setTodos([ ...todos, 新项 ])
// ↑ ↑
// 创建新数组 末尾加一条
3.2 为什么不能用 push?
js
// ❌ 错误写法
todos.push(新项)
setTodos(todos)
// React:数组引用没变 → 不触发更新 → 页面没变化
React 判断状态是否变化靠的是引用对比 (===)。push 修改的是原数组,引用不变。而 [...todos, 新项] 创建了一个全新的数组,引用变了,React 就知道该重新渲染了。
这是 React 不可变更新的第一条铁律:数组更新,用新数组包;对象更新,用新对象包。
js
// 数组
setTodos([...todos, newItem])
// 对象
setUser({ ...user, name: '新名字' })
3.3 text.trim() 是干嘛的?
去除首尾空格,防止用户输入一堆空格就提交。' '.trim() 结果是 '',判空后直接 return 不添加。
3.4 { text } 是什么?
这是 ES6 的对象属性简写。{ text } 等价于 { text: text },当属性名和变量名一样时就能简写。
四、切换完成状态------map 与三元表达式
jsx
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
))
}
逐层拆解:
| 代码 | 作用 |
|---|---|
todos.map(todo => ...) |
遍历数组,返回一个新数组 |
todo.id === id ? ... : todo |
找到匹配的那条就改,否则原样不动 |
{ ...todo, completed: !todo.completed } |
展开旧属性,翻转 completed |
所以这段代码的逻辑是:遍历 → 找到 id 匹配的那条 → 把它的 completed 翻过来 → 其他不动 。map 本身返回新数组,直接交给 setTodos 就行了。
!todo.completed--- 取反。true变false,false变true,就是勾选 ↔ 取消勾选的切换。
五、删除一条------filter 保留符合条件
jsx
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
filter 保留符合条件 的元素。这里条件是 todo.id !== id,意思是"id 不等于要删的那个"。
所以结果就是:除了被删那条,其他全保留。
同样的三条:todos = [{id:1}, {id:2}, {id:3}],调用 deleteTodo(2),剩下 [{id:1}, {id:3}]。
六、清除已完成------filter 取反条件
jsx
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed))
}
只保留 completed 为 false 的,即 !todo.completed 为 true 的。等价于说:"只留下未完成的"。
七、派生数据------不算 state,实时算就行
jsx
const activeCount = todos.filter(todo => !todo.completed).length
const completedCount = todos.length - activeCount
这两行不是 useState,就是普通变量。它们在每次渲染时重新计算,不必单独维护一个 state。
一个判断标准:如果一个值可以从已有的 state 推导出来,就不要用新的 state 存它。否则你会面临"两个 state 不同步"的 bug。
八、JSX------那些看起来像 HTML 的东西
我们写的这些标签叫 JSX:
jsx
return (
<div>
<h1>Todo List</h1>
<Todoinput onAdd={addTodo} />
<Todolist todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
<TodoStats ... />
</div>
)
JSX 不是 HTML,是 JavaScript 的语法糖 。它会被编译成 React.createElement() 调用,最终产出真实的 DOM 节点。
在 JSX 里:
{}里可以写任意 JS 表达式- 自定义组件必须以大写字母 开头(
<Todoinput />,不是<todoinput />) - 原生 HTML 属性变成了驼峰命名:
className代替class,onClick代替onclick
关于 return 后面的括号 () ------ 它是为了让 JSX 可以换行写 。不加括号的话,return 后面的 JSX 必须和 return 在同一行,否则 JS 会在 return 后自动插入分号。
九、父子组件通信------整个项目的核心
9.1 基本原则
数据向下流动(props),事件向上冒泡(回调函数)
ini
┌──────────────────────────────────────────┐
│ App(父组件) │
│ · 持有 todos state │
│ · 定义 addTodo / toggleTodo / deleteTodo │
│ │
│ ┌─────────────────┐ │
│ │ props ↓ ↑ 回调 │ │
│ │ TodoInput │ onAdd={addTodo} │
│ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ props ↓ ↑ 回调 │ │
│ │ Todolist │ todos={todos} │
│ │ │ onToggle={...} │
│ │ │ onDelete={...} │
│ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ props ↓ ↑ 回调 │ │
│ │ TodoStats │ total={...} │
│ └─────────────────┘ │
└──────────────────────────────────────────┘
9.2 Props 命名约定
jsx
<Todoinput onAdd={addTodo} />
// ↑prop名 ↑父组件的函数
onAdd--- prop 的名字,on开头表示"当......的时候",类似原生onClick。子组件通过props.onAdd调用它addTodo--- 父组件里定义的,真正干活的那个函数
两边名字可以不一样,比如 onSubmit={handleAdd},但保持一致更直观。
9.3 子组件不能直接改父组件的数据
React 的数据流是单向的:props 只读。子组件想修改数据,必须:
- 调用父组件传下来的回调函数
- 父组件在回调里
setTodos修改状态 - React 发现状态变了,重新渲染,新的 props 自动流回子组件
用一句话记:子组件报告意图,父组件执行修改,React 负责同步。
十、TodoInput------受控组件实战
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} // 值由 state 控制
onChange={(e) => setInputValue(e.target.value)} // 每次输入更新 state
placeholder="What needs to be done?"
autoFocus
/>
<button type="submit">Add</button>
</form>
)
}
10.1 受控组件
这里 input 的 value 由 React state (inputValue) 控制,而不是 DOM 自己管理------这就是受控组件。
perl
用户输入 → onChange → setInputValue → state 更新 → value 变化 → 输入框显示新值
每一步都在 React 的掌控中,数据流向清晰可追踪。
10.2 e.preventDefault()
表单里按回车,浏览器默认会刷新页面 。但这里的"提交"只是想往列表里加一条数据,不需要刷新。e.preventDefault() 就是拦截这个默认行为。
10.3 两个 useState 的区别
注意这个项目里有两个 useState:
| 位置 | state | 作用域 | 用途 |
|---|---|---|---|
| App | todos |
业务数据 | 整个应用共享 |
| TodoInput | inputValue |
表单 UI | 仅输入框自己用 |
todos 是"业务状态",由父组件持有。inputValue 是"UI 状态",仅输入框自己关心,放在 TodoInput 内部就行。
这是一个很好的判断标准:这个状态其他组件关心吗?关心 → 放在父组件;不关心 → 放在自己内部。
10.4 key 的作用
在 Todolist 中:<li key={todo.id}>,key 是 React 给列表里每个元素分的"身份证"。React 靠 key 来判断哪些项变了、删了、加了,从而高效更新 DOM。永远不要用数组索引当 key(除非列表是纯静态的),用唯一 id。
十一、Todolist------纯展示 + 转发事件
jsx
const Todolist = ({ todos, onToggle, onDelete }) => {
return (
<ul className="todo-list">
{todos.length === 0 ? (
<li className="empty">No todos yet!</li>
) : (
todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)} // 转发:点我切换这条
/>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>Delete</button> {/* 转发:点我删这条 */}
</li>
))
)}
</ul>
)
}
这个组件本身没有任何状态,纯纯地接收 props、渲染列表、把点击事件转发回去。这种组件叫"展示组件"(Presentational Component),职责单一,最容易写也最容易调试。
completed 类名配合 CSS 实现删除线效果:
css
.completed {
text-decoration: line-through;
}
十二、TodoStats------条件渲染
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}>Clear Completed</button>
)}
</div>
)
}
{completed > 0 && <button .../>} 是 React 里最常用的条件渲染写法:
js
条件 && <要渲染的内容>
// true && <div> → 渲染 <div>
// false && <div> → 什么都不渲染
利用了 JS 的短路求值 :false && anything 直接短路返回 false,React 不会渲染 false / null / undefined。
十三、凑成整体------入口文件 main.jsx
jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
createRoot 是 React 18+ 的新写法,把整个应用挂载到 index.html 里的 <div id="root"> 上。
十四、完整的心智模型
把整个流程串起来:
scss
1. App 初始化 → useState 设置初始 todos → 渲染三个子组件
↓
2. 用户输入 "学 React" 按回车
→ TodoInput.handleSubmit → e.preventDefault() 阻止刷新
→ onAdd("学 React") → App.addTodo 被调用
→ setTodos([新对象, ...旧数组]) → todos 更新
→ React 重新渲染 App、Todolist、TodoStats
↓
3. 用户点击某个 checkbox
→ onChange → onToggle(id) → App.toggleTodo 被调用
→ setTodos(map 出翻转 completed 的新数组) → 重新渲染
↓
4. 用户点击某个 Delete
→ onClick → onDelete(id) → App.deleteTodo 被调用
→ setTodos(filter 掉那条) → 重新渲染
每一步都是:子组件报告 → 父组件 setState → React 重新渲染 → 新 props 自动流回去。
十五、常见错误速查
| 错误 | 原因 | 正确写法 |
|---|---|---|
todos.push(x); setTodos(todos) |
原地修改,引用不变 | setTodos([...todos, x]) |
export default App() |
调用了组件,导出的是返回值 | export default App |
todo.completed = true; setTodos(todos) |
直接修改对象属性 | setTodos(todos.map(...)) |
key 用索引 key={i} |
数组顺序变化时出 bug | 用唯一 id key={todo.id} |
| 子组件改 props | React 不允许 | 通过回调通知父组件改 |
十六、进阶思考:这些概念的关系
markdown
组件化 → 拆页面成小组件 → 需要组件间通信 → Props 单向数据流
↓
useState 管理每个组件的状态
↓
修改状态必须不可变(新引用)
↓
React 自动对比引用 → 自动重新渲染
这不是几个孤立的 API,而是一套完整的数据驱动视图范式:你只管修改数据,React 负责把界面同步到最新。
总结
一个 Todo List,涵盖的知识点:
- ✅ 组件化思维 --- 拆页面为组件树
- ✅ useState --- 管理组件状态
- ✅ 不可变更新 --- 展开运算符
...+ 新数组/新对象 - ✅ map / filter --- 遍历变换、条件过滤
- ✅ 父子通信 --- props 向下,回调向上
- ✅ 受控组件 --- value + onChange 绑定
- ✅ 条件渲染 ---
&&短路求值 - ✅ 事件处理 ---
onClick、onSubmit、preventDefault
这些东西不是靠背能学会的,敲一遍就全懂了。把握住"数据驱动视图"这个中心思想,其他的都是具体实现细节。
项目源码 :
hjf-ai/fe/react/basic/todos/技术栈:React 18 + Vite + JSX