从零到一理解 React 父子组件通信:手写一个 Todo 应用带你彻底搞懂单向数据流

拒绝背八股,从一个真实场景出发,吃透 React 组件间通信的底层逻辑


写在前面

很多初学者在学习 React 时,最先被"卡住"的地方往往不是 JSX 语法,也不是 hooks 的用法,而是一个看似简单却至关重要的问题:组件之间到底怎么通信?

尤其是父子组件,props 传参、回调函数、状态提升......这些概念单独拎出来都懂,一写代码就懵。

今天,我们不聊虚的。我带你从零到一实现一个完整的 Todo 应用,边写边讲 ,把 React 父子组件通信的每一个细节都掰开揉碎。你会发现,当你真正理解了单向数据流,这些问题都会迎刃而解。

一句话总结本文核心:数据在父组件中持有,通过 props 向下传递;变化通过回调函数向上通知,父组件修改数据后,子组件自动更新。


一、从组件化思维开始

在 React 的世界里,一切皆为组件 。组件化不仅仅是一种代码组织方式,更是一种思维方式

1.1 拿到设计稿,先画组件树

无论是做一个 Todo 应用,还是搭建一个企业级后台,第一步都不是写代码,而是 "拆"

把 UI 拆解成独立的、可复用的组件,并规划好它们之间的层级关系。这就是所谓的 组件树(Component Tree)

对于我们的 Todo 应用,界面可以拆分成三块:

scss 复制代码
App (根组件)
├── TodoInput    (输入框 + 添加按钮)
├── TodoList     (列表)
│   └── TodoItem (列表项 - 在 TodoList 内部渲染)
└── TodoState    (统计 + 清除按钮)

对应的目录结构是这样的:

css 复制代码
src/
├── App.jsx
├── App.css
├── components/
│   ├── TodoInput.jsx
│   ├── TodoList.jsx
│   └── TodoState.jsx
└── main.jsx

为什么先做这一步? 因为在 vibe coding 时代,清晰的组件结构能让你的思路始终保持在线。有了这张"地图",代码往哪里写、数据往哪里放,一目了然。


二、核心原则:单向数据流

在 React 中,有一条铁律:数据流向是单向的,从父组件到子组件

说白了就是:父组件是"老板",子组件是"员工"。老板掌握核心数据,员工只负责干活,通过老板给的"指令"(props)来工作。员工不能擅自改数据,只能向老板"打报告"(调用回调函数),由老板来决策是否修改。

这张图,刻在脑子里:

scss 复制代码
数据 (state)     →  通过 props 传递  →  子组件渲染
    ↑                                      │
    │                                      │
    └────── 回调函数 (callback)  ←─────────┘
             子组件通过调用回调
             通知父组件更新数据

三、实战演练:Todo 应用完整实现

下面我们一步步实现这个 Todo 应用,每一行代码都会对应到上面的原则。

3.1 父组件 App:数据的"大本营"

父组件 App 是整个应用的数据持有者。所有的 Todo 数据都存放在这里,所有修改数据的方法也在这里定义。

javascript 复制代码
// 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>
      <h1>My 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 生成新数组来更新
  • 永远不要直接修改 state,React 依赖不可变数据来触发重新渲染

为什么不能直接修改 state? 因为 React 做的是浅比较 。直接 todos.push() 修改的是同一个数组引用,React 认为"没变化",自然不会重新渲染。而 setTodos([...]) 创建了新数组,引用变了,React 才会知道"哦,数据变了,我得重新渲染"。

3.2 子组件 TodoInput:表单交互

TodoInput 负责用户输入和提交。它自己需要维护一个输入框的 inputValue 状态------但注意,这个状态是它自己的私有状态,不需要共享给父组件

ini 复制代码
// components/TodoInput.jsx
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="What needs to be done?"
        autoFocus
      />
      <button type="submit">Add</button>
    </form>
  );
};

export default TodoInput;

通信模式拆解:

方向 方式 数据内容
父→子 props onAdd 回调函数
子→父 调用 onAdd 输入框中的文本 inputValue

TodoInput 自己管理 inputValue,但数据真正的归宿是父组件 。用户点击提交时,子组件"打报告"给父组件,父组件执行 addTodo 将数据加入列表。这就是所谓的 "子组件只负责收集,不负责存储"

3.3 子组件 TodoList:列表渲染

TodoList 接收父组件传来的 todos 数组,负责渲染列表。同时,它把 onToggleonDelete 回调继续向下传递(给 TodoItem)。

这里我直接把 TodoItem 内联在 TodoList 中渲染,方便展示:

ini 复制代码
// components/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>
  );
};

export default TodoList;

通信模式拆解:

方向 方式 数据内容
父→子 props todos, onToggle, onDelete
子→父 调用 onToggle(id) / onDelete(id) 目标 todo 的 id

TodoList 是一个典型的展示型组件:它只负责"怎么显示",不关心"数据怎么来""改了会怎样"。所有的逻辑都委托给父组件处理。

