开篇
学 React,很多人一开始会写组件,但不一定真的理解"组件化"。
比如写一个计数器,所有代码都放在 App.jsx 里,好像也没什么问题。
但当页面变成一个 Todo App:
- 有输入框
- 有添加按钮
- 有待办列表
- 有完成状态
- 有删除按钮
- 有统计信息
- 还可能有清除已完成
如果所有逻辑都塞进一个 App.jsx,代码很快就会变成一坨。
React 的组件化,不只是"把页面拆成几个文件"。
更重要的是搞清楚三个问题:
谁负责管理数据?
谁负责展示界面?
子组件怎么通知父组件发生了变化?
这个 Todo App 项目刚好很适合拆解 React 组件化思想。它虽然小,但把几个核心概念都串起来了:
- 组件树
- 本地状态和共享状态
- 数据向下,事件向上
- 状态提升
- 不可变更新
- 受控组件
- 单一数据源
读完这篇文章,你会明白为什么 React 开发里经常说:
先画组件树,再写代码。
一、先画组件树,再写代码
面对 Todo App 这种页面,新手最容易直接开写:
text
一个输入框,一个按钮,一个 ul,循环渲染 li。
这样当然能写出来。
但一旦功能变多,你就会发现:
- 输入框逻辑和列表逻辑混在一起
- 删除逻辑和统计逻辑混在一起
- 状态到底放在哪里开始变得模糊
- 后续想加筛选、排序、持久化会越来越难
所以更好的做法是:先拆组件。
这个 Todo App 可以拆成:
text
App(总管)
├── TodoInput 输入框 + 添加按钮
├── TodoList 待办列表 + 勾选/删除
└── TodoStats 统计信息 + 清除已完成

这就是组件树。
以前写原生 DOM,我们更关心:
text
div 里面套 div,ul 里面套 li
而写 React,我们更关心:
text
哪个组件负责什么职责,数据从哪里来,事件往哪里走
组件树的价值不只是"看起来清楚"。
它直接决定后面的状态设计。
二、组件划分的第一原则:谁需要,谁管理
划分组件不是随便拆文件。
一个很重要的原则是:
状态应该放在所有需要使用它的组件的最近公共父组件里。
这句话听起来有点绕,放到 Todo App 里就很清楚。
todos 这份数据被多个地方需要:
TodoList需要它来渲染列表TodoStats需要它来统计数量TodoInput添加新任务时,需要让父组件把新任务加进todosApp需要协调所有子组件的数据和事件
所以 todos 应该放在 App 里。
jsx
const [todos, setTodos] = useState([
{ id: 1, text: "吃饭", completed: false },
{ id: 2, text: "睡觉", completed: false },
{ id: 3, text: "打豆豆", completed: true },
]);
App 是这个 Todo App 的数据总管。
它维护唯一的 todos 数据源,然后把数据通过 props 传给子组件。
这就是 React 里常说的:
single source of truth,单一数据源。
只有一个地方保存真实数据,其他组件都从这里读取。
这样做的好处是:数据不会散落在多个组件里,也不会出现 A 组件里的数据已经变了,B 组件还拿着旧数据的情况。
三、本地状态和共享状态
不是所有状态都应该放在 App。
比如输入框里的内容:
jsx
const [inputValue, setInputValue] = useState("");
这个状态只属于 TodoInput。
用户正在输入什么,只有输入框自己关心。TodoList 不需要知道,TodoStats 也不需要知道。
所以它应该放在 TodoInput 内部。
这就是本地状态。
而 todos 不一样。
它被列表、统计栏、添加逻辑共同使用,所以它应该提升到共同父组件 App 中。
这就是共享状态。
判断一个状态放在哪里,可以问自己两个问题:
text
谁需要读取它?
谁需要修改它?
如果只有一个组件需要,那就放在这个组件内部。
如果多个兄弟组件都需要,那就往上提,放到它们最近的共同父组件里。
这就是状态提升。
四、数据向下流,事件向上流
组件拆好之后,下一个问题是:
子组件不能直接改父组件的数据,那它怎么让父组件知道"我要添加/删除/切换完成状态"?
React 的答案是:
数据通过 props 向下传,事件通过回调向上传。

