引言
“在 React 的世界里,组件是岛屿,数据是海洋——而 props 和回调函数,就是连接它们的桥梁与航船。”
你是否曾困惑于:
- 子组件如何通知父组件“我被点击了”?
- 兄弟组件之间为何不能直接对话?
- 父组件如何把数据安全地分发给所有孩子?
今天,我们将通过一个真实、完整、可运行的 React Todo 应用,深入骨髓地讲解 父子通信、子父通信、兄弟组件通信 的核心机制。这篇文章将:
✅ 逐行解析每个文件中的每一个 API
✅ 清晰描绘数据在组件间的完整流动路径
✅ 揭示 React 单向数据流的设计哲学
✅ 保持原始代码一字不变,只为还原最真实的开发场景
准备好了吗?让我们启航!
第一章:项目全景图 —— 文件结构与设计哲学
文件结构一览
附项目源码链接: lesson_zp/react/todos: AI + 全栈学习仓库
架构的灵魂宣言
父组件负责持有数据,管理数据
props 传递给子组件
父组件还可以将修改数据的方法传给子组件
子组件不可以直接修改数据,只能通过父组件传递的方法来修改数据”
这段话,就是整个应用的设计宪法!它明确指出了:
- 状态集中管理:所有数据由
App持有 - 单向数据流:数据只能从父 → 子(通过 props)
- 事件反向通知:子 → 父(通过回调函数)
- 兄弟通信中介化:必须通过父组件中转
这正是 React 官方推荐的 “状态提升(Lifting State Up)” 模式。
第二章:App.jsx —— 数据的“中央控制室”与调度中心
App.jsx 是整个应用的唯一真相源(Single Source of Truth) 。它不仅持有所有状态,还提供所有修改状态的方法,并将它们分发给子组件。
逐行深度解析
import { useState, useEffect} from 'react'
useState:用于声明和管理组件内部状态(如todos列表)。useEffect:用于处理副作用(如数据持久化到localStorage)。
import './styles/app.styl'
- 引入全局样式(使用 Stylus 预处理器),与逻辑无关,但体现工程化。
import TodoList from './components/TodoList'
import TodoInput from './components/TodoInput'
import TodoStats from './components/TodoStats'
- 引入三个子组件,构成完整的 UI 结构。
状态初始化:从 localStorage 恢复数据
const [todos, setTodos] = useState(() => {
const savedTodos = localStorage.getItem('todos')
return savedTodos ? JSON.parse(savedTodos) : [];
})
- 高级用法:
useState接收一个初始化函数(而非直接值),避免每次渲染都解析 JSON。 - 持久化:首次加载时尝试从浏览器
localStorage读取已保存的待办事项。 - 安全回退:若无数据,则返回空数组
[]。 - 关键点:状态提升 —— 所有子组件依赖的
todos都集中在此。
副作用:自动同步到本地存储
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos))
}, [todos])
useEffect:当组件渲染完成后执行副作用。- 依赖数组
[todos]:仅当todos引用发生变化时才触发。 - 序列化:将 JavaScript 对象数组转为 JSON 字符串存入
localStorage。 - 自动持久化:用户刷新页面后,数据不会丢失。
✅ 这体现了 响应式编程思想:状态变化 → 自动触发副作用。
状态修改函数:四大核心操作
1. 新增待办事项 addTodo
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }])
}
- 参数
text:来自TodoInput的用户输入。 - 不可变更新:使用展开运算符
...todos创建新数组,避免直接修改原数组(React 要求状态不可变)。 - 唯一 ID:
Date.now()生成时间戳作为 ID(简单有效,生产环境建议用uuid)。 - 初始状态:
completed: false表示未完成。
2. 删除待办事项 deleteTodo
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
- 过滤删除:保留所有
id不等于目标id的项。 - 函数式思维:不使用
splice等会修改原数组的方法。
3. 切换完成状态 toggleTodo
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
- 遍历更新:使用
map遍历数组。 - 条件更新:仅当
id匹配时,创建新对象并翻转completed。 - 对象展开
{...todo}:确保新对象与原对象引用不同,触发 React 重渲染。
4. 清除已完成事项 clearCompleted
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed))
}
- 保留未完成项:过滤掉
completed === true的项。
派生状态:计算活跃与已完成数量
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
- 派生状态(Derived State) :从主状态
todos计算而来,无需单独用useState管理。 - 避免冗余:若单独维护
activeCount,需在每次操作后手动更新,易出错。
渲染与 Props 分发
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>
)
<TodoInput onAdd={addTodo} />
→ 将addTodo函数作为onAddprop 传给TodoInput(为子父通信做准备)<TodoList todos={...} onDelete={...} onToggle={...} />
→ 传递数据todos+ 两个操作函数<TodoStats total={...} active={...} completed={...} onClearCompleted={...} />
→ 传递三个统计数字 + 清除函数
✅ 至此,App 完成了“数据分发”任务:向下传递数据和方法。
第三章:TodoInput.jsx —— 子组件如何“上报”用户意图?
TodoInput 是典型的受控组件(Controlled Component) ,它只管理自己的输入状态,并通过回调通知父组件。
逐行深度解析
import { useState } from 'react'
- 仅需
useState管理本地输入状态。
const TodoInput = (props) => {
const { onAdd } = props
- 解构赋值:从
props中取出onAdd回调函数(即App.addTodo)。
const [inputValue, setInputValue] = useState('')
- 本地状态:仅用于同步输入框的值,不影响全局
todos。
const handleSubmit = (e) => {
e.preventDefault()
onAdd(inputValue)
setInputValue('')
}
e.preventDefault():阻止表单默认提交行为(页面刷新)。onAdd(inputValue):关键! 调用父组件传入的函数,将用户输入“上报”给App。setInputValue(''):清空输入框,准备下一次输入。
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
/>
<button type="submit">Add</button>
</form>
)
-
受控组件模式:
value={inputValue}:输入框的值由状态控制onChange:用户输入时更新状态
-
表单提交:按回车或点击 Add 都会触发
handleSubmit
✅ 子父通信完成!
TodoInput不知道也不关心todos如何更新,它只负责“说话”。
第四章:TodoList.jsx —— 渲染列表 + 处理交互
TodoList 负责展示待办事项,并提供删除和切换完成状态的功能。
逐行深度解析
const TodoList = (props) => {
const { todos, onDelete, onToggle } = props
- 解构 props:接收
todos数据 + 两个操作函数。
{ todos.length === 0 ? (
<li className="empty">No todos yet!</li>
) : (
todos.map(todo => ( ... ))
)}
- 条件渲染:无数据时显示友好提示。
map渲染列表:React 推荐方式。
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
key={todo.id}:React 必需!帮助识别列表项身份,优化渲染性能。- 动态类名:已完成项添加
completed类,用于样式高亮。
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
-
受控复选框:
checked由todo.completed控制onChange触发onToggle(todo.id)→ 通知App切换状态
<button onClick={() => onDelete(todo.id)}>Delete</button>
- 删除按钮:点击后调用
onDelete(todo.id)→ 通知App删除
✅ 再次验证子父通信:
TodoList只触发事件,不修改数据。
第五章:TodoStats.jsx —— 纯展示 + 条件交互
TodoStats 是最纯粹的展示型组件(Presentational Component) ,它只接收数据并渲染。
逐行深度解析
const TodoStats = (props) => {
const { total, active, completed, onClearCompleted } = props
- 解构四个 props:三个数字 + 一个函数。
<p>Total: {total} | Active: {active} | Completed: {completed}</p>
- 纯展示:所有数据来自父组件,无任何计算逻辑。
{ completed > 0 && (
<button onClick={onClearCompleted} className="clear-btn">
Clear Completed
</button>
) }
- 条件渲染:仅当
completed > 0时显示按钮。 - 点击事件:调用
onClearCompleted→ 通知App清除已完成项。
✅ 又一次子父通信:简单、安全、可预测。
第六章:兄弟组件通信 —— 间接但高效
现在回答最关键的问题:TodoInput、TodoList、TodoStats 如何“通信”?
它们不能直接通信!
TodoInput无法直接访问TodoList的todosTodoStats无法监听TodoInput的输入事件- React 禁止组件直接互相引用(除非用 ref,但不推荐用于数据流)
正确方式:通过父组件中转
场景:用户新增一条待办
-
用户 在
TodoInput输入 “买牛奶” 并点击 Add -
TodoInput调用onAdd("买牛奶")→ 实际是App.addTodo("买牛奶") -
App执行setTodos([...]),状态更新 -
React 检测到
todos变化,重新渲染App -
App将新的todos和计算后的activeCount/completedCount通过 props 传递给:TodoList→ 列表中新增 “买牛奶”TodoStats→ Total 变为 1,Active 变为 1
数据流动图
TodoInput
│
↓ (调用 onAdd)
App ←───┐
│ │ (状态更新)
├────→ TodoList
│
└────→ TodoStats
✅ 这就是“间接通信” :所有兄弟组件的变化,都源于父组件状态的更新。
第七章:通信模式总结
| 通信类型 | 实现方式 | 关键技术 | 示例 |
|---|---|---|---|
| 父子通信 | 父 → 子 | props 传递数据 | App → TodoList: todos |
| 子父通信 | 子 → 父 | props 传递回调函数 | TodoInput → App: onAdd |
| 兄弟通信 | 子A → 父 → 子B | 状态提升 + 间接通信 | TodoInput → TodoStats |
核心原则
-
单向数据流:数据只能从父流向子,不能反向。
-
状态不可变:更新状态必须创建新对象/数组。
-
关注点分离:
- 父组件:管理状态 + 业务逻辑
- 子组件:展示 UI + 触发事件
-
最小权限原则:子组件只拥有完成任务所需的最少 props。
第八章:为什么这样设计?—— React 的哲学
1. 可预测性(Predictability)
- 所有状态变更都有明确来源(父组件函数)
- 调试时只需查看
App的状态变化
2. 可维护性(Maintainability)
- 修改
todos的逻辑集中在一处 - 新增功能(如“编辑待办”)只需在
App添加函数并传递
3. 可测试性(Testability)
TodoList、TodoStats是纯函数组件,易于单元测试- 传入 mock props 即可验证渲染结果
4. 性能优化基础
- React 通过
props变化决定是否重渲染 - 明确的数据流便于使用
React.memo优化
结语:小应用,大智慧
这个看似简单的 Todo 应用,实则蕴含了 React 开发的黄金法则:
“让数据流动起来,但要让它流得清晰、安全、可追踪。”
你上传的代码,完美诠释了:
- 如何用
props实现父子通信 - 如何用回调函数实现子父通信
- 如何通过状态提升实现兄弟通信
- 如何用
useState+useEffect管理状态与副作用
当你下次面对复杂组件树时,请记住:
- 不要让子组件擅自修改数据
- 不要让兄弟组件直接对话
- 把状态交给最合适的共同父组件
愿你的 React 应用,如这 Todo 一般——简洁、清晰、健壮!