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

51 阅读8分钟

写在前面

在 React 开发中,组件不是各自为政的孤岛,而是通过一套清晰规则协同工作的单元。掌握组件之间如何安全、高效地传递信息,是写出可维护、可扩展应用的关键。本文以一个经典的 Todo 应用为例,带你理解 React 组件通信的三大核心原则。

image.png


一、父子通信:数据只能“向下流”

React 坚持 单向数据流:数据从父组件流向子组件,方式是通过 props

比如在 App.jsx 中,我们定义了整个应用的核心状态:

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

然后把这个状态传给需要它的子组件:

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

这时,子组件只是“消费者”——它们拿到数据后用来渲染界面,但不能修改它。这种只读模式保证了数据来源的单一性和可预测性,是 React 架构的第一道防线。


二、子组件想改数据?请“上报意图”

如果子组件需要改变状态(比如用户点了删除按钮),它不能直接改 props,而必须 调用父组件传下来的函数

父组件提供“操作接口”

App 中,我们提前写好修改逻辑:

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 
  todos={todos}
  onDelete={deleteTodo}
  onToggle={toggleTodo}
/>

TodoList 内部,用户操作会触发这些回调:

<button onClick={() => onDelete(todo.id)}>X</button>
<input onChange={() => onToggle(todo.id)} />

重点在于:

子组件不关心“怎么删”,只负责说“我想删哪个”。真正的逻辑由父组件掌控。

这种“描述意图 + 父级执行”的模式,既解耦了组件,又集中了状态管理。


三、兄弟组件通信:状态提升的本质与工程意义

在 Todo 应用中,TodoInputTodoListTodoStats 虽然功能各异,却共享同一份数据源:任务列表 todos。当用户通过 TodoInput 添加一条新任务时,TodoList 需要渲染它,TodoStats 需要更新计数——这看似简单的联动,实则触及了 React 架构的核心命题:如何在保持组件独立性的同时,实现跨组件的状态同步?

❌ 为什么兄弟组件不能直接通信?

直觉上,我们可能会想:“既然 TodoInput 知道自己加了任务,为什么不直接调用 TodoList.add() 或修改 TodoStats.count?”
这种想法在小型 demo 中或许可行,但在真实项目中会迅速导致:

  • 隐式依赖:组件之间形成看不见的耦合,修改一个可能意外破坏另一个。
  • 状态分散:数据散落在多个组件中,无法确定“哪个才是最新值”。
  • 调试困难:状态变更路径不透明,难以追踪 bug 源头。
  • 测试复杂:组件无法独立运行,必须模拟整个上下文。

React 明确拒绝这种模式,因为它违背了可预测性(Predictability)这一现代 UI 框架的基石。

✅ 正确解法:状态提升(Lifting State Up)

React 的答案是:将共享状态提升至最近的共同祖先组件(在这里是 App),由它统一持有、更新和分发。

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

此时,todos 成为整个应用的 单一事实来源(Single Source of Truth) 。所有子组件都从这个源头获取数据,而非彼此。

🧩 分发策略:最小权限原则

父组件并非一股脑把状态全塞给子组件,而是遵循 “最小必要能力”原则

组件接收什么为什么
TodoInput仅 onAdd 回调它只负责“发起添加意图”,无需知道任务列表长什么样
TodoListtodos + onDelete + onToggle它需要完整数据和操作能力来渲染和交互
TodoStats计算后的 total 和 activeCount它只关心统计结果,不关心原始数据结构

这种按需分发的设计,使得每个组件的职责边界极其清晰,也天然支持未来扩展(例如新增一个“任务导出”按钮,只需从 App 获取 todos 即可,无需改动其他组件)。

🔁 数据流:声明式更新的威力

以“添加任务”为例,完整的通信链路如下:

  1. 用户操作:在 TodoInput 中输入并提交
  2. 意图上报TodoInput 调用 onAdd(text) —— 注意,它不执行任何状态变更,只是发出一个“请求”
  3. 状态更新App 执行 setTodos([...todos, 新任务]),生成全新状态
  4. 自动同步:React 检测到 todos 变化,触发 App 重新渲染
  5. 数据分发:新的 todos 和计算值自动通过 props 流向 TodoList 和 TodoStats
  6. UI 一致更新:所有相关组件基于同一份新状态重新渲染

整个过程无手动通知、无事件广播、无全局变量,完全由 React 的声明式渲染机制驱动。这就是“状态即 UI”的体现:你只描述“状态是什么”,React 负责“如何更新界面”

🏗️ 工程价值:为什么这比“直接通信”更强大?

维度直接通信(反模式)状态提升(推荐)
可维护性修改一个组件需理解多个依赖所有逻辑集中在父组件,修改局部
可测试性组件耦合,难以单元测试子组件纯函数式,易于 mock
可扩展性新增组件需侵入现有通信链新组件只需从 App 获取所需数据
可预测性状态变更路径隐蔽所有变更通过 setTodos 显式发生
调试体验难以追踪状态来源React DevTools 可直接查看 props 流动

