React组件通信实战:从Todo应用彻底搞懂父子、子父、兄弟通信

React组件通信实战:从Todo应用彻底搞懂父子、子父、兄弟通信

前言

刚接触React的同学一定对组件间的通信感到困惑:

  • 父组件怎么把数据传给子组件?(父→子
  • 子组件怎么通知父组件修改数据?(子→父
  • 没有直接关系的兄弟组件又该如何共享状态?(兄弟↔兄弟

别担心,今天我们就通过一个极简的Todo应用,把这些通信方式一次理清楚。你会学到:

  • React单向数据流到底是什么
  • props如何传递数据和函数
  • 状态提升如何解决兄弟通信
  • 最后,还会教你如何用 localStorage + useEffect 实现数据持久化,让Todo列表刷新后依然存在

项目代码简洁,但五脏俱全,非常适合初学者理解和上手。让我们开始吧!

项目初始化与技术栈

  • React + Vite(快速构建)
  • Stylus(CSS预处理器,本文重点不在样式)
  • 最后会用到 localStorage 做数据持久化

项目结构:

bash 复制代码
src/
  components/
    TodoInput.jsx    # 输入框组件
    TodoList.jsx     # 列表展示组件
    TodoStats.jsx    # 统计信息组件
  App.jsx            # 根组件,持有共享数据
  styles/
    app.styl

一、核心概念回顾:单向数据流与状态提升

在开始写代码前,我们先理解两个React最重要的概念:

1.1 单向数据流

React的数据是从父组件流向子组件的(通过props)。子组件不能直接修改收到的props,因为props是只读的。这保证了数据的可预测性------数据变化的原因一定来自组件自身(state)或父组件传递的新props。

1.2 状态提升

当多个组件需要共享同一份数据时,我们应该将这份数据提升到它们最近的共同父组件中,由父组件管理,然后通过props分发给子组件。子组件想修改数据,必须调用父组件传递的回调函数,由父组件真正修改数据。

这正是Todo应用的设计思想:todos数组作为共享状态,放在App组件中,三个子组件都通过props获取数据或回调。

二、完整代码(无持久化版本)

为了让大家先对整个项目有一个整体认识,我们先给出不包含持久化的完整代码。每个文件都包含了详细的注释,方便理解。

App.jsx

jsx 复制代码
import { useState } from 'react';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';
import TodoStats from './components/TodoStats';

function App() {
  // 核心数据:todos 数组,包含所有任务
  const [todos, setTodos] = useState([]);

  // 添加任务
  const addTodo = (text) => {
    // 使用展开运算符创建新数组,保持不可变性
    setTodos([...todos, { id: Date.now(), text, completed: false }]);
  };

  // 删除任务
  const deleteTodo = (id) => {
    // filter 返回新数组,删除指定 id 的任务
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // 切换任务完成状态
  const toggleTodo = (id) => {
    // map 返回新数组,切换指定任务的 completed 状态
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  // 清除所有已完成任务
  const clearCompleted = () => {
    // filter 保留未完成的任务
    setTodos(todos.filter(todo => !todo.completed));
  };

  // 派生数据:从 todos 计算统计信息
  const activeCount = todos.filter(todo => !todo.completed).length;
  const completedCount = todos.filter(todo => todo.completed).length;

  return (
    <div className="todo-app">
      <h1>My Todo List</h1>
      {/* 子组件通过 props 接收数据和回调 */}
      <TodoInput onAdd={addTodo} />
      <TodoList
        todos={todos}
        onDelete={deleteTodo}
        onToggle={toggleTodo}
      />
      <TodoStats
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  );
}

export default App;

components/TodoInput.jsx

jsx 复制代码
import { useState } from 'react';

const TodoInput = ({ onAdd }) => {
  // 内部状态:输入框的值(只属于这个组件,无需提升)
  const [inputValue, setInputValue] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (inputValue.trim()) {
      // 调用父组件传递的回调,将新任务文本传回去
      onAdd(inputValue);
      // 清空输入框
      setInputValue('');
    }
  };

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input
        type="text"
        value={inputValue}
        onChange={e => setInputValue(e.target.value)}
        placeholder="输入新任务..."
      />
      <button type="submit">添加</button>
    </form>
  );
};

export default TodoInput;

components/TodoList.jsx

jsx 复制代码
const TodoList = ({ todos, onDelete, onToggle }) => {
  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <li className="empty">暂无任务</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)}>X</button>
          </li>
        ))
      )}
    </ul>
  );
};

export default TodoList;

components/TodoStats.jsx

jsx 复制代码
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
  return (
    <div className="todo-stats">
      <p>总计: {total} | 待办: {active} | 已完成: {completed}</p>
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          清除已完成
        </button>
      )}
    </div>
  );
};

export default TodoStats;
  • 效果图

