当一个 TodoList 只有几十行代码时,把输入框、列表和统计信息全部写进 App 组件也能正常工作。但随着功能增加,继续把所有内容放在一个组件里,会让数据处理和界面渲染混在一起。
更合适的做法是按照职责拆分组件:
App
├── TodoInput 负责输入和提交
├── TodoList 负责展示、切换和删除
└── TodoStates 负责统计和清理已完成项
组件拆开之后,新的问题随之出现:todos 应该由谁保存?子组件怎样读取它?点击子组件里的按钮时,又应该由谁修改它?
这正是 React 组件通信要解决的问题。
先确定状态属于谁
TodoList 中的几块界面都依赖同一份任务数据:
TodoList需要读取任务数组;TodoStates需要根据任务数组计算数量;TodoInput提交后需要向任务数组添加数据。
既然多个兄弟组件需要共享 todos,这份状态就应该放在它们最近的共同父组件 App 中:
function App() {
const [todos, setTodos] = useState([
{ id: 1, text: 'eat', completed: false },
{ id: 2, text: 'sleep', completed: false },
{ id: 3, text: 'study', completed: false },
])
// ...
}
把多个组件需要共享的状态移动到共同父组件中统一管理,就是状态提升。
状态提升不等于把所有状态都塞进根组件。输入框中的 inputValue 只服务于 TodoInput,其他组件不需要它,因此它仍然保留在输入组件内部:
const TodoInput = ({ onAdd }) => {
const [inputValue, setInputValue] = useState('')
// ...
}
判断状态位置时,可以使用一个简单原则:谁需要读取这份状态,就把它放到这些组件最近的共同父组件;只有单个组件使用的临时状态,则留在组件内部。
Props 向下传递数据
父组件持有 todos 后,通过 Props 把子组件需要的数据传下去:
<TodoList
todos={todos}
onToggle={toggleTodos}
onDelete={deleteTodo}
/>
<TodoStates
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
TodoList 接收 todos 并渲染列表:
const TodoList = ({ todos, onToggle, onDelete }) => {
return (
<ul className="todo-list">
{todos.length === 0 ? (
<li className="empty">No todos left</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)}>
Delete
</button>
</li>
))
)}
</ul>
)
}
在这个组件中,todos 是只读输入。子组件根据 Props 描述界面,但不应该直接修改父组件的状态。
列表渲染时还需要为每一项提供稳定的 key。这里使用 todo.id,React 就能在任务增加、删除或改变顺序时识别每一项,而不是仅凭数组位置判断元素身份。
子组件通过回调通知父组件
数据可以通过 Props 从父组件向下传递,但用户的操作发生在子组件中。例如:
- 在
TodoInput中提交任务; - 在
TodoList中切换或删除任务; - 在
TodoStates中清理已完成任务。
React 的常用做法是:父组件定义修改状态的方法,再把方法作为 Props 传给子组件。子组件发生交互时调用这个方法,并把必要的信息作为参数传回去。
以新增任务为例,父组件先定义 addTodo:
const addTodo = (text) => {
if (text.trim() === '') return
setTodos([
{
id: +Date.now(),
text,
completed: false,
},
...todos,
])
}
然后通过 onAdd 传给输入组件:
<TodoInput onAdd={addTodo} />
TodoInput 并不知道任务数组保存在哪里,也不需要接触 setTodos。它只负责在表单提交时调用 onAdd:
const TodoInput = ({ onAdd }) => {
const [inputValue, setInputValue] = useState('')
const handleSubmit = (event) => {
event.preventDefault()
onAdd(inputValue)
setInputValue('')
}
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
placeholder="What needs to be done?"
autoFocus
/>
<button type="submit">Add</button>
</form>
)
}
这里有一个术语需要区分:onAdd、onToggle 不是浏览器原生事件,也不是 React 额外创建的事件对象。它们只是普通的函数 Props,名称使用 on 开头,是为了清楚表达“发生某种操作时调用”。
因此,父子组件通信可以概括为:
父组件把数据和回调函数作为 Props 传下去
↓
子组件读取数据并渲染界面
↓
用户操作时,子组件调用回调函数
↓
父组件更新状态,React 重新渲染相关组件
用不可变更新保持数据流清晰
父组件统一管理状态后,添加、切换、删除和清理操作都集中在 App 中。更新数组时,不直接修改原数组,而是创建一个新数组交给 setTodos。
添加任务:展开运算符
setTodos([
{ id: +Date.now(), text, completed: false },
...todos,
])
切换任务:map
const toggleTodos = (id) => {
setTodos(
todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
)
}
map 返回新数组,只有命中的任务会通过对象展开生成新对象,其他任务保持不变。
删除和清理任务:filter
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed))
}
filter 不会修改原数组,而是返回符合条件的新数组。这种不可变更新让新旧状态边界清楚,也符合 React 对状态更新的使用方式。
统计数据不需要重复保存
总数、未完成数和已完成数都能从 todos 推导出来,因此没有必要再创建三份 State:
const activeCount = todos.filter(todo => !todo.completed).length
const completedCount = todos.length - activeCount
父组件把计算结果传给 TodoStates:
const TodoStates = ({
total,
active,
completed,
onClearCompleted,
}) => {
return (
<div className="todo-states">
<p>
Total: {total} | Active: {active} | Completed: {completed}
</p>
{completed > 0 && (
<button
onClick={onClearCompleted}
className="clear-btn"
>
Clear Completed
</button>
)}
</div>
)
}
如果把这些数量也保存成独立状态,每次修改任务时就必须同步更新多份数据,反而容易出现列表已经变化、统计信息却没有更新的情况。能由现有 Props 或 State 计算得到的值,通常应在渲染期间直接计算。
完整的数据流
以“完成一项任务”为例,整个过程只有一条清晰路径:
App把todos和onToggle传给TodoList;TodoList根据todos渲染复选框;- 用户点击复选框,子组件调用
onToggle(todo.id); App中的toggleTodos使用map生成新数组;setTodos更新状态;- React 使用新的 Props 重新渲染列表和统计组件。
组件之间没有互相查找,也没有共享可变变量。状态始终由 App 持有,数据通过 Props 向下流动,操作通过回调函数返回父组件。
总结
React 组件通信的关键并不是记住多少种 API,而是先确定数据归属:
- 多个组件共享的状态,提升到最近的共同父组件;
- 只有一个组件使用的状态,留在组件内部;
- 父组件通过 Props 向子组件传递数据;
- 子组件调用函数 Props,请求父组件更新状态;
- 数组和对象使用不可变方式更新;
- 可以从现有状态推导出的值,不重复保存为 State。
当状态只有一个可信来源时,TodoList 的输入、列表和统计信息就会始终保持一致。组件虽然被拆开了,数据流反而更加清晰。