“状态是应用的灵魂,而通信是它的神经。”
—— 某位不愿透露姓名的前端工程师 😏
在现代前端开发中,React 凭借其声明式、组件化和单向数据流的设计哲学,成为构建用户界面的首选框架之一。今天,我们就通过一个经典的 TodoList 应用,深入剖析 React 中的核心概念:父子组件通信、子父通信、兄弟组件间接通信、状态提升(Lifting State Up)以及不可变更新(Immutable Updates) 。
我们将围绕四个模块展开讲解:
- 输入模块(Input Component) :负责新增待办事项。
- 列表模块(List Component) :渲染所有待办项,并支持完成/删除操作。
- 统计模块(Stats Component) :展示任务总数、活跃数、已完成数,并提供“清除已完成”功能。
- 主应用容器(App Component) :作为唯一的数据持有者和状态管理者,协调所有子组件。
📥 模块一:输入模块 —— 如何安全地向父组件提交新数据?
核心职责
- 提供一个表单,让用户输入新的待办事项。
- 在用户提交后,将文本内容通过 自定义事件 传递给父组件。
- 提交后清空输入框。
关键技术点
- 使用
useState管理本地输入状态。 - 表单使用
onSubmit阻止默认刷新行为。 - 通过
props.onAdd(text)调用父组件传入的函数,实现 子 → 父通信。
const [inputValue, setInputValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onAdd(inputValue); // 👈 调用父组件方法
setInputValue(''); // 清空
};
✅ 为什么不能直接修改父组件的
todos?
因为 React 遵循 单向数据流 原则:数据只能从父流向子,子组件不能直接修改 props。必须通过回调函数通知父组件:“我想改数据,请你来改”。
💡 答疑解惑 | 输入模块常见误区
❓ Q1:为什么不用 v-model 那样的双向绑定?
A:React 故意不提供双向绑定。因为:
- 双向绑定隐藏了数据流动路径,难以追踪状态变化。
- 单向数据流 + 显式更新(如
onChange+setState)更可预测、易调试。- 性能上,React 的批量更新机制配合受控组件(controlled component)效率更高。
❓ Q2:为什么要在 handleSubmit 里调用 e.preventDefault()?
A:
<form>默认提交会刷新页面!这会丢失所有 React 状态。preventDefault()阻止浏览器默认行为,让 React 完全接管表单逻辑。
🎯 面试题延伸:
“React 中受控组件和非受控组件的区别?”
- 受控组件:表单元素的值由 React state 控制(如本例),通过
value+onChange同步。- 非受控组件:值由 DOM 自身管理(如用
ref获取 input 值),React 不参与同步。通常用于集成第三方库或一次性表单。
📋 模块二:列表模块 —— 渲染、切换与删除
核心职责
- 接收
todos数组并渲染每一项。 - 支持点击复选框 切换完成状态。
- 支持点击删除按钮 移除某项。
- 根据
completed状态应用 CSS 类名(如completed)。
关键技术点
- 使用
.map()渲染列表,必须加key(这里用todo.id)。 - 通过
props.onToggle(id)和props.onDelete(id)触发父组件的状态更新。 - 条件渲染:当
todos.length === 0时显示提示信息。
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)} // 👈 通知父组件切换状态
/>
<button onClick={() => onDelete(todo.id)}>❌</button>
✅ 注意:
onChange里写的是箭头函数() => onToggle(...),而不是直接onToggle(...),否则会在渲染时立即执行!
💡 答疑解惑 | 列表模块高频陷阱
❓ Q1:为什么 key 不能用数组索引(index)?
A:当列表发生 插入、删除、排序 时,用 index 作 key 会导致 React 误判哪些元素“变了”,从而复用错误的 DOM 节点,引发 UI 错乱或状态错位。
✅ 正确做法:使用稳定、唯一、不变的 ID(如时间戳、数据库 ID)。
❓ Q2:为什么不能在子组件里直接 setTodos(...)?
A:因为
todos是父组件的 state,子组件只通过props读取。React 的设计哲学是 “状态集中管理” —— 所有状态变更必须由拥有该状态的组件处理,保证数据一致性。
🎯 面试题延伸:
“React 列表渲染为什么需要 key?它的作用是什么?”
key帮助 React 识别哪些元素被添加、删除或移动。- 在 diff 算法中,React 会基于 key 进行 O(1) 查找,避免全量重建 DOM,极大提升性能。
- 没有 key 或 key 不稳定,会导致不必要的 re-render 和状态丢失。
📊 模块三:统计模块 —— 数据展示与批量操作
核心职责
- 展示
Total / Active / Completed三个数字。 - 当存在已完成任务时,显示 “Clear Completed” 按钮。
- 点击按钮,通知父组件清除所有已完成项。
关键技术点
- 接收
total、active、completed三个数值型 props。 - 条件渲染按钮:
{ completed > 0 && <button>... </button> } - 通过
props.onClearCompleted调用父组件的清除逻辑。
{ completed > 0 && (
<button onClick={onClearCompleted}>Clear Completed</button>
) }
✅ 亮点:这个组件是 纯展示型(Presentational Component) —— 它不关心数据怎么来,只负责“怎么显示”。这种分离让组件更易测试和复用。
💡 答疑解惑 | 统计模块的思维误区
❓ Q1:为什么不在这个组件里自己计算 active 和 completed?
A:因为数据源(
todos)在父组件。如果每个子组件都自己算,会导致:
- 重复计算(性能浪费)
- 逻辑分散(难以维护)
- 数据不一致风险(比如过滤条件不同)
✅ 最佳实践:计算逻辑集中在状态持有者(父组件) ,子组件只消费结果。
❓ Q2:completed > 0 && <button> 是什么语法?
A:这是利用了 JavaScript 的 短路求值(Short-circuit Evaluation) :
- 如果
completed > 0为false,表达式直接返回false,React 会忽略它(不渲染)。- 如果为
true,则返回<button>元素并渲染。⚠️ 注意:不能用于数字 0!因为
0 && <X>会渲染出0。此时应改用三元表达式:completed > 0 ? <button /> : null
🎯 面试题延伸:
“React 中如何优化频繁计算的派生状态?”
- 使用
useMemo缓存计算结果(如activeCount、completedCount)。- 但在本例中,由于
todos更新频率不高,且计算简单,直接在 render 中计算即可,过早优化反而增加复杂度。
🧩 模块四:主应用容器 —— 状态管理中心
App.jsx是整个通信体系的 大脑:
- 使用
useState持有todos数组(唯一数据源)。 - 提供
addTodo、deleteTodo、toggleTodo、clearCompleted四个 状态更新函数。 - 将
todos和这些函数作为 props 分发给子组件。 - 所有子组件的交互最终都 汇总到 App,由它决定如何更新状态。
🔑 核心思想:状态提升(Lifting State Up)
当多个组件需要共享状态时,将状态移到它们最近的共同父组件中。
🤝 兄弟组件如何通信?—— 间接通信的艺术
你的三个子组件(输入、列表、统计)互为兄弟,它们 不能直接对话。那如何协同?
答案是:全部通过父组件中转!
- 用户在 输入组件 添加任务 → 调用
onAdd→ 父组件更新todos→ 所有子组件重新渲染。 - 用户在 列表组件 删除任务 → 调用
onDelete→ 父组件过滤todos→ 统计组件自动显示新数字。 - 用户点击 统计组件 的清除按钮 → 调用
onClearCompleted→ 父组件更新todos→ 列表自动消失已完成项。
✅ 这就是 “间接通信” :兄弟组件通过 共享父状态 + 回调函数 实现联动。
虽然多了一层,但换来的是 清晰的数据流 和 可预测的状态变更。
💡 答疑解惑 | 兄弟通信的本质
❓ Q:有没有办法让兄弟组件直接通信?比如用 Context 或全局状态?
A:可以,但 不推荐 在这种简单场景下使用。
- Context 适合跨多层组件传递(如主题、用户信息)。
- Redux/Zustand 适合大型应用的复杂状态管理。
对于 TodoList 这种 局部、紧密耦合 的状态,状态提升 + props drilling 是最简洁、高效的方式。
🎯 面试题延伸:
“什么时候该用 Context,什么时候用 props?”
- 用 props:组件层级浅(≤3 层),状态仅被少数组件使用。
- 用 Context:状态需要穿透多层无关组件(“prop drilling 太深”),且更新不频繁。
- ⚠️ 注意:Context 更新会触发所有 Consumer 重渲染,需配合
useMemo/memo优化。
🏁 总结:这个 TodoList 教会我们的 React 哲学
| 概念 | 体现 |
|---|---|
| 单向数据流 | 数据从 App → 子组件,子组件通过回调“请求”修改 |
| 状态提升 | todos 集中在 App,避免分散管理 |
| 不可变更新 | 使用 [...todos]、map、filter 创建新数组,而非直接修改原数组 |
| 受控组件 | Input 的值由 state 控制,确保视图与状态同步 |
| 组件职责分离 | 输入(增)、列表(删/改)、统计(查+批量删)各司其职 |
这个看似简单的 TodoList,实则是 React 核心思想的完美缩影。掌握它,你就掌握了构建任何复杂 React 应用的基石 🧱。
🌟 最后赠言:
“不要为了用 React 而写 TodoList,
要为了理解 React 而写 TodoList。”
Happy Coding! 💻✨