写在前面
在 React 开发中,组件不是各自为政的孤岛,而是通过一套清晰规则协同工作的单元。掌握组件之间如何安全、高效地传递信息,是写出可维护、可扩展应用的关键。本文以一个经典的 Todo 应用为例,带你理解 React 组件通信的三大核心原则。
一、父子通信:数据只能“向下流”
React 坚持 单向数据流:数据从父组件流向子组件,方式是通过 props。
比如在 App.jsx 中,我们定义了整个应用的核心状态:
const [todos, setTodos] = useState([]);
然后把这个状态传给需要它的子组件:
<TodoList todos={todos} />
<TodoStats total={todos.length} />
这时,子组件只是“消费者”——它们拿到数据后用来渲染界面,但不能修改它。这种只读模式保证了数据来源的单一性和可预测性,是 React 架构的第一道防线。
二、子组件想改数据?请“上报意图”
如果子组件需要改变状态(比如用户点了删除按钮),它不能直接改 props,而必须 调用父组件传下来的函数。
父组件提供“操作接口”
在 App 中,我们提前写好修改逻辑:
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
这些函数的特点是:
- 使用
setTodos更新状态(符合不可变原则) - 只接收必要参数(如
id) - 返回全新状态,而非直接修改原数组
子组件通过回调“提请求”
然后,父组件把这些函数作为 props 传下去:
<TodoList
todos={todos}
onDelete={deleteTodo}
onToggle={toggleTodo}
/>
在 TodoList 内部,用户操作会触发这些回调:
<button onClick={() => onDelete(todo.id)}>X</button>
<input onChange={() => onToggle(todo.id)} />
重点在于:
子组件不关心“怎么删”,只负责说“我想删哪个”。真正的逻辑由父组件掌控。
这种“描述意图 + 父级执行”的模式,既解耦了组件,又集中了状态管理。
三、兄弟组件通信:状态提升的本质与工程意义
在 Todo 应用中,TodoInput、TodoList 和 TodoStats 虽然功能各异,却共享同一份数据源:任务列表 todos。当用户通过 TodoInput 添加一条新任务时,TodoList 需要渲染它,TodoStats 需要更新计数——这看似简单的联动,实则触及了 React 架构的核心命题:如何在保持组件独立性的同时,实现跨组件的状态同步?
❌ 为什么兄弟组件不能直接通信?
直觉上,我们可能会想:“既然 TodoInput 知道自己加了任务,为什么不直接调用 TodoList.add() 或修改 TodoStats.count?”
这种想法在小型 demo 中或许可行,但在真实项目中会迅速导致:
- 隐式依赖:组件之间形成看不见的耦合,修改一个可能意外破坏另一个。
- 状态分散:数据散落在多个组件中,无法确定“哪个才是最新值”。
- 调试困难:状态变更路径不透明,难以追踪 bug 源头。
- 测试复杂:组件无法独立运行,必须模拟整个上下文。
React 明确拒绝这种模式,因为它违背了可预测性(Predictability)这一现代 UI 框架的基石。
✅ 正确解法:状态提升(Lifting State Up)
React 的答案是:将共享状态提升至最近的共同祖先组件(在这里是 App),由它统一持有、更新和分发。
// App.jsx
const [todos, setTodos] = useState(() => {
// 初始化时从 localStorage 恢复
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
此时,todos 成为整个应用的 单一事实来源(Single Source of Truth) 。所有子组件都从这个源头获取数据,而非彼此。
🧩 分发策略:最小权限原则
父组件并非一股脑把状态全塞给子组件,而是遵循 “最小必要能力”原则:
| 组件 | 接收什么 | 为什么 |
|---|---|---|
TodoInput | 仅 onAdd 回调 | 它只负责“发起添加意图”,无需知道任务列表长什么样 |
TodoList | todos + onDelete + onToggle | 它需要完整数据和操作能力来渲染和交互 |
TodoStats | 计算后的 total 和 activeCount | 它只关心统计结果,不关心原始数据结构 |
这种按需分发的设计,使得每个组件的职责边界极其清晰,也天然支持未来扩展(例如新增一个“任务导出”按钮,只需从 App 获取 todos 即可,无需改动其他组件)。
🔁 数据流:声明式更新的威力
以“添加任务”为例,完整的通信链路如下:
- 用户操作:在
TodoInput中输入并提交 - 意图上报:
TodoInput调用onAdd(text)—— 注意,它不执行任何状态变更,只是发出一个“请求” - 状态更新:
App执行setTodos([...todos, 新任务]),生成全新状态 - 自动同步:React 检测到
todos变化,触发App重新渲染 - 数据分发:新的
todos和计算值自动通过 props 流向TodoList和TodoStats - UI 一致更新:所有相关组件基于同一份新状态重新渲染
整个过程无手动通知、无事件广播、无全局变量,完全由 React 的声明式渲染机制驱动。这就是“状态即 UI”的体现:你只描述“状态是什么”,React 负责“如何更新界面” 。
🏗️ 工程价值:为什么这比“直接通信”更强大?
| 维度 | 直接通信(反模式) | 状态提升(推荐) |
|---|---|---|
| 可维护性 | 修改一个组件需理解多个依赖 | 所有逻辑集中在父组件,修改局部 |
| 可测试性 | 组件耦合,难以单元测试 | 子组件纯函数式,易于 mock |
| 可扩展性 | 新增组件需侵入现有通信链 | 新组件只需从 App 获取所需数据 |
| 可预测性 | 状态变更路径隐蔽 | 所有变更通过 setTodos 显式发生 |
| 调试体验 | 难以追踪状态来源 | React DevTools 可直接查看 props 流动 |
更重要的是,这种模式为后续引入状态管理库(如 Redux、Zustand)或服务端状态(如 React Query)打下了思维基础——无论状态来自哪里,组件只关心“消费”,不关心“来源” 。
⚠️ 常见误区与边界
- 误区1:“状态提升会导致父组件臃肿。”
→ 解法:当App逻辑过重时,可拆分为状态容器组件(如TodoProvider)+ 展示组件,或使用 Context + useReducer。 - 误区2:“每次都要传好多 props,太麻烦。”
→ 解法:对于深层嵌套,可用React.Context避免 prop drilling,但不要滥用——Context 适合全局状态(如用户登录态),而todos是局部业务状态,仍建议显式传递。 - 边界:如果两个组件确实完全无关(如 Header 和 Footer),却需要共享状态,才考虑全局状态管理。否则,优先使用状态提升。
“兄弟组件通过父组件通信”表面上是一个技术方案,实质上是一种架构哲学:
让状态流动可见,让组件依赖显式,让变更路径可追溯。
正是这种克制与纪律,使得 React 应用在规模增长时依然能保持清晰、稳定与可演进。TodoList 虽小,却足以窥见大型应用的治理之道。
四、状态持久化:副作用交给 useEffect
为了让 Todo 数据在刷新后不丢失,我们需要同步到 localStorage。但不要在每个修改函数里手动存!
❌ 错误做法:重复写持久化逻辑
const addTodo = (text) => {
setTodos(...);
localStorage.setItem('todos', JSON.stringify(...)); // 重复!
}
这样容易漏写、难维护、逻辑混杂。
✅ 正确做法:用 useEffect 统一监听
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
同时,初始化时从本地恢复:
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
优势很明显:
- 持久化逻辑只写一次
- 业务代码更干净
- 未来换存储方案只需改一处
- 完全符合 React “副作用分离”的理念
而且,它依然遵循前面的通信原则——状态变更仍是显式、受控的,只是副作用被优雅地托管了。
总结:React 通信的黄金三法则
通过这个 Todo 应用,我们可以提炼出三条简单却强大的原则:
- 数据向下流:父 → 子,靠
props传数据 - 事件向上冒:子 → 父,靠回调函数(如
onDelete)传意图 - 兄弟不直连:通信必须通过共同父组件中转
这些原则看似基础,却是构建健壮 React 应用的基石。它们让状态变得可预测、可追踪、易调试——而这,正是现代前端工程的核心追求。
记住:在 React 的世界里,状态变更永远应该是显式的、受控的、有迹可循的。只要守住这条底线,你的应用就能在复杂度增长时依然保持清晰与稳定。
附录源码
App.jsx
import {
useState,
useEffect
} from 'react'
import './styles/app.styl'
import TodoList from '../components/TodoList'
import TodoInput from '../components/TodoInput'
import TodoStats from '../components/TodoStats'
function App() {
// 子组件共享的数据状态
const [todos, setTodos] = useState(() =>{
// 从本地存储中获取数据
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
})
// 子组件提交修改数据的方法
const addTodo = (text) => {
setTodos([...todos,{
// 时间戳
id:Date.now(),
text,
completed:false,
}])
}
// 从待办事项列表中删除指定 ID 的任务。
const deleteTodo =(id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
// 计算未完成(活跃)的待办事项数量。
const activeCount = todos.filter(todo => !todo.completed).length;
// 计算已完成(完成)的待办事项数量。
const completedCount = todos.filter(todo => todo.completed).length;
// 切换指定 ID 任务的完成状态。
const toggleTodo = (id) => {
setTodos(todos.map(todo => todo.id === id ? {
...todo,
completed: !todo.completed} : todo))
}
// 清除所有已完成的任务。
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed))
}
// 监听todos 变化 保存到本地存储
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos))
}, [todos])
return (
<div>
<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>
</div>
)
}
export default App
TodoInput.jsx
import { useState } from 'react'
const TodoInput = (props) => {
// console.log(props);
const { onAdd } = props
// react 不支持v-model那样的双向绑定 性能不好
// react 只支持单向绑定 性能好 + onChange实现数据和视图的同步
//
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)}
/>
<button type="submit">Add</button>
</form>
)
}
export default TodoInput
TodoList.jsx
const TodoList = (props) => {
const {
todos,
onDelete,
onToggle
} = props;
return (
<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)}>X</button>
</li>
))
)
}
TodoList
</ul>
);
};
export default TodoList;
TodoStats.jsx
const TodoStats = (props) => {
const {
total,
active,
completed,
onClearCompleted
} = props
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>
)
}
export default TodoStats