前端开发··2 阅读·预计 8 分钟

React key 的语义边界:从隐式索引到稳定身份的类型安全治理

React 官方文档把 key 描述为“帮助 React 识别哪些元素发生了变化”,但工程实践中它常被降级为一句消除警告的补丁。本文要谈的不是“怎么消掉那个黄色 warning”,而是:key 本质上是列表项的身份(identity),把身份选型交给运行时的隐式索引,等于让 React 的 reconciler 在错误的前提上做 diff。

为什么索引是一种身份错觉

先看一个反面案例——用数组下标做 key

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>{todo.title}</li>
      ))}
    </ul>
  );
}

这串代码在只追加(append)时看起来完全正常:新元素拿到新下标,旧元素下标不变,React 能正确复用。问题出在头部插入、删除或重排时。假设列表从 [A, B, C] 变为 [X, A, B, C],下标 0、1、2、3 对应的元素全部改变:

  1. React 认为下标 0 的节点从 A 换成了 X,销毁旧 DOM 重建;
  2. 下标 1B 换成 A,再次重建……
  3. 所有带状态的子组件(受控输入、动画、焦点)全部错位。

更隐蔽的是 useState 状态与 DOM 焦点会被“嫁接到”错误的下标上。一个经典复现:

function Row({ label }: { label: string }) {
  const [checked, setChecked] = useState(false);
  return (
    <label>
      <input
        type="checkbox"
        checked={checked}
        onChange={(e) => setChecked(e.target.checked)}
      />
      {label}
    </label>
  );
}

function BadList({ items }: { items: string[] }) {
  return (
    <>
      {items.map((item, i) => (
        <Row key={i} label={item} />
      ))}
    </>
  );
}

当你勾选第一项再在头部插入一项,勾选状态会“平移”到新插入项上——因为 React 复用了下标 0 对应的组件实例。这不是 bug,是把身份定义错了

正确姿势:让数据自带稳定身份

修正方式很简单,让 key 来自数据域的唯一标识:

function GoodList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

这里 todo.id 是稳定、唯一、可预测的身份。只要 id 不变,无论列表如何增删重排,React 都能精确复用对应组件实例,状态与 DOM 始终跟随正确的数据。

类型系统如何抓住身份选型错误

靠 Code Review 让人“别用索引做 key”是不可持续的。我们能用 TypeScript 把“key 必须来自稳定身份”固化为编译期约束。核心思路是:key 的类型退化为一个不允许下标出现的集合

第一步,定义一个携带稳定 ID 的泛型约束:

type Identifiable = { id: string | number };

第二步,封装一个受约束的列表渲染工具,强制 key 只能取 id

function KeyedList<T extends Identifiable>({
  items,
  render,
}: {
  items: T[];
  render: (item: T) => React.ReactNode;
}) {
  return (
    <>
      {items.map((item) => (
        <Fragment key={item.id}>{render(item)}</Fragment>
      ))}
    </>
  );
}

这样一来,任何没有 id 字段的数据都无法通过 KeyedList 的编译,直接用下标绕过 key 的路径被类型系统堵死。开发者被迫在上游为数据补充稳定身份,而不是在渲染层临时凑一个下标。

反例:对“无状态临时数组”过度设计

需要警惕另一个方向——把稳定身份教条化。对于静态、从不变更顺序的数组,索引 key 并非不可接受:

// 这列数据只在挂载时生成,且永不重排
const STEPS = ['概述', '环境', '部署'];
function Steps() {
  return (
    <ol>
      {STEPS.map((s, i) => (
        <li key={i}>{s}</li>
      ))}
    </ol>
  );
}

此类场景用 i 无害,因为身份从未被挑战。真正的问题是“不知道何时会重排却默认用索引”。工程判断的落点是:数据是否具有可变顺序——可变,就必须有业务唯一 ID。

用 ESLint 兜底,形成双重防线

类型系统管住“身份来源”,Lint 管住“漏网的 map”。eslint-plugin-reactjsx-key 规则能在静态层面拦截缺失的 key

{
  "rules": {
    "react/jsx-key": ["error", { "checkFragmentShorthand": true }]
  }
}

再叠加一个团队约定:禁止在 key 使用 index 的表达式,除非通过 // eslint-disable-next-line 显式豁免并附上“为何顺序不可变”的注释。这样每处索引 key 都需要一次有意识的豁免决策,而非无意识默认。

小结

key 从来不是消除 warning 的占位符,而是 reconciler 做身份匹配的唯一依据。把“key = 稳定身份”这条隐性约定,通过 TypeScript 泛型约束 + ESLint 规则 显式地写进工程,才能在列表重排时保住状态、焦点与 DOM 复用。记住这条判断标准:数据会重排吗?会,就必须有业务唯一 ID;不会,索引才勉强可用。

0 评论

评论区

登录 后参与评论