数据向下,事件向上,兄弟绕道:React 通信的黄金三角

43 阅读6分钟

React 组件通信三原则:从 Todo 应用看数据流动的正确姿势

在 React 的世界里,组件不是孤立的岛屿,而是通过明确的规则相互连接的网络。理解组件之间如何安全、高效地传递数据和触发行为,是掌握 React 开发的关键。本文将以一个经典的 Todo 应用为例,深入解析 React 组件通信的三大核心原则。

一、父子通信:单向数据流的基石

React 严格遵循单向数据流(Unidirectional Data Flow):数据只能从父组件流向子组件,通过 props 传递。

在父组件 App 中,我们首先定义状态:

const [todos, setTodos] = useState([]);

然后将这个状态作为 props 传递给需要它的子组件:

<TodoList todos={todos} />
<TodoStats total={todos.length} />

这里只传递了数据本身,没有任何方法。TodoList 接收到 todos 后,仅用于渲染任务列表;TodoStats 接收到 total 后,仅用于显示统计数字。这种纯粹的数据传递体现了父子通信中最基础的形式:父组件提供数据,子组件消费数据

此时,子组件是完全“只读”的——它们能看到数据,但无法改变它。这是 React 单向数据流的第一层含义。

二、子组件修改数据:通过父组件传递的函数实现

当子组件需要修改数据时(例如用户点击删除按钮),它不能直接操作 props,而必须依赖父组件传递的函数。

父组件定义修改方法

App.jsx 中,我们定义具体的修改逻辑:

// 删除任务的方法
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
  ));
};

这些方法的核心特点是:

  • 它们使用 setTodos 来更新状态
  • 它们接收最小必要参数(如 id),而不是整个对象
  • 它们返回全新的状态(不可变更新)

父组件传递方法给子组件

接着,我们将这些方法作为 props 传递给 TodoList

<TodoList 
  todos={todos}
  onDelete={deleteTodo}
  onToggle={toggleTodo}
/>

注意命名规范:我们使用 onXxx 前缀(如 onDeleteonToggle)来明确表示这是事件处理函数,这是一种广泛采用的 React 命名约定。

子组件调用方法触发修改

TodoList.jsx 中,子组件通过调用这些函数来请求状态变更:

// 删除按钮
<button onClick={() => onDelete(todo.id)}>X</button>

// 切换完成状态的复选框
<input 
  type="checkbox"
  checked={todo.completed}
  onChange={() => onToggle(todo.id)}
/>

关键点在于:

  • 子组件不关心 deleteTodo 内部如何实现
  • 子组件只负责通知父组件“我想删除 ID 为 x 的任务”
  • 实际的状态更新逻辑完全由父组件控制

这种模式的核心思想是:子组件描述“意图”,父组件执行“操作” 。这既保证了状态的集中管理,又赋予了子组件必要的交互能力。

三、兄弟组件通信:通过共同父组件中转

当多个子组件需要共享同一份数据时(如 TodoInput 添加任务后,TodoList 要显示新任务,TodoStats 要更新计数),它们不能直接互相通信,而必须通过共同的父组件协调。

父组件统一管理共享状态

所有相关组件共享的 todos 状态都定义在 App 中:

const [todos, setTodos] = useState([]);

父组件分发状态和能力

App 将状态和相应的操作方法分发给各个子组件:

// TodoInput 只需要添加任务的能力
<TodoInput onAdd={addTodo} />

// TodoList 需要完整的任务列表和修改能力
<TodoList 
  todos={todos}
  onDelete={deleteTodo}
  onToggle={toggleTodo}
/>

// TodoStats 只需要统计数据
<TodoStats 
  total={todos.length}
  active={todos.filter(t => !t.completed).length}
/>

