React学习:通过TodoList,完整理解组件通信
React 组件通信从零到精通:用一个完整 Todo List 项目彻底搞懂父子、子父与兄弟通信
最近学习了React中完整点的组件通信,包括父传子,子传父,兄弟组件通信。概念听起来简单——props 向下传、回调向上传、状态提升——但真正写代码时,总觉得迷迷糊糊。于是我通过一个 Todo List 功能讲解,结合真实代码,一点点拆解了组件通信的每一个细节。
从最基础的父传子开始,到子传父的回调机制,再到兄弟组件的状态提升,最后深入到大家经常问的“为什么 onChange 要包箭头函数”这类细节。全程基于一个可运行的 Todo List 项目,代码全部贴出,讲解尽量通俗、细致,适合初学者反复阅读,也适合有经验的同学复习巩固。
项目整体结构:经典的状态提升模式
先看整个项目的组件树:
App(父组件)
├── TodoInput(添加输入框)
├── TodoList(列表展示 + 删除 + 切换完成状态)
└── TodoStats(统计 + 清除已完成任务)
核心数据 todos 数组只在 App 组件中用 useState 管理。三个子组件都不直接持有或修改 todos,而是通过 props 接收数据和修改方法。
这就是 React 官方推荐的状态提升(Lifting State Up) :把多个组件需要共享的状态提升到它们最近的共同父组件中统一管理。
这样做的好处:
- 数据有单一真相来源(single source of truth)
- 避免数据不同步的 bug
- 逻辑集中,容易维护
一、父组件 → 子组件:单向数据流与 Props 传递
React 的核心原则是单向数据流:数据只能从父组件通过 props 向下传递,子组件不能直接修改父组件的数据。
在 App.jsx 中,我们把 todos 数据、统计数字、各种操作函数都通过 props 传给了子组件:
// App.jsx 关键片段
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onDelete={deleteTodo}
onToggle={toggleTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={onClearCompleted}
/>
子组件只需要接收 props,使用即可,完全不需要关心数据是怎么来的、怎么改的。
关于父传子的单项数据可以看我上一篇文章# React 学习:父传子的单项数据流——props
二、子组件 → 父组件:回调函数上报事件(深度详解)
子组件如何影响父组件的状态?这正是 React 组件通信中最核心、最容易混淆的部分。
很多人误以为“子传父”是子组件把数据直接塞给父组件,其实完全不是!
React 中“子传父”的正确姿势是:父组件提前定义好一个回调函数,通过 props 传给子组件;子组件在合适时机调用这个函数,把必要的信息“上报”给父组件,由父组件决定如何更新自己的状态。
这套机制在我们的三个子组件中都有体现,下面结合代码一步一步彻底拆解它的实现原理。
子传父的完整四步流程
- 父组件定义回调函数(负责真正修改状态)
- 父组件通过 props 把回调函数传给子组件
- 子组件接收回调,并在事件触发时调用它(上报数据或事件)
- 回调执行 → 父组件状态更新 → 触发重新渲染 → 新数据通过 props 再次向下传递
下面以“添加新 Todo”为例,逐行代码演示这个闭环。
示例 1:TodoInput 添加新事项(子传父经典案例)
步骤 1:父组件定义回调函数 addTodo
// App.jsx
const addTodo = (text) => {
setTodos(prev => [...prev, {
id: Date.now(),
text,
completed: false
}]);
};
这个函数接收一个 text 参数,负责把新事项添加到状态中。
步骤 2:父组件通过 props 传递回调
<TodoInput onAdd={addTodo} /> // 注意:传的是函数本身,不是调用
步骤 3:子组件接收并在提交时调用
// TodoInput.jsx
const TodoInput = ({ onAdd }) => { // 解构接收
const [inputValue, setInputValue] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
const text = inputValue.trim();
if (!text) return;
onAdd(text); // ← 关键!上报用户输入的文本
setInputValue(""); // 清空输入框
};
return (
<form onSubmit={handleSubmit}>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button type="submit">Add</button>
</form>
);
};
步骤 4:闭环完成
- 用户输入并提交 → onAdd(text) 被调用 → 执行父组件的 addTodo
- setTodos 更新状态 → App 重新渲染 → 新 todos 通过 props 传给 TodoList → 列表自动显示新项
示例2. TodoList:删除和切换完成状态
// TodoList.jsx
const TodoList = ({ todos, onDelete, onToggle }) => {
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)} // 上报 id
/>
<span>{todo.text}</span>
</label>
<button
className="delete-btn"
onClick={() => onDelete(todo.id)} // 上报 id
>
×
</button>
</li>
))
)}
</ul>
);
};
export default TodoList;
关键点:
- 复选框也是受控组件:checked 值来自 props 中的 todo.completed
- 点击复选框或删除按钮时,分别调用 onToggle(todo.id) 和 onDelete(todo.id),把当前事项的 id 上报给父组件
- 父组件根据 id 找到对应项并更新状态
父组件中的实现:
const deleteTodo = (id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
const toggleTodo = (id) => {
setTodos(prev => prev.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
));
};
示例3. TodoStats:清除已完成事项
// TodoStats.jsx
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
return (
<div className="todo-stats">
<p>
Total: {total} | Active: {active} | Completed: {completed}
</p>
{completed > 0 && (
<button onClick={onClearCompleted} className="clear-btn">
Clear Completed
</button>
)}
</div>
);
};
export default TodoStats;
关键点:
- 统计数字由父组件提前计算好传下来,避免子组件重复计算
- 点击清除按钮时调用 onClearCompleted() 上报事件
父组件实现:
const onClearCompleted = () => {
setTodos(prev => prev.filter(todo => !todo.completed));
};
子传父的核心本质总结
- 不是子组件“给”父组件数据,而是子组件“通知”父组件:“嘿,发生了一件事(用户点了添加/删除/切换),需要的参数我给你,你自己看着办。”
- 所有状态修改权永远掌握在父组件手里,子组件只有“上报权”。
- 这种“事件向上冒泡、数据向下流动”的模式,正是 React 单向数据流的完美体现。
掌握了这个机制,你就真正理解了为什么 React 说“数据流是单向的”,却依然能轻松实现复杂的交互。
完整子组件代码(带详细注释)
TodoInput.jsx
import { useState } from "react";
const TodoInput = ({ onAdd }) => {
const [inputValue, setInputValue] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
const text = inputValue.trim();
if (!text) return;
onAdd(text); // 子 → 父:上报新事项文本
setInputValue("");
};
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="输入事项后按回车或点击添加"
/>
<button type="submit">Add</button>
</form>
);
};
export default TodoInput;
TodoList.jsx
const TodoList = ({ todos, onDelete, onToggle }) => {
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)} // 子 → 父:上报要切换的 id
/>
<span>{todo.text}</span>
</label>
<button
className="delete-btn"
onClick={() => onDelete(todo.id)} // 子 → 父:上报要删除的 id
>
×
</button>
</li>
))
)}
</ul>
);
};
export default TodoList;
TodoStats.jsx
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
return (
<div className="todo-stats">
<p>Total: {total} | Active: {active} | Completed: {completed}</p>
{completed > 0 && (
<button onClick={onClearCompleted} className="clear-btn">
Clear Completed
</button>
)}
</div>
);
};
export default TodoStats;
三、为什么 onChange={() => onToggle(todo.id)} 必须包箭头函数?
这是初学者最容易踩的坑之一,我们详细拆解。
错误写法 1:直接调用
onChange={onToggle(todo.id)} // 灾难性错误!
渲染时就会立即执行 onToggle(todo.id),导致:
- 页面加载瞬间所有任务状态翻转
- 可能引发无限渲染循环
错误写法 2:只传函数不传参
jsx
onChange={onToggle}
React 会把 event 对象传给 onToggle,但我们需要的是 id,导致切换失败。
正确写法:箭头函数包裹
onChange={() => onToggle(todo.id)}
只有用户真正点击时才执行,并正确传递 id。
四、完整 App 组件:数据管理中心
import { useEffect, useState } from "react";
import "./styles/app.styl";
import TodoList from "./components/TodoList";
import TodoInput from "./components/TodoInput";
import TodoStats from "./components/TodoStats";
function App() {
// 子组件共享的数据状态
const [todos, setTodos] = useState(() => {
// 高级用法
const saved = localStorage.getItem("todos");
return saved ? JSON.parse(saved) : [];
});
// 子组件修改数据的方法
const addTodo = (text) => {
setTodos([
...todos,
{
id: Date.now(), // 时间戳
text,
completed: false,
},
]);
};
const deleteTodo = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
const toggleTodo = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id
? {
...todo,
completed: !todo.completed,
}
: todo
)
);
};
const activeCount = todos.filter((todo) => !todo.completed).length;
const completedCount = todos.filter((todo) => todo.completed).length;
const onClearCompleted = () => {
setTodos(todos.filter((todo) => !todo.completed));
};
useEffect(() => {
localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]);
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={onClearCompleted}
/>
</div>
);
}
export default App;
五、最终效果展示:
初始状态
添加示例
勾选完成
六、总结:掌握这套模式,就掌握了 90% 的组件通信
通过这个 Todo List,我们完整实践了:
- 父传子:props 单向传递
- 子传父:回调函数上报事件(深度掌握)
- 兄弟通信:状态提升
- 常见坑避免:事件处理正确写法
这套模式简单、可靠、可预测,是 React 项目的基石。
当项目更大时,再学习 Context 或状态管理库。但请记住:万丈高楼平地起,先把这套基础打牢。
希望这篇文章能帮你彻底弄懂 React 组件通信的本质。下次写代码时,遇到数据流动问题,先问自己:“这个状态该谁管?回调要不要传参?箭头函数包好了吗?”