从零到一理解 React 父子组件通信:手写一个 Todo 应用带你彻底搞懂单向数据流
拒绝背八股从一个真实场景出发吃透 React 组件间通信的底层逻辑写在前面很多初学者在学习 React 时最先被“卡住”的地方往往不是 JSX 语法也不是 hooks 的用法而是一个看似简单却至关重要的问题组件之间到底怎么通信尤其是父子组件props 传参、回调函数、状态提升……这些概念单独拎出来都懂一写代码就懵。今天我们不聊虚的。我带你从零到一实现一个完整的 Todo 应用边写边讲把 React 父子组件通信的每一个细节都掰开揉碎。你会发现当你真正理解了单向数据流这些问题都会迎刃而解。一句话总结本文核心数据在父组件中持有通过 props 向下传递变化通过回调函数向上通知父组件修改数据后子组件自动更新。一、从组件化思维开始在 React 的世界里一切皆为组件。组件化不仅仅是一种代码组织方式更是一种思维方式。1.1 拿到设计稿先画组件树无论是做一个 Todo 应用还是搭建一个企业级后台第一步都不是写代码而是“拆”。把 UI 拆解成独立的、可复用的组件并规划好它们之间的层级关系。这就是所谓的组件树Component Tree。对于我们的 Todo 应用界面可以拆分成三块App (根组件) ├── TodoInput (输入框 添加按钮) ├── TodoList (列表) │ └── TodoItem (列表项 - 在 TodoList 内部渲染) └── TodoState (统计 清除按钮)对应的目录结构是这样的src/ ├── App.jsx ├── App.css ├── components/ │ ├── TodoInput.jsx │ ├── TodoList.jsx │ └── TodoState.jsx └── main.jsx为什么先做这一步因为在 vibe coding 时代清晰的组件结构能让你的思路始终保持在线。有了这张“地图”代码往哪里写、数据往哪里放一目了然。二、核心原则单向数据流在 React 中有一条铁律数据流向是单向的从父组件到子组件。说白了就是父组件是“老板”子组件是“员工”。老板掌握核心数据员工只负责干活通过老板给的“指令”props来工作。员工不能擅自改数据只能向老板“打报告”调用回调函数由老板来决策是否修改。这张图刻在脑子里数据 (state) → 通过 props 传递 → 子组件渲染 ↑ │ │ │ └────── 回调函数 (callback) ←─────────┘ 子组件通过调用回调 通知父组件更新数据三、实战演练Todo 应用完整实现下面我们一步步实现这个 Todo 应用每一行代码都会对应到上面的原则。3.1 父组件 App数据的“大本营”父组件 App 是整个应用的数据持有者。所有的 Todo 数据都存放在这里所有修改数据的方法也在这里定义。// App.jsx import { useState } from react; import TodoInput from ./components/TodoInput.jsx; import TodoList from ./components/TodoList.jsx; import TodoState from ./components/TodoState.jsx; import ./App.css; const App () { // ✅ 数据状态由父组件持有和管理 const [todos, setTodos] useState([ { id: 1, text: 吃饭, completed: false }, { id: 2, text: 睡觉, completed: false }, { id: 3, text: 打豆豆, completed: true }, ]); // ✅ 添加 todo —— 父组件负责修改 const addTodo (text) { if (text.trim() ) return; // ⚠️ 注意不能直接 push 修改原数组 // todos.push({ id: Date.now(), text }); // ❌ 错误做法 // 必须创建一个全新的状态 setTodos([{ id: Date.now(), text, completed: false }, ...todos]); }; // ✅ 切换完成状态 const toggleTodo (id) { setTodos(todos.map(todo todo.id id ? { ...todo, completed: !todo.completed } : todo )); }; // ✅ 删除 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.filter(t t.completed).length; return ( div h1My Todo List/h1 {/* 通过 props 向下传递数据和方法 */} TodoInput onAdd{addTodo} / TodoList todos{todos} onToggle{toggleTodo} onDelete{deleteTodo} / TodoState total{todos.length} active{activeCount} completed{completedCount} onClearCompleted{clearCompleted} / /div ); }; export default App;划重点todos数组是唯一的数据源由useState管理所有修改操作增、删、改、清都通过setTodos生成新数组来更新永远不要直接修改 stateReact 依赖不可变数据来触发重新渲染为什么不能直接修改 state因为 React 做的是浅比较。直接todos.push()修改的是同一个数组引用React 认为“没变化”自然不会重新渲染。而setTodos([...])创建了新数组引用变了React 才会知道“哦数据变了我得重新渲染”。3.2 子组件 TodoInput表单交互TodoInput 负责用户输入和提交。它自己需要维护一个输入框的inputValue状态——但注意这个状态是它自己的私有状态不需要共享给父组件。// components/TodoInput.jsx import { useState } from react; const TodoInput ({ onAdd }) { // 这是子组件自己的状态与父组件无关 const [inputValue, setInputValue] useState(); const handleSubmit (e) { e.preventDefault(); // ✅ 当需要报告父组件时调用父组件传过来的回调函数 onAdd(inputValue); setInputValue(); // 清空输入框 }; return ( form classNametodo-input onSubmit{handleSubmit} input typetext value{inputValue} onChange{(e) setInputValue(e.target.value)} placeholderWhat needs to be done? autoFocus / button typesubmitAdd/button /form ); }; export default TodoInput;通信模式拆解方向方式数据内容父→子propsonAdd回调函数子→父调用onAdd输入框中的文本inputValueTodoInput 自己管理inputValue但数据真正的归宿是父组件。用户点击提交时子组件“打报告”给父组件父组件执行addTodo将数据加入列表。这就是所谓的“子组件只负责收集不负责存储”。3.3 子组件 TodoList列表渲染TodoList 接收父组件传来的todos数组负责渲染列表。同时它把onToggle和onDelete回调继续向下传递给 TodoItem。这里我直接把 TodoItem 内联在 TodoList 中渲染方便展示// components/TodoList.jsx const TodoList ({ todos, onToggle, onDelete }) { return ( ul classNametodo-list {todos.length 0 ? ( li classNameemptyNo todos yet!/li ) : ( todos.map(todo ( li key{todo.id} className{todo.completed ? completed : } label input typecheckbox checked{todo.completed} onChange{() onToggle(todo.id)} // ✅ 调用父组件传下来的回调 / span{todo.text}/span /label button onClick{() onDelete(todo.id)}Delete/button /li )) )} /ul ); }; export default TodoList;通信模式拆解方向方式数据内容父→子propstodos,onToggle,onDelete子→父调用onToggle(id)/onDelete(id)目标 todo 的idTodoList 是一个典型的展示型组件它只负责“怎么显示”不关心“数据怎么来”“改了会怎样”。所有的逻辑都委托给父组件处理。3.4 子组件 TodoState状态统计// components/TodoState.jsx const TodoState ({ total, active, completed, onClearCompleted }) { return ( div classNametodo-state pTotal: {total} | Active: {active} | Completed: {completed}/p {completed 0 ( button onClick{onClearCompleted} classNameclear-btn Clear Completed /button )} /div ); }; export default TodoState;通信模式拆解方向方式数据内容父→子propstotal,active,completed,onClearCompleted子→父调用onClearCompleted()无参数仅表示“清除已完成”这个动作四、深入理解为什么子组件不能直接修改父组件的 state这是 React 新手最容易踩的坑之一。从设计层面看如果子组件能随意修改父组件的状态数据流就会变得混乱。当 Bug 出现时你根本不知道是哪个子组件偷偷改了数据。调试难度呈指数级上升。从 React 机制看父组件的 state 是“私有财产”子组件根本没有访问它的权限。子组件能拿到的只有 props而 props 是只读的。记住这句话数据只能向下流变化只能向上报。正确的做法就是我们的代码所示子组件通过调用props中的回调函数“请求”父组件修改数据。父组件收到请求后自主决定是否修改、如何修改。五、补充useState 的懒执行Lazy Initialization顺带提一个点useState的懒执行。当初始值的计算开销较大时比如涉及复杂运算、读取 localStorage 等可以使用函数形式的 initialState// ❌ 每次渲染都会执行 getInitialValue即使只在首次使用 const [state, setState] useState(getInitialValue()); // ✅ 只有在首次渲染时才会执行 getInitialValue const [state, setState] useState(() getInitialValue());在 Todo 应用中如果你的初始数据需要从 localStorage 读取可以这样写const [todos, setTodos] useState(() { const saved localStorage.getItem(todos); return saved ? JSON.parse(saved) : []; });这个细节很小但在性能敏感的场景下能帮你避免不必要的计算。六、踩坑记录 最佳实践坑 1直接修改 state// ❌ 错误 const addTodo (text) { todos.push({ id: Date.now(), text }); // 直接修改原数组 setTodos(todos); };后果React 检测不到变化界面不更新。正确做法永远使用setTodos([...])创建新数组。坑 2在子组件中“缓存” props// ❌ 错误 const TodoItem ({ todo }) { const [completed, setCompleted] useState(todo.completed); // 危险 // ... };后果当父组件的todo.completed变化时子组件的completed不会同步更新因为useState只在首次渲染时生效。正确做法直接在 JSX 中使用props.todo.completed不要用 state 去“复制” props。坑 3回调函数中忘记传递参数// ❌ 错误 button onClick{onDelete}Delete/button // 没有传 id // ✅ 正确 button onClick{() onDelete(todo.id)}Delete/button最佳实践清单□状态提升多个子组件共享的数据提升到最近的共同父组件中管理□不可变数据使用扩展运算符[...]、{...}、map、filter等创建新数据□props 只读子组件绝不修改 props 中的数据□回调命名规范用on开头如onAdd、onToggle、onDelete一看就知道是事件□单一数据源一个数据只在一个地方管理不要多处存副本七、总结回顾一下我们从零实现了一个完整的 Todo 应用核心只有三点状态在哪里在父组件 App 中通过useState持有todos数据。数据怎么传递通过props从父组件流向子组件。数据怎么修改子组件通过调用父组件传递的回调函数向父组件发送“修改请求”。这就是 React 最经典的父子组件通信模式也是单向数据流的最佳实践。最后送你一句话在 React 的世界里数据像水一样往下流变化像信号一样往上发。理解了这一点你就能驾驭任何复杂的组件通信。