通过一个经典 Todo 应用,深入理解 React 组件通信的核心机制与设计哲学,以及为什么状态更新必须"不可变"
导语
如果你已经写过 React 项目,一定遇到过这样的困惑:为什么子组件不能直接修改父组件的状态?为什么用 todos.push() 后调用 setTodos 页面却不更新?props 传递回调函数到底是在做什么?
这些问题的根源,都指向 React 最核心的两个设计理念------单向数据流 和不可变数据(Immutability) 。
今天,我通过一个完整的 Todo List 项目,从 JavaScript 底层数据类型开始,帮你彻底搞懂 React 父子组件通信的完整机制。这不仅仅是一篇 API 教程,更是一次对 React 设计哲学的深度剖析。
一、核心概念提炼:数据在哪里,修改权就在哪里
1.1 用 "银行账户" 理解组件通信
想象一个场景:父组件是一家银行总行,子组件是各个支行网点。
- 数据(todos) = 总行的核心账户数据
- 修改数据的方法(setTodos) = 银行的交易系统
- 子组件展示数据 = 网点查询余额
- 子组件请求修改 = 客户到柜台办理存取款
关键规则:任何一家支行都不能直接修改总行的账户数据,必须走正规流程(填单→柜员操作→系统更新) 。
对应到代码中:
- 数据
todos由父组件 App 持有 - 修改方法
setTodos只在父组件定义 - 子组件通过
props接收数据(只读) - 子组件通过回调函数(
onAdd、onToggle等)发送修改请求
1.2 本项目的核心功能
这是一个标准的 Todo 管理应用,包含四个核心功能:

二、痛点与场景:为什么需要这套通信机制?
2.1 实际开发中的痛点
场景一:表单组件需要提交数据到父页面
你在写一个登录表单,子组件 LoginForm 需要把账号密码传给父组件 App 进行登录请求。如果子组件能直接修改父组件的状态,代码会变得极度混乱------你根本不知道数据是在哪里被改的。
场景二:列表项需要删除自己
TodoList 的每个列表项都有一个 "删除" 按钮,点击后需要从父组件的 todos 数组中移除该项。子组件怎么通知父组件删除哪一条?
场景三:多个子组件共享同一份数据
TodoInput 要添加任务,TodoList 要展示任务,TodoStatus 要统计任务数量。如果每个子组件各存一份数据,数据同步将是一场噩梦。
2.2 单向数据流的价值
React 强制单向数据流的根本原因:
| 问题 | 双向绑定的弊端 | 单向数据流的优势 |
|---|---|---|
| 数据来源 | 数据在多个组件间互相修改,来源不清晰 | 所有数据只有一个来源(父组件) |
| 调试难度 | 数据变化难以追踪,不知道谁改的 | 数据变化链路清晰:用户操作→子组件发请求→父组件修改 |
| 可维护性 | 组件耦合度高,修改一个影响一片 | 组件职责清晰,低耦合高内聚 |
三、重难点剖析(核心)
3.1 重难点一:子组件如何"安全地"修改父组件状态?
设计者为什么这么写?
React 的设计哲学是 "数据向下流动,事件向上传递" 。子组件不能直接修改父组件的状态,这是为了保证数据的可预测性。
来看代码中的核心实现:
jsx
ini
// ✅ 正确做法:父组件定义修改函数,通过 props 传给子组件
const App = () => {
const [todos, setTodos] = useState([...]);
// 父组件定义修改方法
const addTodo = (text) => {
if(text.trim() === '') return;
setTodos([
{id: +Date.now(), text, completed: false},
...todos,
]);
};
// 通过 props 传递修改方法的引用
return <TodoInput onAdd={addTodo} />;
};
// 子组件接收并调用
const TodoInput = ({ onAdd }) => {
const [inputValue, setInputValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onAdd(inputValue); // 📤 发送修改请求
setInputValue('');
};
return <form onSubmit={handleSubmit}>...</form>;
};
理解思路:
- 借用 +Date.now() 生成唯一 ID:利用时间戳保证 id 的唯一性,避免使用索引作为 key
- 展开运算符创建新数组 :
[...todos]确保状态不可变,触发重新渲染 - 回调函数本质是"请求" :子组件调用
onAdd只是发起请求,真正的修改在父组件执行
数据流时序图:

