拒绝背八股,从一个真实场景出发,吃透 React 组件间通信的底层逻辑
写在前面
很多初学者在学习 React 时,最先被“卡住”的地方往往不是 JSX 语法,也不是 hooks 的用法,而是一个看似简单却至关重要的问题:组件之间到底怎么通信?
尤其是父子组件,props 传参、回调函数、状态提升……这些概念单独拎出来都懂,一写代码就懵。
今天,我们不聊虚的。我带你从零到一实现一个完整的 Todo 应用,边写边讲,把 React 父子组件通信的每一个细节都掰开揉碎。你会发现,当你真正理解了单向数据流,这些问题都会迎刃而解。
一句话总结本文核心:数据在父组件中持有,通过 props 向下传递;变化通过回调函数向上通知,父组件修改数据后,子组件自动更新。
一、从组件化思维开始
在 React 的世界里,一切皆为组件。组件化不仅仅是一种代码组织方式,更是一种思维方式。
1.1 拿到设计稿,先画组件树
无论是做一个 Todo 应用,还是搭建一个企业级后台,第一步都不是写代码,而是“拆”。
把 UI 拆解成独立的、可复用的组件,并规划好它们之间的层级关系。这就是所谓的组件树(Component Tree)。
对于我们的 Todo 应用,界面可以拆分成三块:
App (根组件) ├── TodoInput (输入框 + 添加按钮) ├── TodoList (列表) │ └── TodoItem (列表项 - 在 TodoList 内部渲染) └── TodoState (统计 + 清除按钮)对应的目录结构是这样的:
src/ ├── App.jsx ├── App.css ├── components/ │ ├── TodoInput.jsx │ ├── TodoList.jsx │ └── TodoState.jsx └── main.jsx为什么先做这一步?因为在 vibe coding 时代,清晰的组件结构能让你的思路始终保持在线。有了这张“地图”,代码往哪里写、数据往哪里放,一目了然。
二、核心原则:单向数据流
在 React 中,有一条铁律:数据流向是单向的,从父组件到子组件。
说白了就是:父组件是“老板”,子组件是“员工”。老板掌握核心数据,员工只负责干活,通过老板给的“指令”(props)来工作。员工不能擅自改数据,只能向老板“打报告”(调用回调函数),由老板来决策是否修改。
这张图,刻在脑子里:
数据 (state) → 通过 props 传递 → 子组件渲染 ↑ │ │ │ └────── 回调函数 (callback) ←─────────┘ 子组件通过调用回调 通知父组件更新数据三、实战演练:Todo 应用完整实现
下面我们一步步实现这个 Todo 应用,每一行代码都会对应到上面的原则。
3.1 父组件 App:数据的“大本营”
父组件 App 是整个应用的数据持有者。所有的 Todo 数据都存放在这里,所有修改数据的方法也在这里定义。
// App.jsx import { useState } from 'react'; import TodoInput from './components/TodoInput.jsx'; import TodoList from './components/TodoList.jsx'; import TodoState from './components/TodoState.jsx'; import './App.css'; const App = () => { // ✅ 数据状态由父组件持有和管理 const [todos, setTodos] = useState([ { id: 1, text: '吃饭', completed: false }, { id: 2, text: '睡觉', completed: false }, { id: 3, text: '打豆豆', completed: true }, ]); // ✅ 添加 todo —— 父组件负责修改 const addTodo = (text) => { if (text.trim() === '') return; // ⚠️ 注意:不能直接 push 修改原数组! // todos.push({ id: Date.now(), text }); // ❌ 错误做法 // 必须创建一个全新的状态 setTodos([{ id: +Date.now(), text, completed: false }, ...todos]); }; // ✅ 切换完成状态 const toggleTodo = (id) => { setTodos(todos.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo )); }; // ✅ 删除 todo const deleteTodo = (id) => { setTodos(todos.filter(todo => todo.id !== id)); }; // ✅ 清除已完成的 const clearCompleted = () => { setTodos(todos.filter(todo => !todo.completed)); }; // 派生数据:从现有数据计算得出 const activeCount = todos.filter(t => !t.completed).length; const completedCount = todos.filter(t => t.completed).length; return ( <div> <h1>My Todo List</h1> {/* 通过 props 向下传递数据和方法 */} <TodoInput onAdd={addTodo} /> <TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} /> <TodoState total={todos.length} active={activeCount} completed={completedCount} onClearCompleted={clearCompleted} /> </div> ); }; export default App;划重点:
todos数组是唯一的数据源,由useState管理- 所有修改操作(增、删、改、清)都通过
setTodos生成新数组来更新 - 永远不要直接修改 state,React 依赖不可变数据来触发重新渲染
为什么不能直接修改 state?因为 React 做的是浅比较。直接
todos.push()修改的是同一个数组引用,React 认为“没变化”,自然不会重新渲染。而setTodos([...])创建了新数组,引用变了,React 才会知道“哦,数据变了,我得重新渲染”。
3.2 子组件 TodoInput:表单交互
TodoInput 负责用户输入和提交。它自己需要维护一个输入框的inputValue状态——但注意,这个状态是它自己的私有状态,不需要共享给父组件。
// components/TodoInput.jsx import { useState } from 'react'; 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> ); }; export default TodoInput;通信模式拆解:
| 方向 | 方式 | 数据内容 |
|---|---|---|
| 父→子 | props | onAdd回调函数 |
| 子→父 | 调用onAdd | 输入框中的文本inputValue |
TodoInput 自己管理
inputValue,但数据真正的归宿是父组件。用户点击提交时,子组件“打报告”给父组件,父组件执行addTodo将数据加入列表。这就是所谓的“子组件只负责收集,不负责存储”。
3.3 子组件 TodoList:列表渲染
TodoList 接收父组件传来的todos数组,负责渲染列表。同时,它把onToggle和onDelete回调继续向下传递(给 TodoItem)。
这里我直接把 TodoItem 内联在 TodoList 中渲染,方便展示:
// components/TodoList.jsx const TodoList = ({ todos, onToggle, onDelete }) => { 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)}>Delete</button> </li> )) )} </ul> ); }; export default TodoList;通信模式拆解:
| 方向 | 方式 | 数据内容 |
|---|---|---|
| 父→子 | props | todos,onToggle,onDelete |
| 子→父 | 调用onToggle(id)/onDelete(id) | 目标 todo 的id |
TodoList 是一个典型的展示型组件:它只负责“怎么显示”,不关心“数据怎么来”“改了会怎样”。所有的逻辑都委托给父组件处理。
3.4 子组件 TodoState:状态统计
// components/TodoState.jsx const TodoState = ({ total, active, completed, onClearCompleted }) => { return ( <div className="todo-state"> <p>Total: {total} | Active: {active} | Completed: {completed}</p> {completed > 0 && ( <button onClick={onClearCompleted} className="clear-btn"> Clear Completed </button> )} </div> ); }; export default TodoState;通信模式拆解:
| 方向 | 方式 | 数据内容 |
|---|---|---|
| 父→子 | props | total,active,completed,onClearCompleted |
| 子→父 | 调用onClearCompleted() | 无参数,仅表示“清除已完成”这个动作 |
四、深入理解:为什么子组件不能直接修改父组件的 state?
这是 React 新手最容易踩的坑之一。
从设计层面看:如果子组件能随意修改父组件的状态,数据流就会变得混乱。当 Bug 出现时,你根本不知道是哪个子组件偷偷改了数据。调试难度呈指数级上升。
从 React 机制看:父组件的 state 是“私有财产”,子组件根本没有访问它的权限。子组件能拿到的只有 props,而 props 是只读的。
记住这句话:数据只能向下流,变化只能向上报。
正确的做法就是我们的代码所示:子组件通过调用props中的回调函数,“请求”父组件修改数据。父组件收到请求后,自主决定是否修改、如何修改。
五、补充:useState 的懒执行(Lazy Initialization)
顺带提一个点:useState的懒执行。
当初始值的计算开销较大时(比如涉及复杂运算、读取 localStorage 等),可以使用函数形式的 initialState:
// ❌ 每次渲染都会执行 getInitialValue,即使只在首次使用 const [state, setState] = useState(getInitialValue()); // ✅ 只有在首次渲染时才会执行 getInitialValue const [state, setState] = useState(() => getInitialValue());在 Todo 应用中,如果你的初始数据需要从 localStorage 读取,可以这样写:
const [todos, setTodos] = useState(() => { const saved = localStorage.getItem('todos'); return saved ? JSON.parse(saved) : []; });这个细节很小,但在性能敏感的场景下,能帮你避免不必要的计算。
六、踩坑记录 & 最佳实践
坑 1:直接修改 state
// ❌ 错误 const addTodo = (text) => { todos.push({ id: Date.now(), text }); // 直接修改原数组 setTodos(todos); };后果:React 检测不到变化,界面不更新。
正确做法:永远使用setTodos([...])创建新数组。
坑 2:在子组件中“缓存” props
// ❌ 错误 const TodoItem = ({ todo }) => { const [completed, setCompleted] = useState(todo.completed); // 危险! // ... };后果:当父组件的todo.completed变化时,子组件的completed不会同步更新,因为useState只在首次渲染时生效。
正确做法:直接在 JSX 中使用props.todo.completed,不要用 state 去“复制” props。
坑 3:回调函数中忘记传递参数
// ❌ 错误 <button onClick={onDelete}>Delete</button> // 没有传 id // ✅ 正确 <button onClick={() => onDelete(todo.id)}>Delete</button>最佳实践清单
□
状态提升:多个子组件共享的数据,提升到最近的共同父组件中管理
□
不可变数据:使用扩展运算符
[...]、{...}、map、filter等创建新数据□
props 只读:子组件绝不修改 props 中的数据
□
回调命名规范:用
on开头,如onAdd、onToggle、onDelete,一看就知道是事件□
单一数据源:一个数据只在一个地方管理,不要多处存副本
七、总结
回顾一下,我们从零实现了一个完整的 Todo 应用,核心只有三点:
- 状态在哪里?在父组件 App 中,通过
useState持有todos数据。 - 数据怎么传递?通过props从父组件流向子组件。
- 数据怎么修改?子组件通过调用父组件传递的回调函数,向父组件发送“修改请求”。
这就是 React 最经典的父子组件通信模式,也是单向数据流的最佳实践。
最后送你一句话:在 React 的世界里,数据像水一样往下流,变化像信号一样往上发。理解了这一点,你就能驾驭任何复杂的组件通信。