如果你在 React 中投入过时间,你一定在控制台中见过这个黄色的警告:“Each child in a list should have a unique ‘key’ prop.”(列表中的每个子项都应有一个唯一的 ‘key’ 属性。) 这听起来像是一个礼貌的建议,但实际上 React 是在警告你,它无法区分你的列表项。忽视它,你最终会发布一个难以复现的 Bug——例如状态跳到了错误的行、文本输入框失去焦点,或者动画在错误的元素上触发。

React 的渲染引擎并不会对你的 UI 进行像素级的对比。它会构建一个被称为“虚拟 DOM”的轻量级对象树,将新树与旧树进行对比,并计算出更新真实 DOM 所需的最少变更集。当你渲染一个列表时,React 看到的是一组兄弟元素数组。如果没有 key,它就没有可靠的方法来判断某个项目是移动了、被替换了,还是被删除了。它默认会按位置进行匹配,而这种方式是非常脆弱的。Key 起到了稳定身份标识的作用。它们告诉 React:“即使这个元素现在处于不同的位置,它仍然是之前的那个元素。” 如果处理不当,你就是在用“猜测”来代替“确定性的更新”。

最低限度的修复方法

该警告通常出现在 map 调用中。你必须在迭代器返回的最顶层元素上分配一个唯一的 key 属性。

以下是在每个触发该警告的代码库中都能看到的模式:

const UserList = ({ users }) => {
  return (
    <ul>
      {users.map((user) => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
};

React 看到了三个 <li> 标签,但不知道哪个是哪个。修正方法只需添加一个属性:

const UserList = ({ users }) => {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

key 必须分配给 map 回调函数中直接返回的元素。如果你将 <li> 提取到一个单独的 UserItem 组件中,key 仍然属于调用处的组件:

{users.map((user) => (
  <UserItem key={user.id} user={user} />
))}

将 key 放在 UserItem 内部的 <div> 上并不能消除警告,也无法修复协调(reconciliation)行为。React 会在迭代器返回的元素上寻找 key。

为什么使用 Index 作为 Key 是危险的

通过使用 map 的第二个参数来消除警告是很诱人的:

{users.map((user, index) => (
  <li key={index}>{user.name}</li>
))}

这消除了控制台的噪音,但并没有解决根本问题。数组索引并不是身份标识。它们只是位置,而位置是会变化的。

假设有一个包含三个用户的列表,按以下顺序渲染:

  1. Alice (index 0)
  2. Bob (index 1)
  3. Charlie (index 2)

如果你删除了 Alice,Bob 会移动到 index 0,Charlie 会移动到 index 1。React 将新树与旧树进行对比。它看到 index 0 现在持有 Bob 的数据,因此它会修改之前显示 Alice 的那个现有 DOM 节点。如果该节点原本拥有焦点,光标会停留在第一行,但文本却变成了 Bob。如果该行包含一个带有本地状态的 <input>,那么该状态会一直绑定在 index 0 上。用户在看起来像是 Bob 的那一行输入内容,但状态却属于 Alice。当你进行排序、过滤或在开头插入项目时,同样的混乱也会发生。索引 key 唯一安全的使用场景是真正的静态列表:没有重新排序、没有过滤、没有插入,也没有删除。永远不会改变的硬编码导航链接就是一个很好的例子。其他所有情况都需要一个真正的标识符。

在哪里可以找到稳定的 Key

最好的 key 是数据模型中已经存在的唯一标识符。数据库主键(如 id)是理想的选择,因为它们保证了唯一性,并且在多次渲染之间保持不变。如果你的后端返回的对象带有 uuidslug 或其他天然唯一的字段,请直接使用它们。

当你的 API 响应缺乏任何唯一字段时,你有两条实际的路径。首先,与后端团队沟通,要求他们包含一个 id。在没有主键的情况下交付关系型数据是一种代码异味(smell),在源头解决问题可以消除整个技术栈中的歧义。其次,如果你完全是在客户端生成项目——例如,用户在数据到达服务器之前创建任务的待办事项列表——请在创建时生成一个 ID。像 uuidnanoid 这样的库正是为此而生的。在用户提交表单时生成 ID,将其存储在对象中,并永远将其用作 key。

切勿在渲染路径中生成 key。在组件渲染期间调用 Math.random()Date.now() 会导致每次渲染都产生一个新值。React 会看到一个全新的 key,认为这是一个全新的元素,从而销毁旧的 DOM 节点并创建一个全新的节点。该元素内的任何状态都会重置。焦点会丢失。由于 React 在进行不必要的 DOM 操作,性能会大幅下降。随机生成的 key 比没有 key 还要糟糕。

Fragments、组件与作用域

一个不太明显的陷阱涉及 React Fragments。如果你对数据进行 map 操作,并且需要在不使用包装 <div> 的情况下返回多个兄弟元素,你可能会使用简写语法:

{items.map((item) => (
  <>
    <dt>{item.term}</dt>
    <dd>{item.definition}</dd>
  </>
))}

简写形式 <>...</> 不支持 props,这意味着你无法为其附加 key。在这种情况下,请切换到完整的显式语法:

{items.map((item) => (
  <React.Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.definition}</dd>
  </React.Fragment>
))}

React 需要在 Fragment 上设置该 key,以便在多次渲染中将其作为一个单一单元进行追踪。

另一个微妙的点是:key 在通常意义上并不是 props。如果你写 <ListItem key={item.id} />ListItem 组件无法读取 props.key。React 会在内部消耗它用于记录管理。如果你的组件确实需要该标识符来进行自身的逻辑处理,请使用不同的名称单独传递,例如 itemId

确保安全的实践规则

  • 优先使用数据库 ID。 它们是唯一的、基于数字或字符串的,并且是稳定的。
  • 对于仅限客户端的数据,使用 uuidnanoid 在创建记录时生成一次 ID,而不是在组件渲染过程中生成。
  • 如果列表会发生变化,切勿根据数组索引派生 key。 排序、过滤和删除操作会引入视觉和状态方面的 bug。
  • 切勿使用 Math.random()Date.now() 或任何在渲染之间会发生变化的值。 这会导致不必要的卸载(unmounting)和重新挂载(remounting)。
  • 请记住,如果 Fragment 位于 map 内部且需要 key,则必须使用完整形式。
  • 将 key 放在 map 返回的元素上,而不是放在子组件内部。

核心要点

key prop 并不是一个装饰性的 lint 规则。它是 React 在多次渲染中维持身份(identity)的方式。可以把它想象成数据库表中的主键。当该身份保持稳定时,React 就可以精确地移动、更新和移除元素。当它缺失或不稳定时,你将付出代价,导致 UI 状态损坏和协调(reconciliation)过程缓慢。在数据层一次性解决这个问题,无论你的列表如何增长或变化,其行为都将是可预测的。