为什么不推荐在 React 中使用 index 作为 key

1,932 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

我们在开发 React 项目时,根据规范,对于列表、表格类型的元素,每项需要指定 key 属性,否则会出现 warning 报错。正如 ant design 的 Table 组件文档中描述的那样,当不指定 key 时,可能会出现未知错误。

ant-design-table-key.png

我们需要尽量给每条数据提供一个 key 属性,在实际项目中,一般数据是从后台获取的,所以我们可以使用唯一的标识 id 作为 key 值。但如果数据没有 id 属性的话,也不推荐使用 index 作为 key 。 React 官网上也有相关的解释说明,不建议使用 index 作为 key ,可以去深入了解一下。本文将讨论不推荐的原因,主要分为 2 点。

We don’t recommend using indexes for keys if the order of items may change. This can negatively impact performance and may cause issues with component state. Check out Robin Pokorny’s article for an in-depth explanation on the negative impacts of using an index as a key. If you choose not to assign an explicit key to list items then React will default to using indexes as keys.

性能

在讨论性能问题前,我们先看一个案例。

function App() {
  const [data, setData] = useState([
    { name: 'Tom', id: 'id1' },
    { name: 'Sam', id: 'id2' },
    { name: 'Ben', id: 'id3' },
    { name: 'Pam', id: 'id4' },
  ]);
  
  return (
    <div className="App">
      <ul>
        {data.map(({ name }, index) => (
          <li key={index}>{name}</li>
        ))}
      </ul>
      <br />
      <br />
      <button
        onClick={() => {
          const newData = [...data];
          newData.shift();
          setData(newData);
        }}
      >
        删除第一项
      </button>
    </div>
  );
}

在这个案例中,我们使用 map 函数进行生成多个 li 标签,每个标签的 key 值用 index 进行绑定。当点击按钮的时候,会将 data 中的第一项进行删除,触发重新渲染。

从截图中可以看到,确实可以达到我们想要的效果,但是如果我们细究一下 React 实际渲染更新做的事情,会发现效率不高。

<!-- 更新前 -->
<li key="0">Tom</li>
<li key="1">Sam</li>
<li key="2">Ben</li>
<li key="3">Pam</li>

<!-- 更新后 -->
<li key="0">Sam</li>
<li key="1">Ben</li>
<li key="2">Pam</li>

我们知道 React 更新机制是先比较虚拟 DOM ,然后通过计算差异,再对真实 DOM 进行操作。

如上述代码所示,更新前后, index 依旧从 0 开始, React 进行逐条比较,发现了 2 条同样 key=0li 标签,然后递归比较内部,发现内部的文字由 Tom 改为了 Sam ,因此需要找到 key=0li 标签,并进行真实 DOM 操作将内部的文字改为 Sam ,此时完成本条数据的更新。然后继续比较 key=1key=2 的数据,并进行更新。最后,原本的 key=3li 标签在新的虚拟 DOM 中,已经不存在了,于是执行了 DOM 删除。

<!-- 更新前 -->
<li key="id1">Tom</li>
<li key="id2">Sam</li>
<li key="id3">Ben</li>
<li key="id4">Pam</li>

<!-- 更新后 -->
<li key="id2">Sam</li>
<li key="id3">Ben</li>
<li key="id4">Pam</li>

如果我们使用数据的 id 作为 key ,一切就不一样了。 React 一开始就发现 key=id1 的数据没有了,就会进行删除的操作。而其他 3 条数据,在进行比较后,会发现无变化,因此不会产生真实 DOM 的更新。

小结一下,在这个案例中,我们想要将第一条数据进行删除,触发页面上的元素变化。如果我们使用 index 作为 key ,会导致所有的真实 DOM 都发生变化;如果我们使用 id 作为 key ,则可以保证最小代价的更新,效率更高。

更新不符预期

如果只是效率问题,可能不是我们需要优先解决的,但是如果更新也会出错的话,那我们就无法忽视了。这里会列举 2 个例子来说明该问题。

输入框内容

function App() {
  const [data, setData] = useState([
    { name: 'Tom', id: 'id1' },
    { name: 'Sam', id: 'id2' },
    { name: 'Ben', id: 'id3' },
    { name: 'Pam', id: 'id4' },
  ]);

  return (
    <div className="App">
      <ul>
        {data.map(({ name }, index) => (
          <li key={index}>
            {name}
            <input />
          </li>
        ))}
      </ul>
      <br />
      <br />
      <button
        onClick={() => {
          const newData = [...data];
          newData.shift();
          setData(newData);
        }}
      >
        删除第一项
      </button>
    </div>
  );
}

