用一个简单的 Todo 应用,彻底搞懂 React 父子组件通信、不可变更新、浅拷贝深拷贝的本质。
引言
如果你刚开始学 React,一定会遇到这个问题:子组件怎么修改父组件的数据?
答案是:子组件不能直接修改。 那怎么办?通过"打小报告"------子组件通知父组件,让父组件自己来改。
这篇文章用一个完整的 Todo 应用作为例子,把父子组件通信这件事讲透,顺便搞清楚背后藏着的不可变更新、浅拷贝、深拷贝等面试高频考点。
一、一个 Todo 应用,三个组件
先看组件结构:
scss
App (父组件)
├── TodoInput ← 输入框 + 添加按钮
├── TodoList ← 展示列表 + 勾选/删除
└── TodoStats ← 统计 + 批量清除
所有的 todo 数据只存在 App 里,三个子组件通过不同方式和 App 通信。
jsx
const App = () => {
const [todos, setTodos] = useState([
{ id: 1, text: '吃饭', completed: false },
{ id: 2, text: '睡觉', completed: true },
{ id: 3, text: '打豆豆', completed: true },
])
// 所有修改方法都定义在父组件
const addTodo = (text) => { /* ... */ }
const toggleTodo = (id) => { /* ... */ }
const removeTodo = (id) => { /* ... */ }
const clearCompleted = () => { /* ... */ }
return (
<div>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onRemove={removeTodo}
onToggle={toggleTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
)
}
二、父 → 子:Props 往下流
这是最简单的方向------父组件把数据通过 props 传给子组件:
jsx
// 父组件把数据"塞"给子组件
<TodoList todos={todos} />
<TodoStats total={total} active={activeCount} />
// 子组件接收,用于展示
const TodoStats = ({ total, active, completed }) => (
<p>Total: {total} | Active: {active} | Completed: {completed}</p>
)
关键约束:子组件对 props 只能读,不能改。你试图改 props,React 不会报错,但页面不会更新------因为 React 不认为状态变了。
三、子 → 父:回调函数往上"打小报告"
既然子组件不能直接改父组件的数据,那用户点了删除按钮怎么办?
父组件把修改方法作为 props 传给子组件,子组件在合适的时机调用它。
jsx
// 父组件:把"修改权"以回调函数的形式下放
<TodoList onRemove={removeTodo} onToggle={toggleTodo} />
jsx
// 子组件:在事件处理中调用回调,把必要信息传回去
const TodoList = ({ todos, onRemove, onToggle }) => (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)} // 🔔 "id=2 被勾选了"
/>
<span>{todo.text}</span>
<button onClick={() => onRemove(todo.id)}>删除</button>
{/* 🔔 "id=5 要删掉" */}
</li>
))}
</ul>
)
整个过程是:
scss
用户点击删除 → 子组件调用 onRemove(id) → 父组件执行 removeTodo(id)
→ setTodos(新数组) → React 检测到变化 → 重新渲染
→ 新的 todos 通过 props 传到子组件 → 页面更新
我把这个叫**"对讲机模式"**------子组件手里拿的不是数据的钥匙,而是一个对讲机,只能呼叫父组件说"有事情发生了",至于怎么处理,完全由父组件决定。
四、"自定义事件"到底是什么?
你可能会在代码注释里看到"自定义事件"这个词:
jsx
{/* onAdd 是自定义事件,子组件通过它通知父组件 */}
<TodoInput onAdd={addTodo} />
自定义事件 = 父组件传给子组件的回调函数,约定以 on 开头命名。 它跟原生的 onClick、onChange 本质一样,只是名字是我们自己起的。
| 原生 DOM 事件 | React 自定义事件 | |
|---|---|---|
| 谁定义的 | 浏览器 | 我们 |
| 谁触发的 | 浏览器 | 子组件手动调用 |
| 例子 | onClick、onChange |
onAdd、onToggle、onRemove |
五、为什么修改数据必须用拷贝式?
这是面试必考题。看两段代码:
jsx
// ❌ 引用式 --- 看着没问题,实际是 Bug
const addTodoWrong = (text) => {
todos.unshift({ id: Date.now(), text, completed: false })
setTodos(todos) // 引用没变 → React 不渲染 → Bug!
}
jsx
// ✅ 拷贝式 --- 创建新数组,新引用
const addTodo = (text) => {
setTodos([
{ id: Date.now(), text, completed: false },
...todos // 展开 → 新数组
])
}
React 靠引用地址判等(===)来决定是否重新渲染:
arduino
旧引用 === 新引用 ? → true → 跳过渲染(没变化)
旧引用 === 新引用 ? → false → 重新渲染(有变化)
直接改原数组,引用地址不变,React 认为没有变化,页面就不会更新。
所以 React 中修改状态有铁律:
- 数组用
.map()、.filter()、[...arr]------它们都返回新数组 - 对象用
{ ...obj, key: newValue }------返回新对象 - 绝对不碰原数据,只在新副本上操作
六、浅拷贝:展开运算符不告诉你的秘密
很多人以为 [...todos] 就万事大吉了,其实它只是一个浅拷贝。
内存的真相
数组里存的不是对象本身,而是对象的地址:
css
todos ──→ [ 0xA1 , 0xB2 ]
│ │
▼ ▼
0xA1: {吃饭} 0xB2: {睡觉}
浅拷贝做了什么
js
const copy = [...todos]
// copy 是新数组 ✅
// 但里面的元素指向的還是旧对象 ⚠️
copy[0] === todos[0] // true ← 同一个对象!
css
todos ──→ [ 0xA1 , 0xB2 ] ──┬──→ 0xA1: {吃饭}
│
copy ───→ [ 0xA1 , 0xB2 ] ──┘──→ 0xB2: {睡觉}
你用了一根新绳子,但上面拴的还是原来那几把钥匙。
这会影响你的代码吗?
分情况:
- 只增删不修改 (如
addTodo、removeTodo):浅拷贝完全够用,因为旧对象只是被引用,没被改 - 要修改某个旧对象 (如
toggleTodo):必须给被修改的那个对象也做拷贝
jsx
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed } // 这个必须拷贝
: todo // 这个可以共享
))
}
{ ...todo, completed: !todo.completed } 中的 ... 是**"抄"一份属性值**,不是"借"属性。抄完后新对象和旧对象各是各的,互不影响。
js
const obj = { name: '吃饭', done: false }
const newObj = { ...obj, done: true }
console.log(obj.done) // false ← 原对象纹丝不动
console.log(newObj.done) // true ← 新对象独立变化
一句话:旧对象不改 → 浅拷贝共享安全;旧对象要改 → 必须给它也拷贝一份。
七、组件设计的直觉:什么放哪里
这个问题没有银弹,但有一个实用的判断框架:
| 放父组件 | 放子组件 |
|---|---|
| 多个子组件都需要的数据 | 只有自己临时用一下的数据 |
| 修改后会影响全局的方法 | 父组件不需要关心的 UI 状态 |
| 从状态派生出来、多处展示的数据 | 表单输入过程中的中间值 |
对照我们的 Todo 应用:
markdown
App(父组件 --- 所有共享的、全局的东西)
├── 状态: todos ← 三个子组件都要用
├── 方法: addTodo 等 ← 改了todos影响所有人
├── 派生: total / activeCount ← TodoStats 要展示
│
├── TodoInput
│ └── 私有: inputValue ← 只有它自己关心用户正在打什么
├── TodoList
│ └── 私有: 无 ← 纯展示组件
└── TodoStats
└── 私有: 无 ← 纯展示组件
八、总结
React 父子组件通信的核心就一张图:
scss
父组件
│
├── 数据(Props) 往下流 ────────→ 子组件(只读)
│
└── 回调函数(Events) 往上冒 ←──── 子组件(通知)
│
▼
父组件 setState
│
▼
拷贝式赋值(不可变更新)
│
┌───────┴───────┐
▼ ▼
浅拷贝够用 要改的项必须深拷贝
(只增删) (修改旧对象属性)
记住三点就够了:
- 数据向下,事件向上 --- Props Down, Events Up
- 永远拷贝,不修改原值 --- React 靠引用来判断变化
- 只读享共享,要改盖新屋 --- 浅拷贝的旧对象不修改是安全的,要修改就必须拷贝一份
搞清楚了这些,再去学 Context、Redux、Zustand 这些进阶的状态管理方案,你会发现它们只是在"如何优雅地把数据和回调传给子组件"这件事上做了包装,核心原理一点没变。