上来就踩了个最经典的坑
我上周写这个 Todo List 的时候,第一版写得特别快,二十分钟就把界面和逻辑堆完了。然后点复选框试了一下 ------ 纹丝不动。
控制台没报错,代码看着也没写错,我对着 checked={todo.completed} 这行盯了十分钟,来回改了好几种写法,勾选状态就是不更新。当时我人都懵了,心想难道我学的 React 是假的?
后来随手打印了一下 todo 对象,发现值其实已经变了,但界面就是不刷新。那一刻我突然反应过来:我直接在子组件里改了 props 传过来的对象属性。
就是这么个入门级的坑,结结实实卡了我半小时。也正是因为这个坑,我才停下来好好捋了一遍 React 父子组件通信的逻辑,而不是照着教程抄完就完事。
先别急着写代码,先想清楚组件怎么拆
最开始我所有代码全塞在 App.js 里,输入框、列表、统计栏挤成一团。加个 "清除已完成" 按钮都要翻半天找位置,越写越烦躁。
后来干脆停手不写了,先在草稿纸上画组件结构。其实拆分逻辑特别朴素:功能独立的块,就单独拎出来做一个组件。
- 顶部输入框,只负责接收用户输入、添加新 todo,单独拆成
TodoInput - 中间的列表,只负责渲染每一条 todo、处理勾选和删除,拆成
TodoList - 底部的统计数字和清除按钮,只负责展示数据、触发清空操作,拆成
TodoStats
拆完之后整个项目目录一下就清爽了:
scss
src/
├── App.js // 父组件,管所有数据
├── App.css
└── components/
├── TodoInput.js
├── TodoList.js
└── TodoStats.js
说实话以前我总觉得组件拆分是面试用的套话,真自己写一遍才明白好处:以后想改输入框的样式,直接去 TodoInput 里找;想调整列表的排版,就去 TodoList 改。不用在几百行代码里大海捞针。
父子通信到底是个啥逻辑
拆完组件问题就来了:数据放哪?总不能每个组件自己存一份 todo 数据吧,那数据不同步肯定乱套。
答案很简单:数据全部放在最顶层的父组件 App 里统一管理,子组件自己不存数据,只负责渲染和触发事件。
我当时想了个特别土的比方,一下就通了: 父组件 App 就是公司老板,手里攥着唯一的一份任务清单(也就是 todos 状态)。下面三个子组件是三个员工:
- TodoInput 是前台,负责接新任务,但不能自己往清单上写,得报告老板,由老板加进去
- TodoList 是执行部,负责展示任务,员工完成了也不能自己打勾,得告诉老板,老板来改状态
- TodoStats 是行政,只负责数数字,想清掉已完成的任务,也得请示老板
所有修改都必须经过老板,老板改完清单,再把最新版发给所有员工同步。这样就永远不会出现 "你改了我不知道,我改了你不知道" 的情况,数据永远是统一的。
换成 React 的术语就是:数据通过 props 向下传递,事件通过回调函数向上触发。子组件没有权力修改父组件的数据,只能调用父组件传下来的方法,通知父组件 "该改数据了"。
一步步把代码写出来
道理讲通了,写代码其实就顺了。我把完整的可运行代码拆开来,说一下每部分我当时是怎么想的。
父组件:所有数据都攥在手里
父组件的核心就两件事:定义 todos 状态,以及写一堆修改状态的方法。
javascript
import { useState } from "react";
import TodoInput from "./components/TodoInput"
import TodoList from "./components/TodoList"
import TodoStats from "./components/TodoStats"
import "./App.css"
const App = () => {
// 所有 todo 数据都存在这里,唯一数据源
const [todos, setTodos] = useState([
{ id: 1, text: "学习 React", completed: false },
{ id: 2, text: "学习 React Native", completed: false },
{ id: 3, text: "学习 React Hooks", completed: true },
])
// 添加 todo
const addTodo = (text) => {
if(text.trim() === "") return
// 注意这里:必须返回全新的数组,别直接 push 原数组
// 别问我为什么知道,点添加按钮界面纹丝不动的时候你就懂了
setTodos([
{ id: +Date.now(), text: text, completed: false },
...todos
])
}
// 切换 todo 的完成状态
const toggleTodo = (id) => {
// 不仅数组要新,匹配到的那一项也要返回新对象
// 直接 todo.completed = !todo.completed 是没用的
setTodos(todos.map(todo =>
todo.id === id ? {...todo, completed: !todo.completed} : todo
))
}
// 删除单条 todo
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
// 清除所有已完成的 todo
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed))
}
// 统计数据直接根据状态算就行,不用单独存
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.length - activeCount;
return (
<div>
<h1>My Todo List</h1>
{/* 把添加方法传给输入组件 */}
<TodoInput onAdd={addTodo}/>
{/* 把数据和两个操作方法传给列表组件 */}
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
{/* 把统计数字和清除方法传给统计组件 */}
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
)
}
export default App
这里最关键的一点:每次修改状态,都要返回一个全新的数组 / 对象。 React 是通过对比引用地址来判断数据有没有变的。你直接改原数组里的属性,数组本身的地址没变,React 就认为数据没改,自然不会刷新界面。所以 map、filter、展开运算符该用就用,别偷懒。
输入组件:自己的小状态自己管
TodoInput 是我觉得最有意思的一个组件,它既有自己的内部状态,又要和父组件通信。
javascript
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="添加新的 todo"
autoFocus
/>
<button type="submit">添加</button>
</form>
)
}
export default TodoInput
不是所有状态都要往父组件塞的。像输入框里的临时文字,用户打字的过程中根本不需要影响别的组件,就放在子组件自己这里就行。只有用户按下回车、点了添加按钮,真正要新增数据的时候,再调用父组件传过来的 onAdd 方法把内容传上去。
这就是局部状态 和全局状态的区别。该放哪就放哪,别什么都堆到顶层,不然父组件会越来越臃肿。
列表组件:我只负责渲染,改数据别找我
TodoList 就是个典型的 "纯渲染组件",它拿到什么数据就渲染什么界面,自己不存任何数据。
javascript
const TodoList = ({ todos, onToggle, onDelete }) => {
return (
<ul className="todo-list">
{
todos.length === 0 ? (
<li className="empty">暂无 todo</li>
) : (
todos.map(todo =>
<li key={todo.id} className={todo.completed ? "completed" : ""}>
<label>
<input
type="checkbox"
checked={todo.completed}
// 勾选的时候,把 id 传给父组件的方法
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
</label>
{/* 这里一定要包箭头函数!别写成 onClick={onDelete(todo.id)} */}
{/* 我第一次写的时候页面一刷新,所有 todo 全没了,人都傻了 */}
<button onClick={() => onDelete(todo.id)}>删除</button>
</li>
)
)
}
</ul>
)
}
export default TodoList
提个很容易踩的坑 key 别图省事用数组下标 index。如果后面有删除、排序的操作,用 index 当 key 会出现渲染错乱、状态错位的问题。每条数据有唯一 id 就一定要用 id。
另外就是事件回调传参的问题。如果你直接写 onClick={onDelete(todo.id)},页面渲染的时候这行代码就会立刻执行,根本等不到你点击。所以必须用箭头函数包一层,或者用 bind 绑定参数。
统计组件:最纯粹的 "工具人"
TodoStats 就更简单了,连自己的状态都没有,完全靠父组件传进来的 props 渲染。
javascript
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
return (
<div className="todo-stats">
<p>Total: {total} | Active: {active} | Completed: {completed}</p>
{
// 只有已完成数量大于 0 才显示按钮
completed > 0 && (
<button
className="clear-btn"
onClick={onClearCompleted}
>
清除已完成
</button>
)
}
</div>
)
}
export default TodoStats
这种组件也叫 "无状态组件" 或者 "展示组件",它的全部职责就是把传进来的数据显示出来,再把点击事件交还给父组件。逻辑越纯粹,以后越不容易出 bug。
全部写完跑起来就是这个效果:

