陌上人如玉
公子世无双

4.5 key 的作用与选择

4.5 key 的作用与选择

一句话:key 是 React 判断「新旧列表中的元素是否是同一个」的身份标识——它决定组件是「复用」还是「重建」;用 index 或随机值会在列表变动时造成状态错位或性能灾难。

一、key 的语义

// 没有 key 时,React 只能用「位置(index)」来推断身份
<ul>
  {items.map((item) => <li>{item.text}</li>)}          {/* ⚠️ React 会警告 */}
</ul>

// 有 key 时,React 用 key 来判断「是否同一个」
<ul>
  {items.map((item) => <li key={item.id}>{item.text}</li>)}
</ul>
「同一个元素」意味着什么?

✅ 复用:
   · 保留原有的 DOM 节点(不重新创建)
   · 保留组件内部状态(useState / useRef / DOM 状态如输入框光标)
   · 保留 DOM 上的副作用(如 CSS 过渡、媒体播放位置)
   · 只更新变化的属性

❌ 重建(不复用):
   · 卸载旧组件(触发清理函数、ref 置 null)
   · 创建新 DOM 节点与组件实例
   · 状态全部重置(输入内容、展开态、动画进度)
一句话理解:
  「key 相同 = 我认为它是同一个东西,请尽量复用」
  「key 不同 = 这是另一个东西,请重新造一个」

二、key 不匹配时会发生什么

// React 判断逻辑(简化)
for (每个新元素) {
  if (可以在旧列表中找到 key 相同的) {
    复用那个 fiber(更新 props)
  } else {
    创建新 fiber(Placement)
  }
}
for (旧列表中「没被复用」的) {
  删除(ChildDeletion)→ 卸载组件、移除 DOM
}
新旧对比 React 的判定
key 相同 + type 相同 复用(更新 props)
key 相同 + type 不同 删除旧的、创建新的(不复用)
key 不同 删除旧的、创建新的
新多了 创建(Placement)
旧多了 删除(ChildDeletion)

三、用 index 作 key 的三个 bug 场景

场景一:在头部插入

// ❌ 用 index 作 key
function List() {
  const [items, setItems] = useState(['A', 'B', 'C']);

  return (
    <>
      <button onClick={() => setItems(['NEW', ...items])}>头部插入</button>
      <ul>
        {items.map((text, i) => (
          <li key={i}>
            {text}
            <input placeholder="备注" />      {/* ⚠️ 有内部状态的元素 */}
          </li>
        ))}
      </ul>
    </>
  );
}
发生了什么?

初始:[A, B, C] → key = [0, 1, 2]
插入后:[NEW, A, B, C] → key = [0, 1, 2, 3]

React 的判定(只看 key):
  key 0: 之前是 A,现在是 NEW → 「同一个」(复用!)→ 只改文本,DOM 和 input 状态保留
  key 1: 之前是 B,现在是 A   → 「同一个」→ 复用
  key 2: 之前是 C,现在是 B   → 「同一个」→ 复用
  key 3: 新增 → 创建

结果:文本看起来是对的(因为文本是「渲染出来的」),
      但**输入框里的内容全部错位了**——
      原本在 A 那一行的备注,现在出现在 NEW 那一行。

场景二:删除中间项

// 初始:[A, B, C],在 B 的输入框里输入了 "hello"
// 删除 B 后:[A, C] → key = [0, 1]

// React 的判定:
//   key 0: A → A   复用(输入框内容保留:空)
//   key 1: B → C   复用(⚠️ 输入框里还留着 "hello")
//   key 2: 删除

// 结果:C 那一行的输入框里显示着 "hello"(本该属于已删除的 B)

场景三:排序 + 带状态的组件

// 一个可以排序的表格,每行有「展开详情」的状态
{rows.map((row, i) => (
  <Row key={i} row={row} />           // ❌ 排序后展开状态跟着「位置」而不是「行」
))}

// 现象:按价格排序后,展开的那一行变成了另一条数据
// 原因:key 是 index,排序只改变了「数据在数组中的位置」,
//       而 React 认为「key 0 还是 key 0」→ 复用 DOM 与状态 → 状态错位
操作 用 index 的后果
列表只读、永不重排 ✅ 没问题(可以接受)
头部/中间插入 后续项的 DOM 与状态错位
删除 被删项的状态「残留」到下一项
排序 状态跟着位置走,与数据不匹配
有受控组件的行 输入内容错乱(最明显的表现)
有动画的行 动画错位
// ✅ 用稳定唯一 ID
{items.map((item) => (
  <li key={item.id}>
    {item.text}
    <input placeholder="备注" />
  </li>
))}
// 现在:key 跟着数据走 → 插入/删除/排序时,「同一个数据」始终是同一个组件实例

四、没有稳定 ID 时怎么办

// ① 业务数据里加 ID(最优解)
// 数据入库/接口返回时就带上 id(数据库主键、UUID、雪花 ID)

