React 组件通信:从 Props 单向传递到自定义事件

0 阅读8分钟

React 组件通信:从 Props 单向传递到自定义事件

摘要:本文通过一个完整的 Todo 项目,拆解 React 父子组件通信的核心机制——Props 单向传递与自定义事件,展示数据状态如何统一管理、组件如何协同工作。

📑 目录

  • 组件化开发:从搭积木说起
  • 组件树规划:先画图再写代码
  • 父组件:数据状态的统一管理
  • Props 单向传递:数据从父到子
  • 自定义事件:子组件向父组件“请求修改”
  • 数据驱动界面:修改数据 = 修改界面
  • 互动讨论

组件化开发:从搭积木说起

在 React 中,页面是由一个个独立的组件组合而成的——就像搭积木一样。每个组件封装了自己的 HTML 结构、CSS 样式和 JavaScript 逻辑,形成一个独立的功能单元。

在 Todo 应用中,组件树是这样的:

text

App(根组件)
├── TodoInput(输入组件)
├── TodoList(列表组件)
│   └── TodoItem(条目组件,内嵌在 TodoList 中)
└── TodoStates(统计组件)

每个组件负责一块独立的功能。但问题是:这些组件需要共享数据、协同工作——TodoInput 要新增待办,TodoList 要展示待办列表,TodoStats 要统计完成情况。

这就引出了 React 组件通信的核心命题:父组件如何把数据传给子组件?子组件如何修改父组件的数据?

组件树规划:先画图再写代码

在开始写代码之前,先规划组件树和组件间的通信方式,这是良好的开发习惯,也有助于 Vibe Coding 中让 AI 理解项目结构。

React 遵循单向数据流原则,数据从父组件流向子组件。在这个 Todo 应用中:

  • 数据状态 todos 由父组件 App 统一持有和管理
  • 子组件通过 Props 接收数据(只读)
  • 子组件通过 自定义事件 向父组件“请求”修改数据

整个数据流向是单向的、可追踪的:

text

数据流向:父组件 App → 子组件(只读展示)
修改请求:子组件 → 自定义事件 → 父组件修改 → 重新流向子组件

这种设计让数据的变化来源清晰可追溯,避免了多组件随意修改数据导致的混乱。

父组件:数据状态的统一管理

在 App 组件中,todos 数据状态由 useState 声明并管理:

jsx

const [todos, setTodos] = useState([
  { id: 1, text: '吃饭', completed: false },
  { id: 2, text: '睡觉', completed: false },
  { id: 3, text: '打豆豆', completed: true },
]);

数据为什么要统一管理?  因为数据和界面要统一,数据驱动界面——所以数据必须集中管理,不能分散在各个子组件中各自维护。如果每个子组件都自己管一份数据,界面就会不一致。

App 组件还提供了四个修改数据的方法,这些方法将作为“修改请求的接收器”传递给子组件:

jsx

const addTodo = (text) => {
  if (text.trim() !== '') 
    return setTodos([
      { id: +Date.now(), text, completed: false },
      ...todos
    ]);
};

const toggleTodo = (id) => {
  setTodos(todos.map((todo) => 
    todo.id === id ? { ...todo, completed: !todo.completed } : todo
  ));
};

const deleteTodo = (id) => {
  setTodos(todos.filter(todo => todo.id !== id));
};

const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed));
};

注意这里的写法:修改状态时必须给出一个全新的状态,而不是在原来的基础上修改setTodos 接收的是一个新的数组,而不是修改原数组后传入。

从数据中还可以派生出统计数据,用于传递给统计组件:

jsx

const activeCount = todos.filter(t => !t.completed).length;
const completedCount = todos.length - activeCount;

Props 单向传递:数据从父到子

父组件通过 Props(属性)  将数据和方法传递给子组件。Props 是只读的——子组件只能使用,不能修改。

jsx

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

TodoInput:接收回调函数

TodoInput 接收 onAdd 这个 Props,它是一个函数,由父组件提供:

jsx

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>
  );
};

这里有一个重要的概念区分:组件可以有自己的私有状态inputValue 是 TodoInput 自己的状态(通过 useState 管理),只在组件内部使用,不需要与父组件共享。只有当用户提交表单时,才通过 onAdd(inputValue) 将数据传递给父组件。

autoFocus 属性让输入框在页面加载后自动获得焦点,提升用户体验。

TodoList:接收数据和回调

TodoList 接收三个 Props:todos(数据)、onToggle 和 onDelete(回调函数):

jsx

const TodoList = ({ todos, onChange, 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)}>删除</button>
          </li>
        ))
      )}
    </ul>
  );
};

这里有一个值得注意的地方:组件的 Props 参数名是 onChange,但在实际使用中调用的是 onToggle。这体现了 Props 的灵活性——参数名只是一个“接口约定”,实际传入什么函数由父组件决定。父组件传的是 onToggle={toggleTodo},所以 TodoList 中调用的实际上是 toggleTodo 函数。

