首页 / 文章

将 React 的键作为组件标识:状态的保留与重置

React的键决定了在渲染后哪个组件实例会保留。索引键如何导致行与行之间的状态泄露,以及如何通过更改键来刻意重置表单。

发布者:REACTAPP.TOP阅读约 6 分钟1462 词

来源: React Keys Solve a Much Stranger Problem Than Rendering Lists

大多数 React 开发者在使用 .map() 时都会不假思索地为每个元素添加 key,而大多数解释也仅停留在“React 需要它来追踪哪个项目发生了变化”这一层面。这个答案虽然正确,但并不完整。React 在每次渲染时真正要判断的是:当前看到的元素是否与上次看到的一致,而 key 就是用来回答这个问题的方式。一旦将 key 视为一种身份标识机制,基于索引的错误就会变得可预测,同时也能让你以一种简洁、声明式的方式按需重置组件的状态。

反射式版本

几乎所有代码库都采用以下模式:数组中的每个用户都会对应一个 User 元素,而该用户的 id 则作为 key:

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

这段代码本身没有问题。有趣的是,当键的选择不当会出现什么问题,而这种情况只有在组件拥有自身状态时才会显现。

带状态的行会让问题显现

考虑一个带有本地selected标志的卡片组件。该标志存在于组件内部,而非user属性中,因此React需要在每次渲染时决定哪个卡片实例拥有哪个标志:

interface UserCardProps {
  user: User;
}

const UserCard = ({ user }: UserCardProps) => {
  const [selected, setSelected] = useState(false);

  return (
    // ...
  );
}

现在渲染三个人:Alice、Bob以及第三位用户。以索引到项的配对形式表示时,数组看起来像这样:

0 → Alice
1 → Bob
2 → Maxime

React通过索引键看到的内容

常见的建议是避免使用数组索引作为键。如果查看使用索引时React实际接收到的元素,原因就会变得清晰:

<User key={0} user={alice} />
<User key={1} user={bob} />
<User key={2} user={maxime} />

假设该列表支持删除、添加或拖放重排功能。移除 Alice 后,下次渲染的结果如下:

<User key={0} user={bob} />
<User key={1} user={maxime} />

从你的视角来看,Alice 已消失,另外两人上移了位置。Bob 从索引 1 移动到索引 0,第三位用户则从 2 移动到 1。但从 React 的视角来看,并没有信息表明这一点。它只看到之前存在且现在仍然存在的元素,其 key={0} 值不变,组件类型也相同。相同的组件类型加上相同的 key 正是 React 用来判断是否保留某个实例的依据。因此,它会保留之前用于渲染 Alice 的实例并为其分配 Bob 的属性,保留第二个实例并为其分配第三位用户的属性,同时卸载第三个实例。

其后果是:如果选中了爱丽丝的卡片,鲍勃的卡片现在也会显示为已选中状态,因为“已选中”状态属于键值为0的那个实例,而非爱丽丝的卡片。同样的问题也会出现在未经控制的输入、焦点状态、行内的滚动位置,以及与实例相关的任何其他状态上。

为何这些漏洞会存在

在各种规模的真实应用中,使用错误的键值其实相当常见。那些仅用于追加元素且从不改变顺序的列表,其表现与使用索引键或稳定键的列表完全相同,因此看起来并无问题。直到有人添加删除、过滤、排序或重新排列功能时,这类漏洞才会显现出来。使用代码检查工具能有所帮助:eslint-plugin-react中的react/no-array-index-key规则会标记出索引键,从而促使开发者有意识地做出选择,而非凭习惯行事。

当某个元素确实没有稳定的标识符时,应在数据创建时生成一个(例如在插入时使用 crypto.randomUUID()),而非在渲染阶段。在 .map() 中生成的键会在每次渲染时变化,迫使 React 重新渲染每一行,这比使用索引更糟糕。

Props用于描述内容,键用于描述身份

