拒绝背八股,从一个真实场景出发,吃透 React 组件间通信的底层逻辑
写在前面
很多初学者在学习 React 时,最先被"卡住"的地方往往不是 JSX 语法,也不是 hooks 的用法,而是一个看似简单却至关重要的问题:组件之间到底怎么通信?
尤其是父子组件,props 传参、回调函数、状态提升......这些概念单独拎出来都懂,一写代码就懵。
今天,我们不聊虚的。我带你从零到一实现一个完整的 Todo 应用,边写边讲 ,把 React 父子组件通信的每一个细节都掰开揉碎。你会发现,当你真正理解了单向数据流,这些问题都会迎刃而解。
一句话总结本文核心:数据在父组件中持有,通过 props 向下传递;变化通过回调函数向上通知,父组件修改数据后,子组件自动更新。
一、从组件化思维开始
在 React 的世界里,一切皆为组件 。组件化不仅仅是一种代码组织方式,更是一种思维方式。
1.1 拿到设计稿,先画组件树
无论是做一个 Todo 应用,还是搭建一个企业级后台,第一步都不是写代码,而是 "拆" 。
把 UI 拆解成独立的、可复用的组件,并规划好它们之间的层级关系。这就是所谓的 组件树(Component Tree) 。
对于我们的 Todo 应用,界面可以拆分成三块:
scss
App (根组件)
├── TodoInput (输入框 + 添加按钮)
├── TodoList (列表)
│ └── TodoItem (列表项 - 在 TodoList 内部渲染)
└── TodoState (统计 + 清除按钮)
对应的目录结构是这样的:
css
src/
├── App.jsx
├── App.css
├── components/
│ ├── TodoInput.jsx
│ ├── TodoList.jsx
│ └── TodoState.jsx
└── main.jsx
为什么先做这一步? 因为在 vibe coding 时代,清晰的组件结构能让你的思路始终保持在线。有了这张"地图",代码往哪里写、数据往哪里放,一目了然。
二、核心原则:单向数据流
在 React 中,有一条铁律:数据流向是单向的,从父组件到子组件。
说白了就是:父组件是"老板",子组件是"员工"。老板掌握核心数据,员工只负责干活,通过老板给的"指令"(props)来工作。员工不能擅自改数据,只能向老板"打报告"(调用回调函数),由老板来决策是否修改。
这张图,刻在脑子里:
scss
数据 (state) → 通过 props 传递 → 子组件渲染
↑ │
│ │
└────── 回调函数 (callback) ←─────────┘
子组件通过调用回调
通知父组件更新数据
三、实战演练:Todo 应用完整实现
下面我们一步步实现这个 Todo 应用,每一行代码都会对应到上面的原则。
3.1 父组件 App:数据的"大本营"
父组件 App 是整个应用的数据持有者。所有的 Todo 数据都存放在这里,所有修改数据的方法也在这里定义。
javascript
// 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 状态------但注意,这个状态是它自己的私有状态,不需要共享给父组件。
ini
// 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 中渲染,方便展示:
ini
// 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:状态统计
javascript
// 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:
scss
// ❌ 每次渲染都会执行 getInitialValue,即使只在首次使用
const [state, setState] = useState(getInitialValue());
// ✅ 只有在首次渲染时才会执行 getInitialValue
const [state, setState] = useState(() => getInitialValue());
在 Todo 应用中,如果你的初始数据需要从 localStorage 读取,可以这样写:
ini
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
这个细节很小,但在性能敏感的场景下,能帮你避免不必要的计算。
六、踩坑记录 & 最佳实践
坑 1:直接修改 state
scss
// ❌ 错误
const addTodo = (text) => {
todos.push({ id: Date.now(), text }); // 直接修改原数组
setTodos(todos);
};
后果: React 检测不到变化,界面不更新。
正确做法: 永远使用 setTodos([...]) 创建新数组。
坑 2:在子组件中"缓存" props
javascript
// ❌ 错误
const TodoItem = ({ todo }) => {
const [completed, setCompleted] = useState(todo.completed); // 危险!
// ...
};
后果: 当父组件的 todo.completed 变化时,子组件的 completed 不会同步更新,因为 useState 只在首次渲染时生效。
正确做法: 直接在 JSX 中使用 props.todo.completed,不要用 state 去"复制" props。
坑 3:回调函数中忘记传递参数
less
// ❌ 错误
<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 的世界里,数据像水一样往下流,变化像信号一样往上发。理解了这一点,你就能驾驭任何复杂的组件通信。