React 组件通信完全指南 —— 从 Todo App 搞懂父子组件那点事

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

这样你永远不用担心 activeCounttodos 不同步------因为它们是数学上的因果关系,不是需要手动维护的同步关系。


六、通信模式完整对照表

场景 解决方案 本项目示例
父 → 子 传递数据 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 组件通信的核心:

  1. 父传子:Props 像快递,从上往下送
  2. 子传父:回调函数像电话,从下往上打
  3. 兄弟通信:把共享数据提到共同的"爹"那里去
  4. 派生状态:能算出来的就别单独存

这些模式不复杂,但它们构成了 React 数据流的底层逻辑。理解透了,后面的 Context、Redux、Zustand 都只是这些模式的"自动化升级版"而已。


思考题

在评论区和大家交流吧:

  1. 如果要给 Todo 加上"编辑"功能,你会怎么设计组件通信?
  2. 如果 TodoList 里的每一项都是一个独立的 TodoItem 组件,props 需要传几层?有没有优化空间?
  3. 你觉得这个项目里有没有"过度设计"或者"设计不足"的地方?
相关推荐
先吃饱再说1 小时前
React 组件通信:从 Props 单向传递到自定义事件
前端·react.js·前端框架
何时梦醒2 小时前
⚛️ React 19 组件化实战 —— 从零搭建 Todo List 并吃透组件通信
前端·人工智能·react.js
bonechips2 小时前
React 组件通信:一套 TodoList 讲清 props 与回调
前端·react.js
不好听6134 小时前
React 父子组件通信:从 Todo 应用理解单向数据流
前端·react.js
光影少年4 小时前
RN 路由栈管理、页面销毁、返回拦截
javascript·react native·react.js
小林ixn13 小时前
从零到一理解 React 父子组件通信:手写一个 Todo 应用带你彻底搞懂单向数据流
前端·javascript·react.js
qetfw16 小时前
MXU:Tauri 2 + React 的 MaaFramework 跨平台 GUI 源码
前端·python·react.js·前端框架·开源项目·效率工具