3.2 重难点二:状态更新时,为什么必须创建"新数组/新对象"?------从 JavaScript 数据类型说起
这是很多 React 新手最容易踩的坑,也是面试最高频的考点。要理解这个问题,我们得先回到 JavaScript 最基础的数据类型。
3.2.1 JavaScript 的两种数据类型
值类型(Primitive Types)
值类型存储在栈内存中,赋值时会创建完整的独立副本。
| 类型 | 示例 | typeof 返回值 |
|---|---|---|
| String | "hello" |
"string" |
| Number | 123 |
"number" |
| Boolean | true |
"boolean" |
| Null | null |
"object"(历史遗留) |
| Undefined | undefined |
"undefined" |
| Symbol | Symbol('id') |
"symbol" |
javascript
ini
let a = 10;
let b = a; // b 获得了 a 的值的独立副本
b = 20;
console.log(a); // 10 ------ a 没有被改变
console.log(b); // 20
引用类型(Reference Types)
引用类型存储在堆内存中,变量保存的是内存地址(指针),赋值时只复制地址。
| 类型 | 示例 | typeof 返回值 |
|---|---|---|
| Object | {name: 'Alice'} |
"object" |
| Array | [1, 2, 3] |
"object" |
| Function | () => {} |
"function" |
javascript
ini
const original = { name: 'Alice', age: 25 };
const copy = original; // copy 只复制了内存地址,指向同一个对象
copy.age = 30;
console.log(original.age); // 30 ------ 原始对象也被修改了!
console.log(original === copy); // true ------ 引用相同
3.2.2 浅拷贝 vs 深拷贝
浅拷贝(Shallow Copy) :只复制第一层数据,嵌套的对象/数组仍然共享引用。
javascript
ini
// 浅拷贝的几种方式
const copy1 = { ...original }; // 扩展运算符
const copy2 = Object.assign({}, original); // Object.assign
const arrCopy = arr.slice(); // 数组 slice
// 浅拷贝的问题
const user = {
name: 'Alice',
address: { city: 'Beijing', district: 'Haidian' }
};
const shallowCopy = { ...user };
shallowCopy.address.city = 'Shanghai';
console.log(user.address.city); // 'Shanghai' ------ 嵌套对象被修改了!
深拷贝(Deep Copy) :复制所有层级的数据,完全独立。
javascript
javascript
// 深拷贝的几种方式
const deepCopy = JSON.parse(JSON.stringify(obj)); // 有局限性
// 或使用 Lodash:import cloneDeep from 'lodash/cloneDeep';
3.2.3 扩展运算符 ... 的作用:实现浅拷贝
回到 Todo 代码中,...todos 的作用:
javascript
ini
// 添加新待办 ------ 创建新数组
setTodos([
{ id: +Date.now(), text, completed: false },
...todos, // 展开原数组的所有元素
]);
// 修改待办 ------ 创建新对象
setTodos(todos.map(todo => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed }; // 创建新对象
}
return todo;
}));
3.2.4 为什么 React 需要这样做?

对比总结:
| 操作 | 结果 | React 是否重新渲染 |
|---|---|---|
todos.push(newTodo) |
修改原数组,引用不变 | ❌ 不会 |
setTodos([...todos, newTodo]) |
创建新数组,引用改变 | ✅ 会 |
todo.completed = true |
修改原对象,引用不变 | ❌ 不会 |
setTodos(todos.map(...)) |
创建新数组/新对象,引用改变 | ✅ 会 |
3.3 重难点三:为什么 toggleTodo 和 deleteTodo 必须判断 id?
javascript
ini
const toggleTodo = (id) => {
setTodos(todos.map(todo => {
if (todo.id === id) { // 🔍 找到目标
return {...todo, completed: !todo.completed};
}
return todo;
}));
}
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id)); // 🔍 过滤掉目标
}
核心作用:在数组中找到"唯一"的目标元素,进行精确操作。
为什么必须用 id,不能用数组下标?
| 方式 | 示例 | 问题 |
|---|---|---|
| 用下标 | todos[index] |
列表增删改后,下标错位,操作会作用于错误的元素 |
| 用 id | todo.id === id |
每个元素有唯一标识,不会混淆 |
javascript
javascript
// ❌ 错误:使用下标
const deleteByIndex = (index) => {
setTodos(todos.filter((_, i) => i !== index));
// 删除第2项后,原本第3项的 index 变成 2,后续操作会错位
};
// ✅ 正确:使用唯一 id
const deleteById = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
// 基于唯一标识过滤,不会受顺序影响
};
为什么 toggleTodo 要返回 {...todo, completed: !todo.completed}?
这是为了在"不可变数据"原则下更新对象属性:
javascript
ini
// ❌ 错误:直接修改原对象
const toggleTodo = (id) => {
const todo = todos.find(t => t.id === id);
todo.completed = !todo.completed; // 修改了堆内存中的对象
setTodos(todos); // 数组引用没变,React 不重新渲染!
};
// ✅ 正确:创建新对象
const toggleTodo = (id) => {
setTodos(todos.map(todo => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed }; // 新对象
}
return todo;
}));
};
3.4 重难点四:状态提升(Lifting State Up)的实战应用
设计者为什么这么写?
所有子组件(TodoInput、TodoList、TodoStatus)都需要访问 todos 数据,所以把数据提升到最近的公共父组件 App 中管理。