回头再想:为什么非要绕这一圈?
说实话最开始我觉得这设计也太反人类了,改个数据还要绕一大圈,子组件直接改不就完了?
直到我后来加功能,有一次数据对不上出了 bug。我不用挨个去三个子组件里翻代码,直接打开 App.js,盯着那四个修改状态的函数看,两分钟就定位到问题了。
那一刻我才明白单向数据流的好处。
所有数据修改的入口都收口在父组件里,数据从哪里来、被谁改了、怎么改的,一目了然。如果每个子组件都能随便改数据,项目一大、组件一多,出了问题你根本不知道是哪个组件偷偷改了状态,查 bug 查到怀疑人生。
这就是 React 推崇的单一数据源思想:同一份数据,只在一个地方存储和修改,所有组件都从这里拿数据。规矩定死了,代码就不会乱。
我踩过的三个坑,你们别再踩了
1. 直接修改 state 里的对象 / 数组,界面不更新
这应该是每个 React 初学者都会踩的坑。直接 todo.completed = true 或者 todos.push(xxx),数据变了但界面不刷新。
原因就是 React 靠引用变化判断是否更新。你改了对象里的属性,但对象本身的地址没变,React 就认为数据没变化,不会触发重渲染。
正确姿势:数组用 map、filter、展开运算符返回新数组;对象用展开运算符返回新对象。
2. 事件回调加了括号,页面一加载就执行
写 onClick={handleDelete(id)} 的时候,React 渲染 JSX 就会直接执行这个函数,根本不会等点击事件触发。
正确姿势 :写成 onClick={() => handleDelete(id)},用箭头函数包裹一层,或者用 bind 绑定参数。
3. 刚调用完 setState 就读数据,拿到的是旧值
我当时加完 todo 想立刻打印一下最新的列表,结果每次都少一条。以为是添加逻辑写错了,查了半天才知道 useState 的更新是异步批量的。
调用 setTodos 之后,当前函数作用域里的 todos 还是旧值,要等下一次渲染才会变成新的。如果需要用到最新的数据,直接用你准备 set 进去的那份新数据就行,别去读状态变量。
最后说两句
折腾完这三遍 Todo List,我心里才算真的踏实了,不是那种照着教程抄完的似懂非懂。
第一个感触是,组件拆分真不是面试题里的标准答案,是实实在在能提升开发体验的东西。每个组件只干一件事,以后改哪里动哪里,不会牵一发而动全身。
第二个就是父子通信的核心逻辑其实特别朴素:数据向下传,事件向上走。别总想着走捷径直接改父组件的数据,省了几行代码,以后埋的坑全要自己填。
第三个,处理引用类型的状态,永远记得返回新的。展开运算符、map、filter 用起来,比起找半天界面不更新的 bug,这点性能开销根本不算事。
当然也不是说这种写法就万能。如果以后项目做大了,组件嵌套五六层,要把一个方法从顶层传到最底层,中间每层都要接一下 props,那也挺崩溃的。那时候就该上 Context 或者 Zustand、Redux 这类状态管理库了。
但就像 Todo List 这样的小功能,甚至很多中小型项目,原生的 props 通信完全够用。别上来就整一堆状态管理库,过度设计也是种病。
你们刚学 React 的时候,有没有踩过直接修改 state 的坑?或者有别的什么经典入门 bug?评论区聊聊,我也找点平衡,看看不是我一个人这么菜。