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

0 阅读10分钟

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

前言

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

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

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

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

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

项目初始化与技术栈

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

项目结构:

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

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

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

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

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;
  • 效果图

屏幕录制 2026-02-22 221410.gif 现在你已经看到了完整的项目代码。接下来,我们将分章节详细解释其中的核心知识点。

三、知识点深度解析

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

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

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

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

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

const TodoList = ({ todos, onDelete, onToggle }) => { ... }

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

特点

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

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

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

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

例如,TodoInput接收onAdd

<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等方法来返回新数组,而不是直接修改原数组。

例如:

// 添加:创建新数组,包含原数组所有元素再加一个新元素
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事件更新状态。这种模式称为受控组件

<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读取初始数据:

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

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

4.3 监听变化并保存到localStorage

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

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

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

只需修改App.jsx,添加上述两处代码,其他组件完全不变。修改后的App.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效果图

屏幕录制 2026-02-22 221753.gif 我们可以看到当页面刷新时原来的数据依然存在

五、总结与思考

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组件通信。如果你觉得有用,请点赞收藏,让更多初学者看到!