写在前面:今天学了一个特别"实战"的东西------用 React 写一个 TodoList。老师说,TodoList 是前端框架的"Hello World"------但千万不要觉得它简单。一个 TodoList 虽然只有 4 个组件、几十行代码,但它涵盖了 React 最核心的知识:组件化、响应式、数据驱动、父子通信、Props 向下传递、事件向上报告...... 尤其是"数据状态该放在哪个组件"这个问题------我第一次写把状态放在了三个子组件里,结果数据一团乱。老师说:"数据状态由父组件统一管理,子组件只负责展示和上报事件。" 我就四个组件,分了三次才搞明白。
一、组件树------先规划再动手
1.1 先画组件树
老师说:
"规划一下组件树(项目的目录结构),有利于 Vibe Coding。"
TodoList 的组件树:
css
App(根组件------持有所有数据状态)
├── TodoInput(输入框------新增任务)
├── TodoList(任务列表------展示和操作)
│ └── 每个任务 li(复选框 + 文字 + 删除按钮)
└── TodoStats(统计信息------总数、活跃、已完成)
组件文件结构:
bash
src/
├── App.jsx # 根组件,持有 todos 状态
├── App.css # 全局样式
├── components/
│ ├── TodoInput.jsx # 输入框组件
│ ├── TodoList.jsx # 列表组件
│ └── TodoStats.jsx # 统计组件
1.2 组件树的好处
老师说:
"一眼看出页面的组件构成、页面的组件化程度、粒度。前端发展的必然------页面及交付越来越复杂,组件作为开发的最小单元,团队好协作、好复用、好维护。"
如果没有组件树,你写代码就是"拉面条"------一个文件越写越长,改都不知道从哪改。
二、数据状态放哪里?父组件统一管理
2.1 错误思路
我之前第一反应是:每个子组件自己管自己的状态。
- TodoInput 管自己的输入内容。
- TodoList 管自己的任务列表。
- TodoStats 管自己的统计数据。
结果------数据全乱套了。 输入框说"我加了新任务",但列表不知道;列表说"我删了任务",但统计不知道。
2.2 正确做法:状态提升
老师说:
"
todosJSON 数组。数据状态由父组件管理(持有和修改),子组件们共享统一(Props)的数据状态。数据和界面统一,不能出问题。"
App.jsx------唯一的"数据主人":
jsx
const App = () => {
const [todos, setTodos] = useState(() => [
{ id: 1, text: '吃饭', completed: false },
{ id: 2, text: '睡觉', completed: false },
{ id: 3, text: '打豆豆', completed: false },
]);
// 所有操作状态的方法都在 App 里
const addTodo = (text) => {
if (text.trim() === '') return;
setTodos([
{ id: Date.now(), text, completed: false },
...todos,
]);
};
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
));
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
const activeCount = todos.filter(t => !t.completed).length;
const completedCount = todos.length - activeCount;
return (
<div>
<h1>My Todo List</h1>
{/* Props 向下传数据,自定义事件向上报告 */}
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
);
};
数据的流向是这样的:
markdown
App(状态主人)
├── Props 向下 → TodoInput(只展示输入框)
│ ↑ 自定义事件向上报告(onAdd)
│
├── Props 向下 → TodoList(只负责渲染列表)
│ ↑ 自定义事件向上报告(onToggle, onDelete)
│
└── Props 向下 → TodoStats(只负责显示统计)
↑ 自定义事件向上报告(onClearCompleted)
核心原则:子组件不可以直接修改父组件的数据状态,只能通过自定义事件通知父组件。父组件修改后,子组件自动更新。
三、父子组件通信:Props 向下,事件向上
3.1 TodoInput:只负责输入
jsx
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="What needs to be done?"
autoFocus
/>
<button type="submit">Add</button>
</form>
);
};
TodoInput 有自己的状态 inputValue------这个状态是它自己独有的,不需要别人共享。所以放在自己组件里是合理的。
当用户提交时,它通过 onAdd(inputValue) 把新任务"报告"给父组件。
3.2 TodoList:只负责展示和触发操作
jsx
const TodoList = ({ todos, onToggle, onDelete }) => {
return (
<ul className="todo-list">
{todos.length === 0 ? (
<li className="empty">No todos yet!</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)}>Delete</button>
</li>
))
)}
</ul>
);
};
TodoList 没有自己的状态 ------它只接收 todos Props,然后渲染。用户点击 checkbox 或删除按钮时,调用父组件传进来的 onToggle 和 onDelete。
3.3 TodoStats:只负责显示
jsx
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
return (
<div>
<p>Total: {total} | Active: {active} | Completed: {completed}</p>
{completed > 0 && (
<button onClick={onClearCompleted} className="clear-btn">
Clear Completed
</button>
)}
</div>
);
};
TodoStats 也没有自己的状态 ------所有数据都从 Props 来。activeCount 和 completedCount 是在父组件 App 里通过 todos.filter() 计算出来的。
老师说:
"子组件主要负责展示------父组件给我什么 Props,我显示成什么样子。报告父组件,才可以修改。"
四、状态管理的"金法则"
4.1 状态放哪的判断标准
| 使用场景 | 状态放哪 | 例子 |
|---|---|---|
| 只有本组件用 | 本组件 | TodoInput 的 inputValue |
| 多个子组件共享 | 最近的共同父组件 | App 的 todos |
| 需要计算得到 | 不放 state,直接计算 | activeCount 和 completedCount |
4.2 不可变数据
注意 addTodo 和 toggleTodo 的实现:
jsx
// ✅ 正确的做法------返回全新数组
setTodos([
{ id: Date.now(), text, completed: false },
...todos,
]);
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
));
每次更新都返回一个新数组,而不是修改原数组。 这是 React 的"不可变性"原则------React 通过比较新旧引用来判断是否需要重新渲染。
五、自定义事件:父组件传给子组件的"遥控器"
老师说:
"子组件是不可以直接修改父组件的数据状态的,只能通过自定义事件通知父组件,父组件修改后,子组件自动更新。"
自定义事件本质上就是 Props 里的函数:
ini
父组件:<TodoInput onAdd={addTodo} />
↑ 把 addTodo 函数传下去
子组件:const TodoInput = ({ onAdd }) => { ... }
↑ 接收函数
子组件调用:onAdd(inputValue)
↑ 就像按了遥控器------父组件收到通知
Props 可以传递任何东西------数据(数字、字符串、数组)、函数(事件处理)、甚至是组件。
六、总结:TodoList 里的 React 核心知识
| 概念 | 在这个项目里怎么体现的 |
|---|---|
| 组件化 | 4 个组件:App、TodoInput、TodoList、TodoStats |
| 响应式 | useState → 数据变了界面自动更新 |
| 数据驱动 | 不再操作 DOM,只操作数据状态 |
| Props 向下 | todos 从 App 流向 TodoList 和 TodoStats |
| 事件向上 | onAdd、onToggle、onDelete 从子组件报告到父组件 |
| 状态提升 | todos 在 App 统一管理,子组件共享 |
| 派生状态 | activeCount 和 completedCount 直接计算,不存 state |
| 不可变数据 | 每次 setTodos 都传新数组,不修改原数组 |
| 条件渲染 | todos.length === 0 ? 显示空状态 |
TodoList 虽小,五脏俱全。它包含了 React 中 90% 的核心概念。
写在最后
今天最大的收获,是真正理解了"状态提升"和"父子通信"。以前写 React 总是随心所欲------这个组件的状态放这里,那个组件的状态放那里------结果数据乱七八糟。现在知道了:多子组件共享的状态,一定要放到共同的父组件去管理。子组件只负责展示和上报事件。
下次面试官问你:"React 中父子组件怎么通信?"
你可以淡定地说:
"React 的父子通信遵循'Props 向下,事件向上'的原则。父组件通过 Props 把数据传给子组件,子组件通过调用父组件传进来的函数(自定义事件)来报告操作。子组件不能直接修改父组件的状态。多个子组件需要共享的状态,应该提升到最近的共同父组件中管理。这样数据流是单向的、可预测的,方便调试和维护。"
然后看着面试官满意的表情,心里默念:这波,又稳了。
本文所有代码示例均来自课堂学习资料,真实可运行。