类似之前的案例,但在遍历数据时,这次我们增加一个输入框的渲染。测试案例时,我们首先在每个输入框内输入各自的内容,然后再点击按钮删除第一项。

可以发现,看起来第一项 Tom 确实被删除了,但是 input 标签内的输入值依旧保持为 1 。

<!-- 更新前 -->
<li key="0">Tom<input/></li>
<li key="1">Sam<input/></li>
<li key="2">Ben<input/></li>
<li key="3">Pam<input/></li>

<!-- 更新后 -->
<li key="0">Sam<input/></li>
<li key="1">Ben<input/></li>
<li key="2">Pam<input/></li>

原因也很容易理解,正如之前的例子一样,虽然看起来是第一项被删除了,但实际上, React 在计算差异时,最终删除的其实是最后一项。看起来是第一条被删除的原因是, React 递归的比较内部的差异,然后更新了文字内容。而从结果上,我们也可以发现 input 标签在前后比较中,被认为未发生变化,因此输入值依旧保留着我们更新前输入的内容。而最后一项 li 标签被删除,所以 input 标签也同时被删除了。

文字标记

有时候,我们会在网页中进行一些标记,类似划词翻译、主体识别等需求,当涉及数据修改的情况,使用 index 作为 key 容易出现问题。

function App() {
  const [data, setData] = useState([
    { name: 'Tom', id: 'id1' },
    { name: 'Sam', id: 'id2' },
    { name: 'Ben', id: 'id3' },
    { name: 'Pam', id: 'id4' },
  ]);

  return (
    <div className="App">
      <ul>
        {data.map(({ name }, index) => (
          <li key={index} className="my-list-item">
            {name}
            <input />
          </li>
        ))}
      </ul>
      <br />
      <button
        onClick={() => {
          const firstItem = document.getElementsByClassName('my-list-item')[0];
          firstItem.innerHTML = firstItem.innerHTML.replace('m', '<a>m</a>');
        }}
      >
        标记第一项
      </button>
      <br />
      <button
        onClick={() => {
          const newData = [...data];
          newData.shift();
          setData(newData);
        }}
      >
        删除第一项
      </button>
    </div>
  );
}

在上述案例中,我们把第一项中的 m 进行标记,通过增加 a 标签包裹的形式,此时进行删除第一项会发现严重的更新错误。

我们可以发现第一项并没有被删除,反而是第二项被删除了。

<!-- 更新前 -->
<li key="0">Tom<input/></li>
<li key="1">Sam<input/></li>
<li key="2">Ben<input/></li>
<li key="3">Pam<input/></li>

<!-- 标注后 -->
<li key="0">To<a>m</a><input/></li>
<li key="1">Sam<input/></li>
<li key="2">Ben<input/></li>
<li key="3">Pam<input/></li>

<!-- 更新后 -->
<li key="0">Sam<input/></li>
<li key="1">Ben<input/></li>
<li key="2">Pam<input/></li>

我们知道 React 更新的时候会首先比较虚拟 DOM ,然后计算如何更新,再将更新过程映射成真实 DOM 的操作,并最终完成更新。标注行为在这个案例中是直接通过修改 DOM 的形式进行操作的, React 并不知道发生了变化,因此在计算虚拟 DOM 变化的时候,依旧是用的 <li key="0">Tom<input/></li> 而不是 <li key="0">To<a>m</a><input/></li> 。在更新前,此处还有个特殊点在于, li 标签下的 Tom 被视为一个隐式的节点,当发生更新的时候,预期的操作是,将该隐式节点替换成 Sam 。然而经过标注后,节点被破坏了,变成了 To 和 span 2 个节点。找不到 Tom 节点导致 React 无法完成预期的更新操作。

如果希望解决这样的问题,可以试着将 input 标签去除,此时由于 li 标签只有一个子节点 Tom ,因此 React 可以直接修改 liinnerHTML 完成更新。或者我们保留 input 标签,但是给 Tom 外层增加一个 span 标签,此时 React 更新的时候会修改 li 内第一个标签,即 span 标签进行更新。

当然最好还是避免使用 index 作为 key

小结一下,在这两个案例中,发生更新的元素内由于存在无法被判断变化的元素,比如 input ,或者元素发生了 React 无法预知的修改导致与虚拟 DOM 不再匹配。在更新元素时,会出现更新不符预期的情况。

总结

  • 不推荐使用 index 作为 key ,除非不涉及任何更新修改
  • 使用 index 作为 key 会导致渲染效率的问题
  • 使用 index 作为 key 会导致更新不符预期的问题

如果读者有兴趣的话,可以去深度了解一下 React 的 diffing 算法。

本文所用代码可在本人仓库找到:gitee