一个简洁的心智模型有助于理解这一点:

  • props回答的是“当前这个元素包含什么?”
  • 键回答的是“这是哪个元素?”

在组件的实例及其状态保持不变的情况下,props可以自由变化。如果某一行支持内联编辑,更改用户的姓名不应导致 React 丢弃该行并重新开始处理;因为这仍然是同一个用户。像 user.id 这样的稳定键正是为了表达这一点而存在的。

刻意强制创建新实例

有时你可能需要相反的效果:希望 React 将某个组件视为全新的。键值在列表之外也同样有效:

<Profile key={user.id} user={user} />

假设 Profile 在本地状态中保存了一个未保存的草稿:

interface ProfileProps {
  user: User;
}

const Profile = ({ user }: ProfileProps) => {
  const [draft, setDraft] = useState({...});
}

如果父组件在不同用户之间切换,而将前一个用户的草稿带入下一个用户配置文件中,那就会构成严重的错误,甚至可能导致数据泄露。如果没有键值,React 会认为同一位置上的组件类型相同,因此会保留该实例,导致草稿依然存在。而使用 key={user.id} 后,新的标识符会告诉 React 旧的 Profile 已经消失,于是它会卸载旧实例、丢弃其状态、执行相关的清理操作,然后创建一个新实例,并重新运行该实例中的 useState 初始化代码。

这是一种比手动编写监听 user 并逐个重置状态的效果更符合惯用方式的解决方案。那种效果处理方式会在修正之前先使用过时的状态进行一次渲染,而且很容易遗漏某个字段。关于为何通过效果同步状态往往适得其反的更多信息,请参阅替代基于 useEffect 的同步方式的更安全模式。

使用键重置表单

同样的技巧也可以让表单实现干净的复位:

<CreateUserForm key={draft.id} />

该键起到了标识边界的作用。每当 draft.id 发生变化时,比如在成功提交后创建了新的草稿,React 会生成一个新的 CreateUserForm,其中不会保留任何字段值、验证错误或已修改标记。

有两点需要注意。重新挂载会重置整个子树,包括其子节点以及它们在挂载时获取的所有内容,因此仅适用于需要完全重启的组件。此外,这一操作会涉及完整的卸载和挂载过程,对于表单来说尚可接受,但不宜在每次按键时都触发。

状态的实际存储位置

这一切都源于 React 存储状态的方式。状态并不绑定在你编写的函数上;如果真是这样,每个 <User /> 都会共享同一个 selected 值。实际上,React 是将状态与渲染树中的位置相关联的。在每次渲染时,它会遍历新的输出结果,并为每个元素判断它是否是上一次渲染内容的延续。组件类型和位置是做出该判断的依据,而在兄弟元素之间,则通过 key 来进一步精确判断。文章 探讨了 useState 如何保持其值,更深入地分析了背后的 fiber 结构。

由此得出的规则很简单:

  • 相同类型、相同位置、相同 key:React 会将其视为同一个组件并保留其状态
  • 类型不同或键值不同:React会丢弃旧状态并创建新实例
  • 列表正是位置信息不再可靠的首个场景。当列表项可以移动、出现或消失时,“第二个子元素”就不再等同于“上一次的那个元素”,因此React需要你提供一个标识。稳定的键能让它在列表结构变化时仍能跟踪某个元素;而变化的键则明确表示已有新元素取而代之。

    核心要点

    • 键的作用是告诉React某个元素属于哪个实例,而不仅仅是哪一项发生了变化。
    • 索引键将状态与特定位置绑定,因此删除或重新排序列表项会导致状态被分配到错误的行上。
    • 应使用在多次渲染中都保持不变的id,绝不要在渲染过程中生成键值。
    • 故意更改键值是重置组件(如表单或实体编辑器)的最简洁方法。
    • 请谨慎使用按键重置:该方法会重新挂载整个子树并丢弃其中的所有内容。