key={todo.id} 是列表渲染中必须提供的,帮助 React 识别每个列表项的唯一身份,优化 Diff 算法的效率。

TodoStates:接收统计数据和清除回调

TodoStates 接收四个 Props:totalactivecompletedonClearCompleted

jsx

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

这里使用了条件渲染{completed > 0 && ( <button>... )} 的意思是——只有存在已完成事项时,才显示“清除已完成”按钮。如果 completed 为 0,条件为 false,React 就不会渲染这个按钮。

自定义事件:子组件向父组件“请求修改”

Props 是单向传递的——父组件传给子组件,子组件不能修改 Props。那么,如果子组件需要修改数据怎么办?

答案是:子组件通过调用父组件传入的回调函数(自定义事件),向父组件“请求修改”

子组件只有使用权,没有修改权。如果要修改,只能通过自定义事件通知父组件,父组件修改后,新的数据通过 Props 重新流向子组件,界面自动更新。

TodoInput 的提交事件

当用户在 TodoInput 中点击提交时:

jsx

const handleSubmit = (e) => {
  e.preventDefault();      // 阻止表单默认提交行为
  onAdd(inputValue);       // 调用父组件传入的回调
  setInputValue('');       // 清空输入框
};

onAdd(inputValue) 触发了父组件的 addTodo 方法,父组件更新 todos 状态后,新的数据通过 Props 重新传递给 TodoList,列表自动刷新。

TodoList 的切换和删除事件

当用户点击复选框或删除按钮时:

jsx

<input 
  type="checkbox" 
  checked={todo.completed} 
  onChange={() => onToggle(todo.id)}   // 切换完成状态
/>
<button onClick={() => onDelete(todo.id)}>删除</button>  // 删除待办

onToggle(todo.id) 触发了父组件的 toggleTodo 方法,onDelete(todo.id) 触发了父组件的 deleteTodo 方法。父组件修改状态后,数据重新流向子组件。

TodoStates 的清除事件

当用户点击“清除已完成”按钮时:

jsx

<button className="clear-btn" onClick={onClearCompleted}>
  Clear Completed
</button>

onClearCompleted() 触发了父组件的 clearCompleted 方法,过滤掉所有已完成事项,剩余的待办列表重新渲染。

数据驱动界面:修改数据 = 修改界面

整个 Todo 应用的运作流程可以总结为:

  1. 初始化:App 组件中的 useState 定义了初始的 todos 数据
  2. 数据传递todos 通过 Props 传递给 TodoList 组件进行渲染
  3. 用户交互:用户在子组件中操作(输入、点击复选框、点击删除)
  4. 请求修改:子组件通过自定义事件调用父组件的方法
  5. 状态更新:父组件使用 setTodos 修改状态(注意:必须传入全新的状态
  6. 重新渲染:新的 todos 通过 Props 重新流向子组件,界面自动更新

这就是“数据驱动界面”的核心思想:你不需要手动操作 DOM 来增删改列表项,只需要修改数据状态,界面会自动重新渲染。

数据要统一管理,数据变化要可预测。父组件持有数据,子组件通过自定义事件请求修改,所有数据变化都经过父组件的“审批”——这样的数据流清晰、可追踪,不会出现“改了 A 组件的数据但 B 组件没更新”的混乱情况。

互动讨论

💬 为什么子组件不能直接修改 Props?

React 遵循单向数据流原则。如果子组件可以随意修改 Props,数据流动就会变得混乱——你无法追踪某个数据是在哪里被修改的。通过自定义事件让父组件统一修改,数据变化来源清晰可追溯。

💬 组件自己的状态和父组件传的 Props 有什么区别?

State 是组件自己的私有数据,由组件自己管理,可以自由修改。Props 是父组件传入的,子组件只读。TodoInput 中的 inputValue 是 State,而 onAdd 是 Props——前者管自己,后者来自父组件。

💬 为什么 setTodos 要传入新数组,不能直接修改原数组?

React 通过比较状态的新旧值来判断是否需要重新渲染。直接修改原数组(如 todos.push(newTodo))不会改变数组的内存地址,React 无法检测到变化,也就不会触发重新渲染。必须传入一个全新的数组,让 React 知道“数据变了”。

💬 key={todo.id} 有什么用?

key 帮助 React 识别列表中的每个元素。在列表发生变化时(新增、删除、排序),React 通过 key 来判断哪些元素是新增的、哪些被删除、哪些只是移动了位置。使用稳定的唯一 ID 作为 key,能让 Diff 算法更高效,避免不必要的 DOM 操作。

💬 为什么要在 TodoList 中判断 todos.length === 0

这是条件渲染的典型用法。当列表为空时,显示友好的空状态提示“No todos yet!”而不是空白页面。良好的用户体验不仅要有数据展示,也要有空状态的反馈。