React 组件通信完全指南 ------ 从 Todo App 搞懂父子组件那点事
写在前面
还记得你刚学 React 时的那个经典场景吗?
"这个按钮在子组件里,怎么把点击事件传给父组件啊?" "父组件的数据变了,子组件怎么感知到?" "兄弟组件之间怎么通信?难道要互相 props?"
如果你也有过这些困惑,恭喜你,你来对地方了。
今天我要用一个最经典的 Todo App ,把 React 组件通信这件事给你讲得明明白白。没有花里胡哨的状态管理库,没有 Redux、没有 Zustand------只用 React 原生的 props 和回调函数,搞定一切。
一、先看成品:组件树长什么样
在聊通信之前,我们先搞清楚这个 Todo App 的组件结构:
scss
App (父组件 - 状态持有者)
├── TodoInput (子组件 - 输入框)
├── TodoList (子组件 - 列表渲染)
└── TodoStats (子组件 - 统计面板)
非常简单,一个父组件 + 三个子组件 。但就是这么简单的结构,已经包含了 React 组件通信的所有核心模式。
组件的职责划分
| 组件 | 职责 | 有无自己的 state |
|---|---|---|
App |
管理所有 Todo 数据、提供增删改查方法 | ✅ |
TodoInput |
收集用户输入、通知父组件添加 | ✅ (输入框值) |
TodoList |
展示 Todo 列表、转发用户操作 | ❌ (纯展示) |
TodoStats |
展示统计数据、提供清除按钮 | ❌ (纯展示) |
看到没?数据永远只属于一个组件------这就是 React 单向数据流的精髓。
二、父 → 子通信:Props 直通车
这是 React 中最基础、最常用的通信方式:父组件通过 Props 把数据传给子组件。
看代码
jsx
// App.jsx ------ 父组件把数据"塞"给子组件
<TodoList
todos={todos} // ← 数据通过 props 传递
onToggle={toggleTodo} // ← 回调函数也通过 props 传递
onDelete={deleteTodo} // ← 同样是 props
/>
<TodoStats
total={todos.length} // ← 直接传原始值
active={activeCount} // ← 传派生状态
completed={completedCount} // ← 传派生状态
onClearCompleted={clearCompleted}
/>
jsx
// TodoList.jsx ------ 子组件通过解构接收
const TodoList = ({ todos, onToggle, onDelete }) => {
return (
<ul className="todo-list">
{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>
)
}
关键要点
scss
┌──────────┐ props (数据向下) ┌──────────┐
│ App │ ─────────────────────────> │ TodoList │
│ (父组件) │ │ (子组件) │
└──────────┘ └──────────┘
- Props 是只读的:子组件永远不应该修改 props
- Props 可以是任何类型:原始值、对象、数组、函数、甚至 JSX
- Props 是单向的:数据只能从父到子,不能反向
💡 新手常见误区 :觉得 props 传得太深很烦(props drilling),于是想"直接在子组件里改父组件的数据"。千万别这么干! React 的数据流是单向的,这是它的核心设计哲学。后面我们会讲到更好的解决方案。
三、子 → 父通信:回调函数反向通知
如果说 Props 是"父母给孩子东西",那回调函数就是"孩子给父母打电话"。
子组件不能直接修改父组件的数据,但它可以调用父组件传下来的函数,让父组件自己改。
看代码 ------ TodoInput 如何通知父组件
jsx
// TodoInput.jsx ------ 子组件通过回调把数据"上报"给父组件
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>
)
}
jsx
// App.jsx ------ 父组件定义回调,并传给子组件
const addTodo = (text) => {
if (text.trim() === '') return
setTodos([
{ id: Date.now(), text, completed: false },
...todos,
])
}
// 把 addTodo 作为 onAdd prop 传给子组件
<TodoInput onAdd={addTodo} />
数据流示意图
scss
┌──────────┐ 回调函数 (事件向上) ┌────────────┐
│ App │ <───────────────────── │ TodoInput │
│ addTodo │ onAdd(text) │ handleSubmit│
└──────────┘ └────────────┘
再看 TodoList 和 TodoStats
同样的模式贯穿始终:
jsx
// TodoList 通知父组件"切换完成状态"
onChange={() => onToggle(todo.id)}
// TodoList 通知父组件"删除这一条"
onClick={() => onDelete(todo.id)}
// TodoStats 通知父组件"清除所有已完成的"
onClick={onClearCompleted}
🎯 核心认知 :React 中不存在真正的"子传父",本质上是父组件把"修改自己数据的权力"下放给了子组件。子组件只是"打了个电话通知",真正动手改数据的还是父组件自己。
四、状态提升:为什么数据要放在 App 里?
你可能会问:"为什么 todos 非要放在 App 里?放在 TodoList 里不行吗?"
来看看如果数据下放会发生什么:
❌ 错误示范:数据放在子组件
jsx
// 如果 todos 放在 TodoList 里
const TodoList = () => {
const [todos, setTodos] = useState([...])
return (/* 列表渲染 */)
}
// 问题来了:
// TodoInput 要添加 todo → 它访问不到 TodoList 的 state
// TodoStats 要统计数量 → 它也访问不到 TodoList 的 state
// 三个组件变成三座孤岛 🤷
✅ 正确做法:状态提升到公共父组件
jsx
// App.jsx ------ 状态在公共父组件
const App = () => {
const [todos, setTodos] = useState([...])
const addTodo = (text) => { /* ... */ }
const toggleTodo = (id) => { /* ... */ }
const deleteTodo = (id) => { /* ... */ }
// 派生状态 ------ 不需要单独的 useState
const activeCount = todos.filter(t => !t.completed).length
const completedCount = todos.length - activeCount
return (
<>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</>
)
}
状态提升的黄金法则
scss
┌─────────────────────────┐
│ App │
│ ┌─────────────────┐ │
│ │ todos (state) │ │
│ │ addTodo() │ │
│ │ toggleTodo() │ │
│ │ deleteTodo() │ │
│ └───┬───┬───┬─────┘ │
└──────┼───┼───┼──────────┘
│ │ │
┌────────┘ │ └────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│TodoInput │ │TodoList │ │TodoStats │
│ onAdd │ │ todos │ │ total │
│ │ │ onToggle│ │ active │
│ │ │ onDelete│ │completed │
└──────────┘ └──────────┘ └──────────┘
🧠 一句话总结 :但凡有多个组件需要共享的数据,就把它提升到它们最近的公共父组件中。 这就是 React 官方文档里的 "Lifting State Up"。
五、派生状态:不需要 useState 的"状态"
注意这行代码:
jsx
const activeCount = todos.filter(t => !t.completed).length
const completedCount = todos.length - activeCount
很多 React 新手会写成这样:
jsx
// ❌ 画蛇添足
const [activeCount, setActiveCount] = useState(0)
const [completedCount, setCompletedCount] = useState(0)
// 然后在每个操作里手动更新...
const toggleTodo = (id) => {
// 改 todos...
// 改 activeCount...
// 改 completedCount...
// 😱 三份状态要同步,漏一个就出 bug
}
派生状态的核心原则:如果一个值可以从已有 state 计算出来,就不要单独再建一个 state。
jsx
// ✅ 正确:直接从 todos 派生
const activeCount = todos.filter(t => !t.completed).length
const completedCount = todos.length - activeCount
这样你永远不用担心 activeCount 和 todos 不同步------因为它们是数学上的因果关系,不是需要手动维护的同步关系。
六、通信模式完整对照表
| 场景 | 解决方案 | 本项目示例 |
|---|---|---|
| 父 → 子 传递数据 | Props | todos={todos} |
| 子 → 父 通知事件 | 回调 Props | onAdd={addTodo} |
| 兄弟组件 共享数据 | 状态提升到公共父组件 | TodoInput 和 TodoList 共享 todos |
| 跨层级 传递 | Context API (本项目未使用) | - |
| 全局状态 | Redux / Zustand 等 | - |
对于这个 Todo App 的复杂度来说,Props + 回调已经完全够用了。别一上来就上 Redux------那是"杀鸡用牛刀"。
七、完整数据流:一个 Todo 的生命周期
让我们跟踪"添加一个 Todo"的全过程,理解数据是怎么流动的:
Step 1:用户在 TodoInput 中输入文字
ini
用户输入 "学习 React 通信"
→ TodoInput 内部 state: inputValue = "学习 React 通信"
→ 此时父组件完全不知情
Step 2:用户点击 Add 按钮
scss
TodoInput.handleSubmit() 被触发
→ 调用 onAdd("学习 React 通信")
→ 这个 onAdd 就是 App 的 addTodo 函数
Step 3:父组件更新状态
php
App.addTodo("学习 React 通信")
→ setTodos([{id: 123, text: "学习 React 通信", completed: false}, ...todos])
→ App 组件重新渲染
→ 新的 todos 通过 props 流向 TodoList 和 TodoStats
Step 4:子组件响应更新
bash
TodoList 收到新的 todos props → 渲染新的列表项
TodoStats 收到新的 total/active → 更新统计数字
TodoInput 的 inputValue 被清空 → 输入框重置
一张图看懂全过程
scss
用户点击 Add
│
▼
TodoInput (子)
│ onAdd(text)
▼
App (父)
│ setTodos([newTodo, ...todos])
├── todos ──────────> TodoList (子) → 渲染新列表
├── total ──────────> TodoStats (子) → 更新数字
└── active ─────────> TodoStats (子) → 更新数字
完美闭环,干净利落。
八、避坑指南:3 个容易犯的错误
坑 1:直接修改 props
jsx
// ❌ 绝对不要!
const TodoList = ({ todos }) => {
todos.push({ id: 999, text: '新todo' }) // 这是在作死
return (/* ... */)
}
React 的数据流是单向 的,props 是只读的。直接修改 props 不会触发重新渲染,而且会引发难以追踪的 bug。
坑 2:忘记 key 属性
jsx
// ❌ 没有 key
{todos.map(todo => <li>{todo.text}</li>)}
// ✅ 有稳定的 key
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
没有 key 会导致 React 在列表更新时出现渲染错乱。用稳定且唯一的 ID,不要用数组索引。
坑 3:滥用 useState
jsx
// ❌ 能计算出来的就不要建 state
const [completedCount, setCompletedCount] = useState(0)
// ✅ 直接从源数据派生
const completedCount = todos.filter(t => !t.completed).length
每多一个不必要的 useState,就多一个可能出 bug 的地方。保持状态最小化是 React 开发的黄金法则。
九、下一步:从 Props 到更高级的通信
这个 Todo App 用纯 Props + 回调解决了所有通信需求。但当应用变复杂时,你还需要了解:
| 场景 | 推荐方案 |
|---|---|
| 跨多层组件传值 | Context API ------ React 内置,无需第三方库 |
| 复杂全局状态 | Zustand ------ 轻量、简单、TS 友好 |
| 服务端状态 | React Query ------ 缓存、去重、自动重新请求 |
| URL 参数共享 | URL Search Params ------ 刷新不丢失、可分享 |
但无论用多高级的方案,Props + 回调这套基本功永远是你理解它们的基石。
写在最后
回顾一下,我们用不到 100 行代码的 Todo App,吃透了 React 组件通信的核心:
- 父传子:Props 像快递,从上往下送
- 子传父:回调函数像电话,从下往上打
- 兄弟通信:把共享数据提到共同的"爹"那里去
- 派生状态:能算出来的就别单独存
这些模式不复杂,但它们构成了 React 数据流的底层逻辑。理解透了,后面的 Context、Redux、Zustand 都只是这些模式的"自动化升级版"而已。
思考题
在评论区和大家交流吧:
- 如果要给 Todo 加上"编辑"功能,你会怎么设计组件通信?
- 如果 TodoList 里的每一项都是一个独立的
TodoItem组件,props 需要传几层?有没有优化空间? - 你觉得这个项目里有没有"过度设计"或者"设计不足"的地方?