更重要的是,这种模式为后续引入状态管理库(如 Redux、Zustand)或服务端状态(如 React Query)打下了思维基础——无论状态来自哪里,组件只关心“消费”,不关心“来源”

⚠️ 常见误区与边界

  • 误区1:“状态提升会导致父组件臃肿。”
    → 解法:当 App 逻辑过重时,可拆分为状态容器组件(如 TodoProvider)+ 展示组件,或使用 Context + useReducer。
  • 误区2:“每次都要传好多 props,太麻烦。”
    → 解法:对于深层嵌套,可用 React.Context 避免 prop drilling,但不要滥用——Context 适合全局状态(如用户登录态),而 todos 是局部业务状态,仍建议显式传递。
  • 边界:如果两个组件确实完全无关(如 Header 和 Footer),却需要共享状态,才考虑全局状态管理。否则,优先使用状态提升。

“兄弟组件通过父组件通信”表面上是一个技术方案,实质上是一种架构哲学

让状态流动可见,让组件依赖显式,让变更路径可追溯。

正是这种克制与纪律,使得 React 应用在规模增长时依然能保持清晰、稳定与可演进。TodoList 虽小,却足以窥见大型应用的治理之道。


四、状态持久化:副作用交给 useEffect

为了让 Todo 数据在刷新后不丢失,我们需要同步到 localStorage。但不要在每个修改函数里手动存!

❌ 错误做法:重复写持久化逻辑

const addTodo = (text) => {
  setTodos(...);
  localStorage.setItem('todos', JSON.stringify(...)); // 重复!
}

这样容易漏写、难维护、逻辑混杂。

✅ 正确做法:用 useEffect 统一监听

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

同时,初始化时从本地恢复:

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

优势很明显:

  • 持久化逻辑只写一次
  • 业务代码更干净
  • 未来换存储方案只需改一处
  • 完全符合 React “副作用分离”的理念

而且,它依然遵循前面的通信原则——状态变更仍是显式、受控的,只是副作用被优雅地托管了。


总结:React 通信的黄金三法则

通过这个 Todo 应用,我们可以提炼出三条简单却强大的原则:

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

这些原则看似基础,却是构建健壮 React 应用的基石。它们让状态变得可预测、可追踪、易调试——而这,正是现代前端工程的核心追求。

记住:在 React 的世界里,状态变更永远应该是显式的、受控的、有迹可循的。只要守住这条底线,你的应用就能在复杂度增长时依然保持清晰与稳定。


附录源码

App.jsx

import {
  useState,
  useEffect
} from 'react'
import './styles/app.styl'
import TodoList from '../components/TodoList'
import TodoInput from '../components/TodoInput'
import TodoStats from '../components/TodoStats'
function App() {
  // 子组件共享的数据状态
  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,

    }])
  }
  // 从待办事项列表中删除指定 ID 的任务。
  const deleteTodo =(id) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }
  // 计算未完成(活跃)的待办事项数量。
  const activeCount = todos.filter(todo => !todo.completed).length;
  // 计算已完成(完成)的待办事项数量。
  const completedCount = todos.filter(todo => todo.completed).length;
  // 切换指定 ID 任务的完成状态。
  const toggleTodo = (id) => {
    setTodos(todos.map(todo => todo.id === id ? {
      ...todo,
       completed: !todo.completed} : todo))
  }
  // 清除所有已完成的任务。
  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed))
  }
  // 监听todos 变化 保存到本地存储
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos))
  }, [todos])  

  return (
    <div>
      <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>
    </div>
  )
}

export default App

TodoInput.jsx

import { useState } from 'react'
const TodoInput = (props) => {
    // console.log(props);
    const { onAdd } = props
    // react 不支持v-model那样的双向绑定 性能不好
    // react 只支持单向绑定 性能好 + onChange实现数据和视图的同步
    // 
    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)}
            />
            <button type="submit">Add</button>
        </form>
    )
}
export default TodoInput

TodoList.jsx

const TodoList = (props) => {
    const {
      todos,
      onDelete,
      onToggle
    } = props;
    
    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)}>X</button>
              </li>
            ))
          )
        }
        TodoList
      </ul>
    );
  };
  
  export default TodoList;

TodoStats.jsx

const TodoStats = (props) => {
  const {
    total,
    active,
    completed,
    onClearCompleted
  } = props
  return (
    <div className="todo-stats">
      <p>Total: {total} | Active:{active} | Completed:{completed}</p>
      {
        completed > 0 && (
          <button
          onClick={onClearCompleted}
          className="clear-btn"
          >Clear Completed</button>
        )
      }
     
    </div>
  )
}

export default TodoStats