React 组件通信实战:用 TodoList 理解 Props、回调与状态提升

83 阅读6分钟

当一个 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 计算得到的值,通常应在渲染期间直接计算。

完整的数据流

以“完成一项任务”为例,整个过程只有一条清晰路径:

  1. App 把 todos 和 onToggle 传给 TodoList;
  2. TodoList 根据 todos 渲染复选框;
  3. 用户点击复选框,子组件调用 onToggle(todo.id);
  4. App 中的 toggleTodos 使用 map 生成新数组;
  5. setTodos 更新状态;
  6. React 使用新的 Props 重新渲染列表和统计组件。

组件之间没有互相查找,也没有共享可变变量。状态始终由 App 持有,数据通过 Props 向下流动,操作通过回调函数返回父组件。

总结

React 组件通信的关键并不是记住多少种 API,而是先确定数据归属:

  • 多个组件共享的状态,提升到最近的共同父组件;
  • 只有一个组件使用的状态,留在组件内部;
  • 父组件通过 Props 向子组件传递数据;
  • 子组件调用函数 Props,请求父组件更新状态;
  • 数组和对象使用不可变方式更新;
  • 可以从现有状态推导出的值,不重复保存为 State。

当状态只有一个可信来源时,TodoList 的输入、列表和统计信息就会始终保持一致。组件虽然被拆开了,数据流反而更加清晰。