理解思路:
- 单一数据源(Single Source of Truth) :所有数据只存一份,避免数据不一致
- 派生状态(Derived State) :
activeCount和completedCount不存为 state,而是通过计算得到 - 组合优于继承:组件通过 props 组合数据和行为,而不是继承
jsx
ini
// 派生状态:通过计算得到,不需要 useState
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.length - activeCount;
// 传递给子组件
<TodoStatus
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
四、避坑指南/最佳实践
4.1 常见坑:在子组件中直接修改 props
jsx
ini
// ❌ 错误:直接修改 props
const TodoItem = ({ todo }) => {
const handleClick = () => {
todo.completed = !todo.completed; // 严重错误!
};
return <span onClick={handleClick}>{todo.text}</span>;
};
// ✅ 正确:通过回调通知父组件
const TodoItem = ({ todo, onToggle }) => {
const handleClick = () => {
onToggle(todo.id); // 发送修改请求
};
return <span onClick={handleClick}>{todo.text}</span>;
};
4.2 常见坑:忘记阻止表单默认提交
jsx
scss
// ❌ 错误:表单提交会刷新页面
const handleSubmit = (e) => {
onAdd(inputValue); // 页面刷新,状态丢失
};
// ✅ 正确:阻止默认行为
const handleSubmit = (e) => {
e.preventDefault(); // 必须调用
onAdd(inputValue);
setInputValue('');
};
4.3 常见坑:直接修改状态导致视图不更新
| 错误做法 | 正确做法(不可变更新) |
|---|---|
todos.push(item) |
setTodos([...todos, item]) |
todos.splice(index, 1) |
setTodos(todos.filter((_, i) => i !== index)) |
todos[index].completed = true |
setTodos(todos.map((item, i) => i === index ? {...item, completed: true} : item)) |
user.name = 'Bob' |
setUser({...user, name: 'Bob'}) |
4.4 判断值类型 vs 引用类型的技巧
javascript
javascript
// 1. typeof 操作符(基础判断)
typeof 'hello' // 'string'
typeof 123 // 'number'
typeof {} // 'object'
typeof [] // 'object' ------ 数组也是 object
// 2. Array.isArray()(判断数组)
Array.isArray([1, 2, 3]) // true
Array.isArray({}) // false
// 3. 比较引用是否相同
const a = { name: 'Alice' };
const b = a;
const c = { ...a };
console.log(a === b); // true ------ 指向同一个对象
console.log(a === c); // false ------ 虽然是"内容相同",但引用不同
4.5 最佳实践清单
| 实践项 | 说明 |
|---|---|
✅ 使用 +Date.now() 生成唯一 ID |
简单有效,避免使用索引作为 key |
| ✅ 使用函数式更新 | setTodos(prev => [...prev, newTodo]) 确保拿到最新状态 |
| ✅ 解构 props | const TodoList = ({ todos, onToggle }) => 清晰明了 |
| ✅ 条件渲染 | todos.length === 0 && <li>暂无任务</li> |
| ✅ 派生状态不存 state | 能用 filter 算出来的就不存 useState |
| ✅ 扩展运算符创建新对象/数组 | 确保不可变更新,触发重新渲染 |
| ✅ 使用唯一 id 操作列表项 | 避免下标错位导致的 bug |
五、面试高频考点
Q1:为什么子组件不能直接修改父组件的状态?
回答要点:
- 单向数据流原则:React 设计为数据从上往下流,保证可预测性
- 调试便利性:数据变化链路清晰,易于追踪 bug
- 解耦:子组件不依赖父组件的具体实现,只通过 props 接口通信
- 性能优化:父组件可以控制何时更新,避免不必要的渲染
Q2:直接修改 todos[0].completed = true 后调用 setTodos(todos),为什么视图不更新?
回答要点:
- 引用未变化:数组引用相同,React 浅比较认为状态未变
- 违背不可变原则:直接修改了堆内存中的对象
- 正确做法 :
setTodos(todos.map((todo, i) => i === 0 ? {...todo, completed: true} : todo))
Q3:setState 是同步还是异步的?
回答要点:
- React 合成事件中异步 :
setState会被合并批量更新,提升性能 - 原生事件中同步 :在
addEventListener或setTimeout中是同步的 - 函数式更新获取最新值 :
setTodos(prev => [...prev, new])可以拿到最新状态
Q4:扩展运算符 ... 是深拷贝还是浅拷贝?
回答要点:
- 是浅拷贝:只复制第一层,嵌套结构仍然共享引用
- 证明 :
const copy = {...obj}; copy.nested.key = 'new'会修改原对象 - React 场景 :单层对象更新用
...就够用,深层嵌套需要配合递归或 Immer
Q5:为什么 React 强调不可变数据(Immutability)?
回答要点:
- 简化比较:通过引用变化即可判断数据是否改变,无需深度对比
- 时间旅行:每次修改都产生新状态,方便实现撤销/重做功能
- 避免副作用:不可变数据让函数更容易预测,减少 bug
- 性能优化 :配合
React.memo和PureComponent实现精准渲染