React组件通信实战:从Todo应用彻底搞懂父子、子父、兄弟通信
前言
刚接触React的同学一定对组件间的通信感到困惑:
- 父组件怎么把数据传给子组件?(父→子)
- 子组件怎么通知父组件修改数据?(子→父)
- 没有直接关系的兄弟组件又该如何共享状态?(兄弟↔兄弟)
别担心,今天我们就通过一个极简的Todo应用,把这些通信方式一次理清楚。你会学到:
- React单向数据流到底是什么
- props如何传递数据和函数
- 状态提升如何解决兄弟通信
- 最后,还会教你如何用 localStorage + useEffect 实现数据持久化,让Todo列表刷新后依然存在
项目代码简洁,但五脏俱全,非常适合初学者理解和上手。让我们开始吧!
项目初始化与技术栈
- React + Vite(快速构建)
- Stylus(CSS预处理器,本文重点不在样式)
- 最后会用到 localStorage 做数据持久化
项目结构:
src/
components/
TodoInput.jsx # 输入框组件
TodoList.jsx # 列表展示组件
TodoStats.jsx # 统计信息组件
App.jsx # 根组件,持有共享数据
styles/
app.styl
一、核心概念回顾:单向数据流与状态提升
在开始写代码前,我们先理解两个React最重要的概念:
1.1 单向数据流
React的数据是从父组件流向子组件的(通过props)。子组件不能直接修改收到的props,因为props是只读的。这保证了数据的可预测性——数据变化的原因一定来自组件自身(state)或父组件传递的新props。
1.2 状态提升
当多个组件需要共享同一份数据时,我们应该将这份数据提升到它们最近的共同父组件中,由父组件管理,然后通过props分发给子组件。子组件想修改数据,必须调用父组件传递的回调函数,由父组件真正修改数据。
这正是Todo应用的设计思想:todos数组作为共享状态,放在App组件中,三个子组件都通过props获取数据或回调。
二、完整代码(无持久化版本)
为了让大家先对整个项目有一个整体认识,我们先给出不包含持久化的完整代码。每个文件都包含了详细的注释,方便理解。
App.jsx
import { useState } from 'react';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';
import TodoStats from './components/TodoStats';
function App() {
// 核心数据:todos 数组,包含所有任务
const [todos, setTodos] = useState([]);
// 添加任务
const addTodo = (text) => {
// 使用展开运算符创建新数组,保持不可变性
setTodos([...todos, { id: Date.now(), text, completed: false }]);
};
// 删除任务
const deleteTodo = (id) => {
// filter 返回新数组,删除指定 id 的任务
setTodos(todos.filter(todo => todo.id !== id));
};
// 切换任务完成状态
const toggleTodo = (id) => {
// map 返回新数组,切换指定任务的 completed 状态
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
// 清除所有已完成任务
const clearCompleted = () => {
// filter 保留未完成的任务
setTodos(todos.filter(todo => !todo.completed));
};
// 派生数据:从 todos 计算统计信息
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
return (
<div className="todo-app">
<h1>My Todo List</h1>
{/* 子组件通过 props 接收数据和回调 */}
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onDelete={deleteTodo}
onToggle={toggleTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
);
}
export default App;
components/TodoInput.jsx
import { useState } from 'react';
const TodoInput = ({ onAdd }) => {
// 内部状态:输入框的值(只属于这个组件,无需提升)
const [inputValue, setInputValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim()) {
// 调用父组件传递的回调,将新任务文本传回去
onAdd(inputValue);
// 清空输入框
setInputValue('');
}
};
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="输入新任务..."
/>
<button type="submit">添加</button>
</form>
);
};
export default TodoInput;
components/TodoList.jsx
const TodoList = ({ todos, onDelete, onToggle }) => {
return (
<ul className="todo-list">
{todos.length === 0 ? (
<li className="empty">暂无任务</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)}>X</button>
</li>
))
)}
</ul>
);
};
export default TodoList;
components/TodoStats.jsx
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
return (
<div className="todo-stats">
<p>总计: {total} | 待办: {active} | 已完成: {completed}</p>
{completed > 0 && (
<button onClick={onClearCompleted} className="clear-btn">
清除已完成
</button>
)}
</div>
);
};
export default TodoStats;
- 效果图
现在你已经看到了完整的项目代码。接下来,我们将分章节详细解释其中的核心知识点。
三、知识点深度解析
3.1 父子组件通信(父→子)
实现方式:父组件通过JSX属性向子组件传递任意类型的数据。
在App.jsx中,我们可以看到多处父子通信的例子:
<TodoList todos={todos} />:将todos数组传递给TodoList组件。<TodoStats total={todos.length} active={activeCount} completed={completedCount} />:将统计信息(数字)传递给TodoStats组件。
子组件通过props对象接收这些数据。例如在TodoList中:
const TodoList = ({ todos, onDelete, onToggle }) => { ... }
这里的{ todos }就是从父组件传递过来的数据。
特点:
- props是只读的,子组件不能修改它们。
- 如果传递的是对象/数组,传递的是引用,子组件虽然不能直接赋值,但可以修改对象内部的属性(不推荐)。最佳实践是保持不可变。
3.2 子父组件通信(子→父)
子组件不能直接修改父组件的状态,但可以通过调用父组件通过props传递的函数来“请求”父组件修改状态。
在App.jsx中,父组件定义了几个修改状态的方法:addTodo、deleteTodo、toggleTodo、clearCompleted。然后将这些方法通过props传递给子组件,通常以on开头命名。
例如,TodoInput接收onAdd:
<TodoInput onAdd={addTodo} />
在TodoInput内部,当表单提交时,调用onAdd(inputValue),将新任务的文本传回父组件。
同样,TodoList接收onDelete和onToggle,在点击删除按钮或复选框时调用这些回调,并传递todo.id。
TodoStats接收onClearCompleted,点击按钮时调用。
关键点:
- 子组件只是触发事件,真正的修改逻辑在父组件中。
- 数据变化的原因集中在父组件,便于追踪和维护。
3.3 兄弟组件通信(通过共同父组件)
兄弟组件之间没有直接通信,而是通过它们共同的父组件作为桥梁。
以添加任务为例:
TodoInput调用onAdd,将新任务文本传给父组件App。- 父组件执行
addTodo,更新todos状态。 - 父组件重新渲染,将新的
todos传给TodoList,将重新计算的activeCount和completedCount传给TodoStats。 TodoList和TodoStats接收到新的props,自动更新视图。
这样,TodoList和TodoStats虽然不直接联系,但通过父组件的状态变化实现了同步。这就是状态提升的核心思想:将共享状态提升到最近的共同父组件中。
为什么这是最佳实践?
- 单一数据源:所有共享数据都在父组件,修改也集中于此。
- 组件解耦:每个子组件只依赖自己的props,不关心其他组件。
- 可预测:数据流是单向的,从父到子,变化原因来自子组件的回调。
3.4 不可变更新
在父组件的修改方法中,我们使用了展开运算符(...)、filter、map等方法来返回新数组,而不是直接修改原数组。
例如:
// 添加:创建新数组,包含原数组所有元素再加一个新元素
setTodos([...todos, { id: Date.now(), text, completed: false }]);
// 删除:filter 返回新数组,不含指定 id
setTodos(todos.filter(todo => todo.id !== id));
// 切换:map 返回新数组,指定 id 的元素替换为新对象
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
为什么必须这样做?
React通过浅比较状态的前后引用是否变化来决定是否重新渲染。如果直接修改原数组(例如todos.push(newTodo)),然后调用setTodos(todos),由于引用未变,React可能不会触发更新。因此,必须返回一个新的数组或对象。
3.5 受控组件
在TodoInput中,<input>元素的值绑定到inputValue状态,并通过onChange事件更新状态。这种模式称为受控组件。
<input
type="text"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
/>
这样,React state成为“唯一数据源”,输入框的值始终与状态同步,方便处理表单逻辑。
四、扩展:实现数据持久化(localStorage + useEffect)
目前我们的Todo应用功能完整,但刷新页面后数据会丢失。为了解决这个问题,我们可以利用浏览器的localStorage将数据保存在硬盘上。
4.1 为什么需要持久化?
- 提升用户体验:用户关闭浏览器后再次打开,之前的任务依然存在。
- 让应用更像一个“真实”的应用。
4.2 初始化时从localStorage读取
修改App.jsx中的useState,使用惰性初始函数从localStorage读取初始数据:
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
这样,组件初始化时,如果localStorage中已有保存的todos,就使用它;否则使用空数组。惰性初始函数保证读取操作只在初始化时执行一次,避免每次渲染都读取。
4.3 监听变化并保存到localStorage
我们希望每当todos变化时,自动将最新数据写入localStorage。这可以用useEffect实现:
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
- 第一个参数是副作用函数,执行保存操作。
- 第二个参数是依赖数组
[todos],表示只有当todos变化时才执行该函数。 - 组件首次渲染时也会执行一次(如果todos有初始值,就会保存一次,不影响)。
4.4 完整代码(包含持久化)
只需修改App.jsx,添加上述两处代码,其他组件完全不变。修改后的App.jsx如下:
import { useState, useEffect } from 'react';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';
import TodoStats from './components/TodoStats';
function App() {
// 初始化时从localStorage读取
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 clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
// 派生数据
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
// 持久化:todos变化时自动保存
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={clearCompleted}
/>
</div>
);
}
export default App;
4.5 注意事项
useEffect在浏览器完成布局与绘制之后执行,不会阻塞渲染。- 保存复杂对象时需要使用
JSON.stringify,读取时使用JSON.parse。 - 如果todos很大,频繁写入localStorage可能影响性能,可以添加防抖优化,但本例中无需。
4.6效果图
我们可以看到当页面刷新时原来的数据依然存在
五、总结与思考
5.1 三种通信方式回顾
| 通信类型 | 实现方式 | 代码示例 |
|---|---|---|
| 父→子 | props传递数据 | <TodoList todos={todos} /> |
| 子→父 | 父组件传递回调函数,子组件调用 | onAdd={addTodo},子组件内onAdd(text) |
| 兄弟↔兄弟 | 通过共同的父组件状态提升 | 兄弟组件都依赖父组件的todos,并通过父组件回调修改 |
5.2 持久化要点
- 使用
useState惰性初始化从localStorage读取初始数据。 - 使用
useEffect监听数据变化,自动同步到localStorage。
5.3 最佳实践总结
- 状态尽可能提升:需要共享的状态放在最近的共同父组件中。
- props只读:永远不要在子组件中修改props。
- 回调命名规范:以
on开头,如onDelete。 - 不可变更新:使用展开运算符或
map/filter返回新数组,不要直接修改原状态。 - 分离UI状态和业务状态:如表单输入使用内部state,核心数据放在父组件。
5.4 常见问题
Q:为什么子组件不能直接修改props? A:如果子组件可以修改props,数据变化源头将不可追溯,调试困难。React的设计哲学是数据自上而下流动,修改必须通过事件向上传递。
Q:兄弟组件必须通过父组件通信吗? A:如果它们没有共同的父组件,或者层级太深,可以使用Context或状态管理库。但大多数情况下,提升状态到共同父组件是最简单可靠的方式。
Q:使用useState更新数组/对象时,为什么一定要返回新引用?
A:React通过浅比较决定是否重新渲染。如果直接修改原数组,然后调用setTodos(todos),由于引用未变,React可能不会触发更新。必须返回一个新数组。
遇到问题欢迎留言讨论!
最后:希望这篇文章能帮你彻底搞懂React组件通信。如果你觉得有用,请点赞收藏,让更多初学者看到!