通信流程示例:添加新任务

  1. TodoInput 触发添加操作

    // TodoInput.jsx
    const handleSubmit = () => {
      if (inputValue.trim()) {
        onAdd(inputValue); // 通知父组件
        setInputValue('');
      }
    };
    
  2. App 执行状态更新

    // App.jsx
    const addTodo = (text) => {
      setTodos([...todos, {
        id: Date.now(),
        text,
        completed: false
      }]);
    };
    
  3. App 重新渲染所有子组件

    • 新的 todos 数组传递给 TodoList
    • 新的 totalactive 值传递给 TodoStats
    • 所有相关组件自动更新 UI

这种模式确保了:

  • 数据一致性:所有组件看到的是同一份状态
  • 逻辑集中:状态变更逻辑只存在于父组件
  • 组件解耦:兄弟组件彼此不知道对方的存在

四、状态提升:解决共享状态的最佳实践

上述模式正是 React 官方推荐的 “状态提升” (Lifting State Up)原则:

当多个组件需要反映相同的变化数据时,应将共享状态提升到它们最近的共同祖先组件中。

在 Todo 应用中:

  • todos 状态被提升到 App 组件(所有相关组件的共同祖先)
  • App 成为唯一的数据源(Single Source of Truth)
  • 所有子组件通过 props 订阅这个数据源

五、状态持久化:用 useEffect 统一处理副作用

在实际应用中,我们通常希望用户的 Todo 数据在页面刷新后依然保留。这需要将状态同步到浏览器的 localStorage。关键在于:不要在每个状态更新方法中手动调用 localStorage.setItem,而应该利用 React 的 useEffect 钩子统一监听状态变化。

冗余做法:分散的副作用

// ❌ 不推荐:在每个方法中重复处理持久化
const addTodo = (text) => {
  const newTodos = [...todos, { id: Date.now(), text, completed: false }];
  setTodos(newTodos);
  localStorage.setItem('todos', JSON.stringify(newTodos)); // 重复代码
};

const deleteTodo = (id) => {
  const newTodos = todos.filter(todo => todo.id !== id);
  setTodos(newTodos);
  localStorage.setItem('todos', JSON.stringify(newTodos)); // 重复代码
};

这种做法的问题很明显:

  • 代码重复:每个修改方法都要写相同的持久化逻辑
  • 容易遗漏:新增状态修改方法时可能忘记添加持久化
  • 耦合度高:业务逻辑与副作用混杂在一起

正确做法:集中式副作用管理

// ✅ 推荐:使用 useEffect 统一监听状态变化
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

这段代码的含义是:每当 todos 状态发生变化时,自动将其保存到 localStorage

同时,在组件初始化时从 localStorage 恢复数据:

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

这里使用了 useState初始化函数形式,确保只在组件首次渲染时读取 localStorage,避免不必要的解析开销。

这种模式的优势

  1. 单一职责:状态更新方法只关注业务逻辑,持久化由专门的副作用处理
  2. 不易出错:无论有多少个状态修改方法,持久化逻辑只需写一次
  3. 易于维护:如果将来要切换到其他存储方案(如 IndexedDB),只需修改 useEffect 中的代码
  4. 符合 React 哲学:将副作用与渲染逻辑分离,保持组件的纯净性

更重要的是,这种模式完美契合了我们前面提到的通信原则——状态变更仍然是显式的、受控的,只是持久化这个副作用被集中管理了。

总结:React 通信的黄金法则

通过 Todo 应用,我们可以提炼出 React 组件通信的三个黄金法则:

  1. 数据向下流:父组件通过 props 向子组件传递数据
  2. 事件向上冒:子组件通过回调函数(命名如 onDelete)向父组件通知变化意图
  3. 兄弟不直连:兄弟组件通信必须通过共同父组件中转

这些原则看似简单,却构成了 React 应用架构的坚实基础。它们确保了应用的状态可预测、代码可维护、bug 可追踪。记住:在 React 中,状态变更永远应该是显式的、受控的、可追溯的——这正是现代前端工程化的精髓所在。