现在你已经看到了完整的项目代码。接下来,我们将分章节详细解释其中的核心知识点。

三、知识点深度解析

3.1 父子组件通信(父→子)

实现方式:父组件通过JSX属性向子组件传递任意类型的数据。

App.jsx中,我们可以看到多处父子通信的例子:

  • <TodoList todos={todos} />:将todos数组传递给TodoList组件。
  • <TodoStats total={todos.length} active={activeCount} completed={completedCount} />:将统计信息(数字)传递给TodoStats组件。

子组件通过props对象接收这些数据。例如在TodoList中:

jsx 复制代码
const TodoList = ({ todos, onDelete, onToggle }) => { ... }

这里的{ todos }就是从父组件传递过来的数据。

特点

  • props是只读的,子组件不能修改它们。
  • 如果传递的是对象/数组,传递的是引用,子组件虽然不能直接赋值,但可以修改对象内部的属性(不推荐)。最佳实践是保持不可变。

3.2 子父组件通信(子→父)

子组件不能直接修改父组件的状态,但可以通过调用父组件通过props传递的函数来"请求"父组件修改状态。

App.jsx中,父组件定义了几个修改状态的方法:addTododeleteTodotoggleTodoclearCompleted。然后将这些方法通过props传递给子组件,通常以on开头命名。

例如,TodoInput接收onAdd

jsx 复制代码
<TodoInput onAdd={addTodo} />

TodoInput内部,当表单提交时,调用onAdd(inputValue),将新任务的文本传回父组件。

同样,TodoList接收onDeleteonToggle,在点击删除按钮或复选框时调用这些回调,并传递todo.id

TodoStats接收onClearCompleted,点击按钮时调用。

关键点

  • 子组件只是触发事件,真正的修改逻辑在父组件中。
  • 数据变化的原因集中在父组件,便于追踪和维护。

3.3 兄弟组件通信(通过共同父组件)

兄弟组件之间没有直接通信,而是通过它们共同的父组件作为桥梁。

以添加任务为例:

  1. TodoInput调用onAdd,将新任务文本传给父组件App
  2. 父组件执行addTodo,更新todos状态。
  3. 父组件重新渲染,将新的todos传给TodoList,将重新计算的activeCountcompletedCount传给TodoStats
  4. TodoListTodoStats接收到新的props,自动更新视图。

这样,TodoListTodoStats虽然不直接联系,但通过父组件的状态变化实现了同步。这就是状态提升的核心思想:将共享状态提升到最近的共同父组件中。

为什么这是最佳实践?

  • 单一数据源:所有共享数据都在父组件,修改也集中于此。
  • 组件解耦:每个子组件只依赖自己的props,不关心其他组件。
  • 可预测:数据流是单向的,从父到子,变化原因来自子组件的回调。

3.4 不可变更新

在父组件的修改方法中,我们使用了展开运算符(...)、filtermap等方法来返回新数组,而不是直接修改原数组。

例如:

jsx 复制代码
// 添加:创建新数组,包含原数组所有元素再加一个新元素
setTodos([...todos, { id: Date.now(), text, completed: false }]);

// 删除:filter 返回新数组,不含指定 id
setTodos(todos.filter(todo => todo.id !== id));

// 切换:map 返回新数组,指定 id 的元素替换为新对象
setTodos(todos.map(todo =>
  todo.id === id ? { ...todo, completed: !todo.completed } : todo
));

为什么必须这样做? React通过浅比较状态的前后引用是否变化来决定是否重新渲染。如果直接修改原数组(例如todos.push(newTodo)),然后调用setTodos(todos),由于引用未变,React可能不会触发更新。因此,必须返回一个新的数组或对象。

3.5 受控组件

TodoInput中,<input>元素的值绑定到inputValue状态,并通过onChange事件更新状态。这种模式称为受控组件

jsx 复制代码
<input
  type="text"
  value={inputValue}
  onChange={e => setInputValue(e.target.value)}
/>

这样,React state成为"唯一数据源",输入框的值始终与状态同步,方便处理表单逻辑。

四、扩展:实现数据持久化(localStorage + useEffect)

目前我们的Todo应用功能完整,但刷新页面后数据会丢失。为了解决这个问题,我们可以利用浏览器的localStorage将数据保存在硬盘上。

4.1 为什么需要持久化?

  • 提升用户体验:用户关闭浏览器后再次打开,之前的任务依然存在。
  • 让应用更像一个"真实"的应用。

4.2 初始化时从localStorage读取

修改App.jsx中的useState,使用惰性初始函数从localStorage读取初始数据:

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

这样,组件初始化时,如果localStorage中已有保存的todos,就使用它;否则使用空数组。惰性初始函数保证读取操作只在初始化时执行一次,避免每次渲染都读取。

4.3 监听变化并保存到localStorage