// ② 在前端「一次性生成」并保存到数据里(注意不要每次渲染都生成)
function useItems(raw: string[]) {
  const [items, setItems] = useState(() =>
    raw.map((text) => ({ id: crypto.randomUUID(), text }))     // ⭐ 初始化时生成一次
  );
  return items;
}
// ⚠️ 不要写成 {raw.map((text) => ({ id: crypto.randomUUID(), text }))} 直接放在渲染里
//    → 每次渲染都是新 id → 每次都重建整列

// ③ 用「内容 + 类型」拼一个相对稳定的 key(有风险)
key={`${item.type}-${item.title}`}
// ⚠️ 只有在「内容唯一且不变」时才可行

// ④ 列表确实只读且永不重排 → 可以用 index(但要写注释说明原因)
{staticList.map((item, i) => <Row key={i} item={item} />)}   // 可接受
// ② 的一个完整例子:需要「本地生成 id」的场景
function useLocalTodoList(initialTexts: string[]) {
  const [todos, setTodos] = useState<Todo[]>(() =>
    initialTexts.map((text) => ({ id: nanoid(), text, done: false }))
  );
  //                                        ↑ 惰性初始化:只在首次渲染执行一次

  const addTodo = useCallback((text: string) => {
    setTodos((prev) => [...prev, { id: nanoid(), text, done: false }]);
  }, []);

  return { todos, addTodo };
}

五、key 的常见误用

// ① key 加在「map 返回元素的内层」
{items.map((item) => (
  <div>                                   {/* 没有 key */}
    <Row key={item.id} item={item} />     {/* ⚠️ 位置错误:外层没有 key */}
  </div>
))}
// ✅ 正确
{items.map((item) => (
  <div key={item.id}>
    <Row item={item} />
  </div>
))}

// ② 用随机值当 key(每次渲染都重建整列 → 性能灾难 + 状态全丢)
{items.map((item) => <Row key={Math.random()} item={item} />)}    // ❌
{items.map((item) => <Row key={crypto.randomUUID()} item={item} />)}   // ❌ 同上

// ③ 用「对象」当 key(每次渲染都是新引用 → 同样全量重建)
{items.map((item) => <Row key={{ id: item.id }} item={item} />)}   // ❌

// ④ 在 Fragment 简写上写 key(语法不支持)
{items.map((item) => <key={item.id}>...</>)}          // ❌ 语法错误
// ✅ 必须用完整写法
{items.map((item) => <Fragment key={item.id}>...</Fragment>)}

// ⑤ 认为 key 会「传给子组件的 props」
function Row({ key, item }) {                          // ❌ key 不会出现在 props 里
  return <div>{key}</div>;
}
// ✅ 需要传递时用别的 prop 名
<Row key={item.id} id={item.id} item={item} />

// ⑥ 用 key 让组件「每次都重新挂载」(有时是故意的,但要清楚代价)
<Component key={Date.now()} />                         // ⚠️ 每秒重建

六、key 的「非列表」用途(有意为之)

// ① 用 key 重置组件状态(这是官方推荐的模式)
function Profile({ userId }: { userId: string }) {
  const [comment, setComment] = useState('');

  // ❌ 不用 key:切换 userId 时草稿不会清空
  // ✅ 用 key:userId 变化时组件重建 → 状态自然重置
  return <ProfileForm userId={userId} />;
}

function ProfilePage({ userId }: { userId: string }) {
  return <Profile key={userId} userId={userId} />;     // ⭐ 切换用户 = 换一个实例
}
「用 key 重置状态」比「用 useEffect 清空状态」好在哪里?

❌ useEffect 方案:
   useEffect(() => { setComment(''); setDraft(''); setTab('info'); }, [userId]);
   · 需要列出所有要重置的状态(容易漏)
   · 多一次渲染
   · 存在「短暂的旧数据 + 新 userId」的中间态

✅ key 方案:
   <Profile key={userId} />
   · 一次写清(整个组件重建)
   · 没有中间态
   · 语义正确(「不同用户 = 不同实例」)
// ② 用 key 强制「重新触发动画」
function FadeIn({ content, animationKey }: { content: string; animationKey: number }) {
  return <div key={animationKey} className="fade-in">{content}</div>;
}
// 每次 animationKey 变化 → 元素重建 → CSS 动画重新播放

// ③ 用 key 表达「这两个是不同的东西」(避免误复用)
{isLoginMode ? <LoginForm key="login" /> : <RegisterForm key="register" />}
// ⚠️ 其实这里 type 已经不同了,key 不是必需的;但在「同一个组件、不同语义」时有用
{tab === 'a' ? <Editor key="a" docId="a" /> : <Editor key="b" docId="b" />}
// ✅ 同一个 Editor 组件,但代表不同文档 → 用 key 明确区分(避免状态串味)

七、常见坑

// ① 用 index
{list.map((x, i) => <Row key={i} />)}                       // ⚠️ 列表会变动时出 bug

// ② 用随机值 / 时间戳 / 对象
key={Math.random()} / key={Date.now()} / key={{ id }}       // ❌ 全量重建

// ③ key 加错位置(应在 map 返回的最外层元素上)
// ④ key 重复(同一层出现相同的 key → 复用错误 + React 警告)
{items.map((x) => <Row key="same" />)}                      // ❌ 所有项 key 相同

