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:total、active、completed、onClearCompleted:
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 应用的运作流程可以总结为:
- 初始化:App 组件中的
useState定义了初始的todos数据 - 数据传递:
todos通过 Props 传递给 TodoList 组件进行渲染 - 用户交互:用户在子组件中操作(输入、点击复选框、点击删除)
- 请求修改:子组件通过自定义事件调用父组件的方法
- 状态更新:父组件使用
setTodos修改状态(注意:必须传入全新的状态) - 重新渲染:新的
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!”而不是空白页面。良好的用户体验不仅要有数据展示,也要有空状态的反馈。