用 React 打造一个健壮的 TodoList:深入理解组件通信与状态管理

44 阅读7分钟

“状态是应用的灵魂,而通信是它的神经。”
—— 某位不愿透露姓名的前端工程师 😏

在现代前端开发中,React 凭借其声明式、组件化和单向数据流的设计哲学,成为构建用户界面的首选框架之一。今天,我们就通过一个经典的 TodoList 应用,深入剖析 React 中的核心概念:父子组件通信、子父通信、兄弟组件间接通信、状态提升(Lifting State Up)以及不可变更新(Immutable Updates)

我们将围绕四个模块展开讲解:

  1. 输入模块(Input Component) :负责新增待办事项。
  2. 列表模块(List Component) :渲染所有待办项,并支持完成/删除操作。
  3. 统计模块(Stats Component) :展示任务总数、活跃数、已完成数,并提供“清除已完成”功能。
  4. 主应用容器(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” 按钮。
  • 点击按钮,通知父组件清除所有已完成项。

关键技术点

  • 接收 totalactivecompleted 三个数值型 props。
  • 条件渲染按钮:{ completed > 0 && <button>... </button> }
  • 通过 props.onClearCompleted 调用父组件的清除逻辑。
{ completed > 0 && (
  <button onClick={onClearCompleted}>Clear Completed</button>
) }

亮点:这个组件是 纯展示型(Presentational Component) —— 它不关心数据怎么来,只负责“怎么显示”。这种分离让组件更易测试和复用。


💡 答疑解惑 | 统计模块的思维误区

❓ Q1:为什么不在这个组件里自己计算 activecompleted

A:因为数据源(todos)在父组件。如果每个子组件都自己算,会导致:

  • 重复计算(性能浪费)
  • 逻辑分散(难以维护)
  • 数据不一致风险(比如过滤条件不同)

✅ 最佳实践计算逻辑集中在状态持有者(父组件) ,子组件只消费结果。

❓ Q2:completed > 0 && <button> 是什么语法?

A:这是利用了 JavaScript 的 短路求值(Short-circuit Evaluation)

  • 如果 completed > 0false,表达式直接返回 false,React 会忽略它(不渲染)。
  • 如果为 true,则返回 <button> 元素并渲染。

⚠️ 注意:不能用于数字 0!因为 0 && <X> 会渲染出 0。此时应改用三元表达式:completed > 0 ? <button /> : null

🎯 面试题延伸:

“React 中如何优化频繁计算的派生状态?”

  • 使用 useMemo 缓存计算结果(如 activeCountcompletedCount)。
  • 但在本例中,由于 todos 更新频率不高,且计算简单,直接在 render 中计算即可,过早优化反而增加复杂度。

🧩 模块四:主应用容器 —— 状态管理中心

App.jsx是整个通信体系的 大脑

  • 使用 useState 持有 todos 数组(唯一数据源)。
  • 提供 addTododeleteTodotoggleTodoclearCompleted 四个 状态更新函数
  • 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]mapfilter 创建新数组,而非直接修改原数组
受控组件Input 的值由 state 控制,确保视图与状态同步
组件职责分离输入(增)、列表(删/改)、统计(查+批量删)各司其职

这个看似简单的 TodoList,实则是 React 核心思想的完美缩影。掌握它,你就掌握了构建任何复杂 React 应用的基石 🧱。


🌟 最后赠言
“不要为了用 React 而写 TodoList,
要为了理解 React 而写 TodoList。”

Happy Coding! 💻✨