Published on

React 列表渲染的 key 属性

在 React 中,通过数组映射来渲染元素列表是一种基础且常见的模式。在此过程中,key prop 扮演着一个至关重要的角色。它并非一个普通的属性,而是 React 用于其协调算法 (Reconciliation Algorithm) 的一个核心“提示 (hint)”。key 的正确使用,直接决定了列表在动态变化(如增、删、改、重排)时的渲染性能和状态管理的正确性。

key 的核心作用:元素的稳定身份标识

key 的核心作用是为列表中的每个元素提供一个稳定的身份标识。当 React 对比两次渲染之间的元素列表时,它会使用 key 来匹配新旧两个列表中的元素,从而判断哪些元素是新增的、被删除的,或是仅仅移动了位置。

key 的两大核心要求

  1. 唯一性 (Unique): 在同一组兄弟节点中,每个元素的 key 必须是唯一的。
  2. 稳定性 (Stable): 一个元素的 key 不应随时间或重新渲染而改变。它应该与该数据项本身进行绑定。
如何选择 key

  • 来自数据库的数据: 应始终使用数据项的唯一主键(如 item.id)作为 key
  • 本地生成的数据: 如果数据没有稳定 ID,应在创建时为其生成一个。可以使用 uuid 等库来生成唯一标识符。
  • 应避免: 绝对不要使用数组的索引 (index) 作为动态列表的 key,除非列表是完全静态、永不重排或筛选的。

key 对协调算法与性能的影响

React 的高效得益于其能够计算出 UI 状态变更的最小差异集,并仅对改变的部分进行 DOM 更新。key 在此过程中扮演了“寻路标”的角色。

反模式:使用索引作为 key(或不提供 key

当不提供 key 时,React 会默认使用数组的索引 (index) 作为 key。如果一个列表的顺序会发生变化,这会导致严重的性能问题。

  • 场景: 一个列表 [A, B, C],在 AB 之间插入一个新元素 D,列表变为 [A, D, B, C]
  • React 的 diffing 过程
    1. key=0: A -> A。节点可复用。
    2. key=1: B -> D内容不同,React 会销毁 B 组件,新建并挂载 D 组件。
    3. key=2: C -> B内容不同,React 会销毁 C 组件,新建并挂载 B 组件。
    4. key=3: 之前不存在。新建并挂载 C 组件。
  • 后果: 尽管 BC 组件实例只是移动了位置,但由于 key(索引)的变化,React 错误地认为它们是全新的元素,导致了三个组件的不必要重渲染

最佳实践:使用稳定 key

  • 场景: 同样是 [A, B, C] 变为 [A, D, B, C],但每个元素都有稳定的 ID。
  • React 的 diffing 过程:
    1. React 发现 key='id-a'A 仍然存在。
    2. React 发现 key='id-b'Bkey='id-c'C 也仍然存在,只是位置变了。它会移动这两个组件的 DOM 节点,而不会重新创建或渲染它们。
    3. React 发现 key='id-d'D 是一个新元素,于是只创建并挂载 D 组件。
  • 后果: 实现了最高效的 DOM 更新,只进行了 1 次创建和 2 次移动,没有不必要的重渲染

key 与组件状态的正确性

使用索引作为 key 不仅是性能问题,更严重的是,它可能导致组件内部状态的错乱

索引 key 导致的状态管理 Bug

当列表项是带有内部状态的组件(例如,一个 <input>),使用索引作为 key 会在列表项被删除或重排时,导致状态与数据的不匹配。React 复用组件实例是基于 key 的,如果 key 是索引,当数据项在数组中被删除时,React 会复用 DOM 节点,但会用新的 props 来渲染它,而该组件实例的内部 state 却被保留了下来

输入框状态错乱的经典案例

使用索引作为 key (错误):

import React, { useState } from 'react';

export default function TodoListWithIndexKey() {
  const [todos, setTodos] = useState([
    { id: 'a', text: '任务 A' },
    { id: 'b', text: '任务 B' },
    { id: 'c', text: '任务 C' },
  ]);

  const handleDeleteFirst = () => {
    // 移除数组的第一个元素
    setTodos(currentTodos => currentTodos.slice(1));
  };

  return (
    <div>
      <h3>反模式:使用 Index 作为 Key</h3>
      <p>步骤:1. 在“任务 A”的输入框里输入一些文字。 2. 点击删除按钮。</p>
      <button onClick={handleDeleteFirst}>
        删除第一项
      </button>
      <ul>
        {todos.map((todo, index) => (
          // ❌ 错误:在动态列表中,使用不稳定的 index 作为 key
          <li key={index}>
            <span>{todo.text}: </span>
            <input 
              type="text" 
              placeholder="在这里输入..."
            />
          </li>
        ))}
      </ul>
    </div>
  );
}

复现步骤:

  1. 在第一个输入框(任务 A)中输入 "111"。
  2. 点击“删除第一项”按钮。
  3. 观察结果: “任务 A” 消失了,但它下方的输入框中的 "111" “漂移” 到了现在是第一项的“任务 B”的输入框中。这是因为 React 认为 key=0<li> 只是内容从“任务 A”变成了“任务 B”,所以复用了 <li> 及其子组件 <input>,导致其内部 state 被错误地保留。

解决方案: 将 key={index} 替换为 key={todo.id} 即可完全修复此问题。