比如 App 把 todos 传给 TodoList:
jsx
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
这里有两类 props:
text
todos 数据
onToggle 回调函数
onDelete 回调函数
TodoList 负责展示数据。
当用户点击复选框或删除按钮时,TodoList 不直接修改 todos,而是调用父组件传下来的函数:
jsx
onToggle(todo.id);
onDelete(todo.id);
子组件的意思是:
爸,我这里发生了一个事件,你来决定怎么改数据。
父组件收到事件后,调用 setTodos 更新状态。
状态一变,父组件重新渲染,再把新的 todos 传给子组件。
这就是 React 的单向数据流。
五、TodoInput:本地状态 + 通知父组件
先看输入组件:
jsx
const TodoInput = ({ onAdd }) => {
const [inputValue, setInputValue] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim() === "") return;
onAdd(inputValue);
setInputValue("");
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
);
};
这个组件做了两件事:
- 自己管理输入框内容
- 提交时通知父组件添加任务
inputValue 是本地状态:
jsx
const [inputValue, setInputValue] = useState("");
它只影响输入框自己,不需要提升到 App。
提交时:
jsx
onAdd(inputValue);
TodoInput 并不知道父组件怎么添加任务。
它只负责把用户输入的文本交出去。
父组件里才是真正修改 todos 的地方:
jsx
const addTodo = (text) => {
if (text.trim() === "") return;
setTodos([
{ id: Date.now(), text, completed: false },
...todos,
]);
};
这里有一个小建议:如果子组件里已经判断了空字符串,父组件里也可以保留一次判断。
因为父组件是数据入口,防御性更强。
六、TodoList:只展示,不保存 todos
再看列表组件:
jsx
const TodoList = ({ todos, onToggle, onDelete }) => {
return (
<ul className="todo-list">
{todos.length === 0 ? (
<li className="empty">no todos</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)}>删除</button>
</li>
))
)}
</ul>
);
};
这个组件很干净。
它没有自己的 useState。
因为它不拥有 todos,它只是展示 todos。
它的职责很明确:
todos.length === 0时显示空状态todos.map渲染待办列表- 勾选时调用
onToggle(todo.id) - 删除时调用
onDelete(todo.id)
它不关心父组件怎么切换完成状态。
它也不关心父组件怎么删除任务。
这就是一个好子组件的样子:
数据从 props 来,事件通过回调走。
七、TodoStats:从 todos 推导统计信息
统计组件通常长这样:
jsx
const TodoStats = ({ todos, onClearCompleted }) => {
const total = todos.length;
const completed = todos.filter((todo) => todo.completed).length;
const active = total - completed;
return (
<div className="todo-stats">
<span>全部:{total}</span>
<span>未完成:{active}</span>
<span>已完成:{completed}</span>
<button onClick={onClearCompleted}>清除已完成</button>
</div>
);
};
这里有一个很重要的点:
total、completed、active不需要再用useState保存。
因为它们都可以从 todos 计算出来。
text
total = todos.length
completed = 已完成数量
active = total - completed
这种数据叫派生数据。
派生数据能算出来,就不要额外存 state。
否则你就要维护两份数据的一致性,很容易出 bug。
八、不可变更新:不要直接改原数组
React 状态更新里有一条非常重要的规则:
不要直接修改原数组或原对象,而是创建一个新数组或新对象。
添加任务:
jsx
setTodos([
{ id: Date.now(), text, completed: false },
...todos,
]);
切换完成状态:
jsx
setTodos(
todos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
删除任务:
jsx
setTodos(todos.filter((todo) => todo.id !== id));
清除已完成:
jsx
setTodos(todos.filter((todo) => !todo.completed));
这几种写法都有一个共同点:
返回新数组,而不是在原数组上改。
不要这样写:
jsx
todos.push(newTodo);
setTodos(todos);
也不要这样写:
jsx
todo.completed = !todo.completed;
setTodos(todos);
因为你修改的是原来的数组和对象,容易导致 React 无法可靠判断状态变化,也会让数据流变得不可预测。
更好的习惯是:
text
旧数据进去,新数据出来。
这就是不可变更新。
它不只是为了让页面更新,更是为了让状态变化可追踪、可调试、可维护。
九、受控组件:React 如何接管表单
输入框这里还有一个经典概念:受控组件。
jsx
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
这段代码的意思是:
输入框显示什么,由 React 状态决定。
用户输入时,并不是你手动去改 DOM。
流程是:
text
用户输入
↓
触发 onChange
↓
setInputValue 更新状态
↓
组件重新渲染
↓
input 显示新的 inputValue
这样做有什么好处?
因为表单数据变成了 React 状态。
你想做校验、限制长度、格式化、提交前处理,都可以在状态更新逻辑里完成。
比如限制最多 20 个字:
jsx
onChange={(e) => {
const value = e.target.value;
if (value.length > 20) return;
setInputValue(value);
}}
这就是受控组件的价值。
十、完整流程:添加一条待办发生了什么?
把前面的内容串起来。
当用户添加一条"买菜"时,完整流程是:
text
用户在输入框输入"买菜"
↓
onChange 触发
↓
setInputValue("买菜")
↓
TodoInput 本地状态更新
用户点击 Add
↓
form onSubmit 触发
↓
handleSubmit 执行
↓
e.preventDefault()
↓
onAdd("买菜")
↓
通知父组件 App
App 执行 addTodo
↓
setTodos([新任务, ...旧任务])
↓
todos 更新
↓
App 重新渲染
新的 todos 通过 props 传下去
↓
TodoList 显示"买菜"
↓
TodoStats 数量 +1
全程没有手动操作 DOM。
你只是更新状态。
React 根据状态重新计算 UI。
这就是数据驱动界面。
十一、组件化真正带来了什么?
组件化不是为了显得代码"高级"。
它真正解决的是复杂度。
拆成组件之后:
TodoInput只管输入和提交TodoList只管列表展示和列表事件TodoStats只管统计和清除事件App只管状态和协调
这样一来,需求变化时影响范围就很清楚。
要改输入框样式,只看 TodoInput。
要改空状态文案,只看 TodoList。
要改统计规则,只看 TodoStats。
要接本地存储或远程接口,优先从 App 的状态入口处理。
这就是组件化最实际的价值:
职责清晰,变化可控。
十二、常见误区
误区一:组件拆得越多越好
不是。
组件拆分的目的不是追求文件数量,而是让职责更清楚。
如果一个组件本身很简单,也没有复用需求,强行拆出去反而增加理解成本。
误区二:子组件应该自己修改父组件数据
不应该。
子组件通过回调通知父组件,父组件负责真正修改状态。
这能保证数据流清晰。
误区三:所有数据都要放到 App
不是。
只有多个组件共享的数据才需要往上提。
像输入框临时内容这种只属于 TodoInput 的状态,留在组件内部就很好。
误区四:派生数据也要存 state
不需要。
能从已有 state 计算出来的数据,优先直接计算。
比如 total、completed、active 都可以从 todos 推导出来。
误区五:可以直接 push 修改数组
不建议。
React 状态更新要保持不可变思维。用 map、filter、展开运算符创建新数组、新对象,数据变化才更可靠、更容易调试。
十三、面试怎么答?
如果面试官问:
React 组件化思想是什么?
可以这样答:
React 组件化不是简单地把页面拆成多个文件,而是根据职责拆分 UI 和逻辑。父组件通常负责状态管理和协调,子组件负责展示和触发事件。数据通过 props 向下传递,子组件通过回调把事件传回父组件,父组件更新状态后再驱动子组件重新渲染。
如果面试官问:
React 里为什么强调单向数据流?
可以这样答:
单向数据流能让数据来源更清楚。父组件维护状态,通过 props 传给子组件;子组件不能直接修改父组件状态,只能通过回调通知父组件。这样状态变化路径是可追踪的,组件之间不会互相偷偷改数据,应用更容易维护。
如果面试官问:
什么是状态提升?
可以这样答:
当多个组件都需要共享同一份状态时,应该把这份状态提升到它们最近的共同父组件中,由父组件统一管理,再通过 props 分发给子组件。这可以避免多个组件各自维护一份状态导致数据不同步。
总结
这个 Todo App 虽然只有一百多行代码,但它讲清楚了 React 组件化的核心:
| 知识点 | 结论 |
|---|---|
| 组件树 | 先拆职责,再写代码 |
| App | 负责共享状态和协调子组件 |
| TodoInput | 管理本地输入状态,提交时通知父组件 |
| TodoList | 根据 props 展示列表,通过回调上报事件 |
| TodoStats | 从 todos 推导统计信息 |
| 单向数据流 | 数据向下,事件向上 |
| 状态提升 | 多组件共享状态放到最近公共父组件 |
| 不可变更新 | 用新数组、新对象描述状态变化 |
| 受控组件 | 表单值由 React 状态控制 |
React 组件化最重要的不是"拆文件",而是"拆职责"。
真正写得好的 React 代码,看起来应该像一棵清楚的组件树:
父组件管数据,子组件管展示;数据向下流,事件向上走。
理解了这句话,Todo App 就不只是一个练手项目,而是 React 组件化思想的入门样板。