React 通信全解:从父子到兄弟,一个 Todo 应用讲透数据流动

58 阅读8分钟

 引言

“在 React 的世界里,组件是岛屿,数据是海洋——而 props 和回调函数,就是连接它们的桥梁与航船。”

你是否曾困惑于:

  • 子组件如何通知父组件“我被点击了”?
  • 兄弟组件之间为何不能直接对话?
  • 父组件如何把数据安全地分发给所有孩子?

今天,我们将通过一个真实、完整、可运行的 React Todo 应用,深入骨髓地讲解 父子通信、子父通信、兄弟组件通信 的核心机制。这篇文章将:

逐行解析每个文件中的每一个 API
清晰描绘数据在组件间的完整流动路径
揭示 React 单向数据流的设计哲学
保持原始代码一字不变,只为还原最真实的开发场景

准备好了吗?让我们启航!


第一章:项目全景图 —— 文件结构与设计哲学

文件结构一览

附项目源码链接: lesson_zp/react/todos: AI + 全栈学习仓库

架构的灵魂宣言

父组件负责持有数据,管理数据
props 传递给子组件
父组件还可以将修改数据的方法传给子组件
子组件不可以直接修改数据,只能通过父组件传递的方法来修改数据

这段话,就是整个应用的设计宪法!它明确指出了:

  1. 状态集中管理:所有数据由 App 持有
  2. 单向数据流:数据只能从父 → 子(通过 props)
  3. 事件反向通知:子 → 父(通过回调函数)
  4. 兄弟通信中介化:必须通过父组件中转

这正是 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 要求状态不可变)。
  • 唯一 IDDate.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 函数作为 onAdd prop 传给 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)}
/>
  • 受控复选框

    • checkedtodo.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 清除已完成项。

又一次子父通信:简单、安全、可预测。


第六章:兄弟组件通信 —— 间接但高效

现在回答最关键的问题:TodoInputTodoListTodoStats 如何“通信”?

它们不能直接通信!

  • TodoInput 无法直接访问 TodoListtodos
  • TodoStats 无法监听 TodoInput 的输入事件
  • React 禁止组件直接互相引用(除非用 ref,但不推荐用于数据流)

正确方式:通过父组件中转

场景:用户新增一条待办

  1. 用户TodoInput 输入 “买牛奶” 并点击 Add

  2. TodoInput 调用 onAdd("买牛奶") → 实际是 App.addTodo("买牛奶")

  3. App 执行 setTodos([...]),状态更新

  4. React 检测到 todos 变化,重新渲染 App

  5. App 将新的 todos 和计算后的 activeCount/completedCount 通过 props 传递给:

    • TodoList → 列表中新增 “买牛奶”
    • TodoStats → Total 变为 1,Active 变为 1

数据流动图

TodoInput
    │
    ↓ (调用 onAdd)
   App ←───┐
    │      │ (状态更新)
    ├────→ TodoList
    │
    └────→ TodoStats

这就是“间接通信” :所有兄弟组件的变化,都源于父组件状态的更新。


第七章:通信模式总结

通信类型实现方式关键技术示例
父子通信父 → 子props 传递数据AppTodoList: todos
子父通信子 → 父props 传递回调函数TodoInputApp: onAdd
兄弟通信子A → 父 → 子B状态提升 + 间接通信TodoInputTodoStats

核心原则

  1. 单向数据流:数据只能从父流向子,不能反向。

  2. 状态不可变:更新状态必须创建新对象/数组。

  3. 关注点分离

    • 父组件:管理状态 + 业务逻辑
    • 子组件:展示 UI + 触发事件
  4. 最小权限原则:子组件只拥有完成任务所需的最少 props。


第八章:为什么这样设计?—— React 的哲学

1. 可预测性(Predictability)

  • 所有状态变更都有明确来源(父组件函数)
  • 调试时只需查看 App 的状态变化

2. 可维护性(Maintainability)

  • 修改 todos 的逻辑集中在一处
  • 新增功能(如“编辑待办”)只需在 App 添加函数并传递

3. 可测试性(Testability)

  • TodoListTodoStats 是纯函数组件,易于单元测试
  • 传入 mock props 即可验证渲染结果

4. 性能优化基础

  • React 通过 props 变化决定是否重渲染
  • 明确的数据流便于使用 React.memo 优化

结语:小应用,大智慧

这个看似简单的 Todo 应用,实则蕴含了 React 开发的黄金法则

“让数据流动起来,但要让它流得清晰、安全、可追踪。”

你上传的代码,完美诠释了:

  • 如何用 props 实现父子通信
  • 如何用回调函数实现子父通信
  • 如何通过状态提升实现兄弟通信
  • 如何用 useState + useEffect 管理状态与副作用

当你下次面对复杂组件树时,请记住:

  • 不要让子组件擅自修改数据
  • 不要让兄弟组件直接对话
  • 把状态交给最合适的共同父组件

愿你的 React 应用,如这 Todo 一般——简洁、清晰、健壮!