3.4 子组件 TodoState:状态统计

javascript 复制代码
// components/TodoState.jsx
const TodoState = ({ total, active, completed, onClearCompleted }) => {
  return (
    <div className="todo-state">
      <p>Total: {total} | Active: {active} | Completed: {completed}</p>
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          Clear Completed
        </button>
      )}
    </div>
  );
};

export default TodoState;

通信模式拆解:

方向 方式 数据内容
父→子 props total, active, completed, onClearCompleted
子→父 调用 onClearCompleted() 无参数,仅表示"清除已完成"这个动作

四、深入理解:为什么子组件不能直接修改父组件的 state?

这是 React 新手最容易踩的坑之一。

从设计层面看: 如果子组件能随意修改父组件的状态,数据流就会变得混乱。当 Bug 出现时,你根本不知道是哪个子组件偷偷改了数据。调试难度呈指数级上升。

从 React 机制看: 父组件的 state 是"私有财产",子组件根本没有访问它的权限。子组件能拿到的只有 props,而 props 是只读的

记住这句话:数据只能向下流,变化只能向上报。

正确的做法就是我们的代码所示:子组件通过调用 props 中的回调函数, "请求" 父组件修改数据。父组件收到请求后,自主决定是否修改、如何修改。


五、补充:useState 的懒执行(Lazy Initialization)

顺带提一个点:useState懒执行

当初始值的计算开销较大时(比如涉及复杂运算、读取 localStorage 等),可以使用函数形式的 initialState:

scss 复制代码
// ❌ 每次渲染都会执行 getInitialValue,即使只在首次使用
const [state, setState] = useState(getInitialValue());

// ✅ 只有在首次渲染时才会执行 getInitialValue
const [state, setState] = useState(() => getInitialValue());

在 Todo 应用中,如果你的初始数据需要从 localStorage 读取,可以这样写:

ini 复制代码
const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

这个细节很小,但在性能敏感的场景下,能帮你避免不必要的计算。


六、踩坑记录 & 最佳实践

坑 1:直接修改 state

scss 复制代码
// ❌ 错误
const addTodo = (text) => {
  todos.push({ id: Date.now(), text }); // 直接修改原数组
  setTodos(todos);
};

后果: React 检测不到变化,界面不更新。

正确做法: 永远使用 setTodos([...]) 创建新数组。

坑 2:在子组件中"缓存" props

javascript 复制代码
// ❌ 错误
const TodoItem = ({ todo }) => {
  const [completed, setCompleted] = useState(todo.completed); // 危险!
  // ...
};

后果: 当父组件的 todo.completed 变化时,子组件的 completed 不会同步更新,因为 useState 只在首次渲染时生效。

正确做法: 直接在 JSX 中使用 props.todo.completed,不要用 state 去"复制" props。

坑 3:回调函数中忘记传递参数

less 复制代码
// ❌ 错误
<button onClick={onDelete}>Delete</button> // 没有传 id

// ✅ 正确
<button onClick={() => onDelete(todo.id)}>Delete</button>

最佳实践清单

  • 状态提升:多个子组件共享的数据,提升到最近的共同父组件中管理

  • 不可变数据 :使用扩展运算符 [...]{...}mapfilter 等创建新数据

  • props 只读:子组件绝不修改 props 中的数据

  • 回调命名规范 :用 on 开头,如 onAddonToggleonDelete,一看就知道是事件

  • 单一数据源:一个数据只在一个地方管理,不要多处存副本


七、总结

回顾一下,我们从零实现了一个完整的 Todo 应用,核心只有三点:

  1. 状态在哪里? 在父组件 App 中,通过 useState 持有 todos 数据。
  2. 数据怎么传递? 通过 props 从父组件流向子组件。
  3. 数据怎么修改? 子组件通过调用父组件传递的回调函数,向父组件发送"修改请求"。

这就是 React 最经典的父子组件通信模式 ,也是单向数据流的最佳实践。

最后送你一句话:在 React 的世界里,数据像水一样往下流,变化像信号一样往上发。理解了这一点,你就能驾驭任何复杂的组件通信。

相关推荐
醇氧4 小时前
CountDownLatch / CyclicBarrier / Semaphore 面试高频问答清单
前端·面试·职场和发展
窝子面5 小时前
手搓最简前后端协作-node
javascript·数据库
qetfw6 小时前
MXU:Tauri 2 + React 的 MaaFramework 跨平台 GUI 源码
前端·python·react.js·前端框架·开源项目·效率工具
甲维斯6 小时前
我要开始吹牛逼了!Kimi K3 “宇宙无敌”!
前端·人工智能
a1117766 小时前
微光小屋-前端养成小游戏 开源项目
前端
kyriewen6 小时前
我让AI给前端项目做了一次完整的Code Review——它和人类的差距,比我想的大得多
前端·javascript·ai编程
张鑫旭6 小时前
快速过下我2026年上半年的前端学了些啥
前端