我们希望每当todos变化时,自动将最新数据写入localStorage。这可以用useEffect实现:

jsx 复制代码
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
  • 第一个参数是副作用函数,执行保存操作。
  • 第二个参数是依赖数组[todos],表示只有当todos变化时才执行该函数。
  • 组件首次渲染时也会执行一次(如果todos有初始值,就会保存一次,不影响)。

4.4 完整代码(包含持久化)

只需修改App.jsx,添加上述两处代码,其他组件完全不变。修改后的App.jsx如下:

jsx 复制代码
import { useState, useEffect } from 'react';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';
import TodoStats from './components/TodoStats';

function App() {
  // 初始化时从localStorage读取
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos');
    return saved ? JSON.parse(saved) : [];
  });

  // 添加任务
  const addTodo = (text) => {
    setTodos([...todos, { id: Date.now(), text, completed: false }]);
  };

  // 删除任务
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // 切换任务完成状态
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  // 清除所有已完成任务
  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed));
  };

  // 派生数据
  const activeCount = todos.filter(todo => !todo.completed).length;
  const completedCount = todos.filter(todo => todo.completed).length;

  // 持久化:todos变化时自动保存
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

  return (
    <div className="todo-app">
      <h1>My Todo List</h1>
      <TodoInput onAdd={addTodo} />
      <TodoList
        todos={todos}
        onDelete={deleteTodo}
        onToggle={toggleTodo}
      />
      <TodoStats
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  );
}

export default App;

4.5 注意事项

  • useEffect在浏览器完成布局与绘制之后执行,不会阻塞渲染。
  • 保存复杂对象时需要使用JSON.stringify,读取时使用JSON.parse
  • 如果todos很大,频繁写入localStorage可能影响性能,可以添加防抖优化,但本例中无需。

4.6效果图

我们可以看到当页面刷新时原来的数据依然存在

五、总结与思考

5.1 三种通信方式回顾

通信类型 实现方式 代码示例
父→子 props传递数据 <TodoList todos={todos} />
子→父 父组件传递回调函数,子组件调用 onAdd={addTodo},子组件内onAdd(text)
兄弟↔兄弟 通过共同的父组件状态提升 兄弟组件都依赖父组件的todos,并通过父组件回调修改

5.2 持久化要点

  • 使用useState惰性初始化从localStorage读取初始数据。
  • 使用useEffect监听数据变化,自动同步到localStorage。

5.3 最佳实践总结

  • 状态尽可能提升:需要共享的状态放在最近的共同父组件中。
  • props只读:永远不要在子组件中修改props。
  • 回调命名规范 :以on开头,如onDelete
  • 不可变更新 :使用展开运算符或map/filter返回新数组,不要直接修改原状态。
  • 分离UI状态和业务状态:如表单输入使用内部state,核心数据放在父组件。

5.4 常见问题

Q:为什么子组件不能直接修改props? A:如果子组件可以修改props,数据变化源头将不可追溯,调试困难。React的设计哲学是数据自上而下流动,修改必须通过事件向上传递。

Q:兄弟组件必须通过父组件通信吗? A:如果它们没有共同的父组件,或者层级太深,可以使用Context或状态管理库。但大多数情况下,提升状态到共同父组件是最简单可靠的方式。

Q:使用useState更新数组/对象时,为什么一定要返回新引用? A:React通过浅比较决定是否重新渲染。如果直接修改原数组,然后调用setTodos(todos),由于引用未变,React可能不会触发更新。必须返回一个新数组。

遇到问题欢迎留言讨论!


最后:希望这篇文章能帮你彻底搞懂React组件通信。如果你觉得有用,请点赞收藏,让更多初学者看到!

相关推荐
前端之虎陈随易7 小时前
编程语言级别的Skill市场,AI Agent 的未来形态
前端·vue.js·人工智能·typescript·node.js
一路向北he7 小时前
字节钢铁军团--“提供情境,而非控制”
java·开发语言·前端
kyriewen7 小时前
豆包和千问同时关了智能体,我用它们搭的 3 个自动化全废了——迁移方案整理
前端·javascript·ai编程
前端一小卒8 小时前
我用 TypeScript 从零手写了一个 Claude Code,然后发现它的核心只有 30 行
前端·agent
大圣编程9 小时前
Python中continue语句的用法是什么?
开发语言·前端·python
yuhaiqiang9 小时前
随手 vibecoding 的浏览器插件已经 6000 多次下载,聊聊他的产品设计
前端·后端·面试
之歆10 小时前
Vue商品详情与放大镜组件
前端·javascript·vue.js
再吃一根胡萝卜10 小时前
如何把小米 MiMo 接入 CodeBuddy,打造私有 Agent
前端
负责的蛋挞12 小时前
异步HttpModule的实现方式
java·服务器·前端
YFF菲菲兔12 小时前
其他 Hooks 解析
react.js