// ⑤ 认为「key 不同 = 重新渲染」(其实是「卸载 + 挂载」,代价更大)
// ⑥ 把 key 当 props 用(拿不到)
// ⑦ 在嵌套列表里重复使用同一套 key(其实只要求「同层唯一」,但容易混淆)
<ul>
  {groups.map((g) => (
    <li key={g.id}>
      {g.items.map((it) => <span key={it.id} />)}            {/* ✅ 同层唯一即可 */}
    </li>
  ))}
</ul>

// ⑧ 用 key 解决「性能问题」(反过来可能更慢)
// ⑨ 在「受控输入」的列表里用 index → 输入内容错乱(最典型的表现)
// ⑩ 认为「有 id 就一定稳定」(id 可能重复或变化,要确认来源)
// ⑪ 在 SSR 与客户端用不同的 key 生成方式(导致 hydration mismatch)
// ⑫ 忘记「key 只在其所在的那一层内唯一」,跨层重复没有关系

八、面试延伸

  1. 「key 有什么用?」
    key 是 React 用来判断「新旧列表中的元素是否是同一个」的身份标识。key 相同 → React 认为「这是同一个元素」→ 复用现有的 fiber、DOM 与组件状态,只更新变化的属性;key 不同 → 认为「换了一个东西」→ 卸载旧的、创建新的(状态重置)。所以 key 直接决定「复用还是重建」,影响性能与状态正确性。

  2. 「为什么不能用 index 作 key?」
    因为 index 表示的是「位置」而不是「身份」。当列表插入、删除或排序时,同一个数据项的 index 会变化,而 React 会「按 key 复用」——于是把「原本属于 A 的 DOM 与状态」复用给了 B,导致:① 有状态组件的内容错位(输入框里的文字跑到另一行);② 状态残留(删除后动画/展开态跑到别的行);③ 受控组件行为异常。只有在「列表只读且永不重排」时才可以用 index。

  3. 「用随机值作 key 会怎样?」
    会导致每次渲染都重建整个列表——因为每次生成的 key 都不同,React 认为「所有这些元素都是新的」→ 卸载全部旧节点、创建全部新节点。后果是:① 性能灾难(大量 DOM 创建/销毁);② 状态全部丢失(输入内容、展开态、动画、媒体播放位置);③ 输入框会每次都失焦。这是最严重的一类 key 错误。

  4. 「key 为什么必须放在 map 返回的最外层元素上?」
    因为 React 是在比较「同层的子元素列表」时使用 key 的——它需要给「列表中的每一项」一个身份。如果你把 key 放在内层元素上,那么「外层的 <div>」就没有 key(React 只能按位置匹配),而内层的 key 只在「这个 div 内部」有意义,无法表达「这一整项是谁」。所以 key 必须放在 map 回调返回的那个最外层元素上。

  5. 「key 会传给子组件吗?」
    不会。key 是 React 的保留字段,它不会被放进 props。所以 function Row({ key }) {} 拿到的是 undefined。如果子组件确实需要这个标识,要额外传一个 prop:<Row key={item.id} id={item.id} />(注意「同名字段」也可以,因为 key 和 props.id 是两回事)。

  6. 「可以用 key 来重置组件状态吗?」
    可以,而且是官方推荐的模式。当「同一个组件在不同 props 下应该是不同实例」时(如 userId 变化),用 <Profile key={userId} /> 就能让 React 在 key 变化时卸载重建,从而自然地重置所有内部状态。这比「在 useEffect 里手动清空每一个 state」更简洁、更不易漏、也没有「短暂中间态」的问题。

  7. 「为什么两个元素类型不同时,key 相同也救不了?」
    因为 React 的判断顺序是「先看 key,再看 type」——即使 key 相同,如果 type 不同(如 <div> 变成 <section>,或两个不同的组件函数),React 仍会认为「这是不同的元素」→ 卸载旧的、创建新的。所以「跨类型复用」在 React 里是不存在的;想避免重建就必须保持同一层级的 type 稳定。

  8. 「key 需要全局唯一吗?」
    不需要,只在「同层(同一个父元素的子元素列表)内」唯一即可。不同层级、不同列表之间可以使用相同的 key(比如两个独立的列表都有 key="1")。但如果同一个列表里出现重复 key,React 会警告并且复用行为会出错(后出现的元素会覆盖前面的身份),所以要保证「同一层内不重复」。

一句话速记

key 是「身份标识」——相同 key 就复用(保留 DOM 与状态),不同就重建(状态重置);用 index 会在插入/删除/排序时让状态错位(最典型的症状是「输入框内容跑到别的行」)、用随机值/对象会让整列每次重建(性能灾难 + 状态全丢);key 必须放在 map 返回的最外层元素、只在同层唯一、不会出现在 props 里;没有稳定 ID 就「数据里加 ID」或「惰性初始化时生成一次」(别写在渲染里);key 还可以用来有意重置组件(<Form key={userId} />,比 useEffect 清空状态更优)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 4.5 key 的作用与选择

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