【面试官系列】React 中的 key 属性到底有什么用?

1,136 阅读13分钟

今天来深入研究一个 ”基本“但不简单 的面试题:React 中的 key 属性到底有什么用?

前言

关于 React 的 "key" 属性,我们可能经常会遇到控制台中显示下图所示的 warning

如果你配置了 eslint-plugin-reacteslint 还会来搞你一下:

react/jsx-key 会警告你在 JSX 中 map 数组时忘记指定 key 属性。不过需要注意的是:eslint-plugin-react 中的 react/jsx-key 对于 React.Fragment 语法(<>...</>)不起作用。

比如:

array.map((item) => (
  <>
    <div>{item.title}</div>
    <div>{item.description}</div>
  </>
))

官方guthub文档 中了解到,在旧版本中,为了让 react/jsx-key 适用于 React.Fragment 速记语法,必须启用 checkFragmentShorthand

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

据说在最新的版本中 checkFragmentShorthand 已经默认启用。

我猜想大多数人在面对 "React 中的 key 属性有什么用?" 这个问题时,都会回答 "呃......我们应该把唯一值作为组件的标记,这样 React 才能识别哪些 item 发生了变化,避免重渲染,这样对性能更好"。某种程度上讲,这个回答可能是有问题的,下面会讲到。

如果不加 "key" 属性会发生什么?程序会崩溃吗?如果我在这里输入一个随机字符串会怎样?值的唯一性如何?可以直接使用数组的索引值吗?这些选择会产生什么影响?它们对性能有什么影响?

让我们一起来深入研究一下!

React 中的 key 属性是如何工作的?

首先,在开始编码之前,我们先弄清楚理论:什么是 "key" 属性,为什么 React 需要它?

简单回顾一下重新渲染过程的简化算法如下:

  1. 首先,React 会生成元素 "before"和 "after"的 "快照,也就是 DIFF 的“前后”。
  2. 其次,它会尝试识别页面上已经存在的元素,以便重新使用它们,而不是从头开始创建它们
    1. 如果存在 "key" 属性,它会认为 "before" 和 "after" 中 key 相同的项目是不变的
    2. 如果不存在 "key" 属性,它将使用同级索引作为默认 "key" 。
  3. 最后
    1. 删除在 "before" 阶段存在但在 "after" 阶段不存在的项目(即卸载它们,removed -> unmount
    2. 从头开始创建在 "before" 变量中不存在的项目(即加载它们,added -> mount
    3. 更新 "before" 存在并在 "after" 继续存在的项目(即重新渲染它们,exists -> re-render

最新的官方文档 这样说:

想象一下,你桌面上的文件没有名字。取而代之的是,你可以按顺序来称呼它们--第一个文件、第二个文件,以此类推。你可能会习惯,但一旦删除文件,就会变得混乱。第二个文件会变成第一个文件,第三个文件会变成第二个文件,以此类推。

文件夹中的文件名和数组中的 JSX key 属性的作用类似。它们能让我们在同级项目之间唯一地识别一个项目。一个精心选择的 key 提供了比数组中的位置更多的信息。即使位置因重新排序而发生变化,key 也能让 React 在项目的整个生命周期中识别该项目。

官方也给出了“陷阱”提示:

  1. 你可能会倾向于使用数组中项目的索引作为其 key。事实上,如果不指定 key,React 就会使用索引作为 key。但是,如果项目被插入、删除,或者数组被重新排序,那么渲染项目的顺序就会随着时间的推移而改变。索引作为 key 通常会导致一些微妙而令人困惑的错误。
  2. 同样,也不要临时生成 key,例如 key={Math.random()}。这会导致 key 值在不同的渲染中永远不会匹配,从而导致每次都要重新创建所有组件和 DOM。这样做不仅速度慢,还会丢失列表项中的任何用户输入。取而代之的是根据数据使用一个稳定的 ID。

请注意,你的组件不会接收 key 作为 props 。它只会被 React 本身用作提示。如果你的组件需要一个 ID,你必须将其作为单独的 props 传递:<Profile key={id} userId={id} />.

官方甚至还给出了怎么获取 key,不同的数据源提供了不同的 key 来源:

  1. 来自数据库的数据:如果数据来自数据库,则可以使用数据库键/ID,因为它们具有唯一性。
  2. 本地生成的数据:如果数据是在本地生成和持久化的(例如记事本应用程序中的笔记),在创建项目时可使用递增计数器、crypto.randomUUID()uuid 等软件包。

或者:


如前面面试问题中所言,使用唯一的 key 能避免重渲染吗?

答案是:不能!

我准备了一个简单的例子(codesandbox),主要代码如下:

import React, { useState, useEffect } from "react";
import "./styles.css";

const fruits = [
  { id: 1, label: "apple" },
  { id: 2, label: "pear" },
  { id: 3, label: "banana" },
  { id: 4, label: "cherry" }
]

const MemoizedFruitItem = React.memo(FruitItem);

export default function App() {
  const [fruitListOfId, setfruitListOfId] = useState(fruits)
  const [fruitListOfIndex, setfruitListOfIndex] = useState(fruits)

  const addOneToListOfId = () => {
    setfruitListOfId(l => ([{ id: 0, label: "orange" }, ...l]))
  }

  const addOneToListOfIndex = () => {
    setfruitListOfIndex(l => ([{ id: 0, label: "orange" }, ...l]))
  }

  return (
    <div>
      <div>
        <h1>id</h1>
        <button onClick={addOneToListOfId}>增加一个</button>
        <div className="App">
          {/* {fruitListOfId.map(f => <FruitItem key={f.id} logPrefix="id" label={f.label} />)} */}
          {fruitListOfId.map(f => <MemoizedFruitItem key={f.id} logPrefix="id" label={f.label} />)}
        </div>
      </div>
      <div>
        <h1>index</h1>
        <button onClick={addOneToListOfIndex}>增加一个</button>
        <div className="App">
          {/* {fruitListOfIndex.map((f, index) => <FruitItem logPrefix="index" key={index} label={f.label} />)} */}
          {fruitListOfIndex.map((f, index) => <MemoizedFruitItem logPrefix="index" key={index} label={f.label} />)}
        </div>
      </div>
    </div>
  );
}

function FruitItem({ label, logPrefix }) {
  useEffect(() => {
    console.log(`MOUNT: ${logPrefix}`);
  }, [logPrefix]);

  console.log('RENDER')
  return (
    <div>{label}</div>
  );
}

如果我们在遍历数组的时候,适用的是未经过 memo 处理的原生组件,不管你设置的 key 是不是唯一的或者是 id 还是 index,都会触发重渲染:

而使用 memo 处理后的组件则不会:

所以,起作用的其实是 memo,并不是 key。

为什么 random 一个 key 是不好的实践?

让我们先实现一个国家列表。我们将有一个项目组件,用于渲染国家信息:

const Item = ({ country }) => {
  return (
    <button className="country-item">
      <img src={country.flagUrl} />
      {country.name}
    </button>
  );
};

和一个渲染实际列表的 CountriesList 组件:

const CountriesList = ({ countries }) => {
  return (
    <div>
      {countries.map((country) => (
        <Item country={country} />
      ))}
    </div>
  );
};

现在,我的项目上没有 "key" 属性。那么,当 CountriesList 组件重新渲染时会发生什么情况?

  1. React 会发现这里没有 "key" ,并退回到使用国家数组的索引作为 key 的状态
  2. 我们的数组没有变化,因此所有项目都将被识别为 "已存在",并且项目将重新渲染

从本质上讲,这与在项目中明确添加 key={index} 没有什么区别

countries.map((country, index) => <Item country={country} key={index} />);

简而言之:当 CountriesList 组件重新渲染时,每个 Item 也会重新渲染。如果用 React.memoItem 进行包装,我们甚至可以摆脱这些不必要的重新渲染,从而提高列表组件的性能。

现在有意思的部分来了:如果我们不使用索引,而是在 "key" 属性中添加一些随机字符串呢?

countries.map((country, index) => <Item country={country} key={Math.random()} />);

在这种情况下

  • 在每次重新渲染 CountriesList 时,React 将重新生成 "key" 属性
  • 由于 "key" 属性已经存在,React 将使用它来识别 "现有" 元素
  • 由于所有的 "key" 属性都是新的,所有 "before" 的项目都将被视为 "已移除",每个项目都将被视为 "新的",React 将卸载所有项目并重新加载它们

简而言之:当 CountriesList 组件重新渲染时,每个 Item 都会被销毁,然后从头开始重新创建。与简单的重新渲染相比,重新挂载组件在性能方面的代价要高得多。此外,用 React.memo 封装项所带来的所有性能提升都将消失——因为每次重新渲染时都要重新创建项,所以 memoisation 将失效。

请看codesandbox中的上述示例。点击按钮重新渲染,注意控制台输出。稍稍控制一下 CPU,即使用肉眼也能看到点击按钮时的延迟!

如何节流 CPU:在 Chrome 浏览器开发工具中打开 "性能 "选项卡,点击右上角的 "齿轮 "图标——它将打开一个附加面板,"CPU 节流 "是其中一个选项。

为什么 "index" 作为 "key" 可能不是个好实践?

现在,我们应该很清楚为什么我们需要稳定的 "key" 属性,而且在重新读取时还能保持。那么数组的 "索引" 呢?在官方文档中,也不推荐使用索引,理由是索引会导致错误和影响性能。但是,当我们使用 "索引" 而不是某个唯一 ID 时,究竟是什么原因导致了这样的后果呢?

首先,我们在上面的示例中看不到这些情况。所有这些错误和对性能的影响只会发生在 "动态" 列表中——在列表中,项目的顺序或数量会在重新渲染时发生变化。为了模拟这种情况,让我们为列表实现排序功能:

const CountriesList = ({ countries }) => {
  const [sort, setSort] = useState('asc');

  const sortedCountries = orderBy(countries, 'name', sort);

  const button = <button onClick={() => setSort(sort === 'asc' ? 'desc' : 'asc')}>toggle sorting: {sort}</button>;

  return (
    <div>
      {button}
      {sortedCountries.map((country) => (
        <ItemMemo country={country} />
      ))}
    </div>
  );
};

每次我点击按钮,数组的顺序都会颠倒。我将以 country.id 作为关键字,在两个变量中实现列表:

sortedCountries.map((country) => <ItemMemo country={country} key={country.id} />);

和数组索引作为 key:

sortedCountries.map((country, index) => <ItemMemo country={country} key={index} />);

为了提高性能,我们将立即对 Item 组件进行 memo:

const ItemMemo = React.memo(Item);

下面是完整实现的 codesandbox 。在 CPU 受控的情况下点击排序按钮。

注意基于 "索引" 的列表速度稍慢,并注意控制台输出:

  1. 在基于 "索引" 的列表中,每次点击按钮都会重新显示每个Item,尽管 Itemmemoised 的,从技术上讲不应该这样做。
  2. 基于 "id" 的实现,除了 key 值外,与基于 "key" 的完全相同,不会出现这个问题:点击按钮后不会重新渲染任何项目,控制台输出也很干净。

为什么会出现这种情况?问题当然是 "key" 值:

  1. React 会生成 "before""after" 的元素列表,并尝试识别 "相同" 的项目。
  2. 从 React 的角度来看,"相同" 的项目就是具有相同 key 值的项目
  3. 在基于 "索引" 的实现中,无论数组如何排序,数组中的第一个项总是 key="0",第二个项总是 key="1"

因此,当 React 进行比较时,当它在 "before"(之前)和 "after"(之后)列表中看到 key="0" 的项目时,它会认为这是完全相同的项目,只是 props 值不同:在我们反转数组后,国家值发生了变化。因此,它对同一个项目做了应该做的事:触发重新渲染循环。因为它认为国家 props 值已经改变,因此会绕过memo函数,触发实际项目的重新渲染。

而基于 id 的行为是正确和高效的:项目能被准确识别,每个项目都被 memo,因此没有组件需要重新渲染。

如果我们为项目组件引入一些状态,这种行为就会特别明显。例如,让我们在点击时改变其背景:

const Item = ({ country }) => {
  // add some state to capture whether the item is active or not
  const [isActive, setIsActive] = useState(false);

  // when the button is clicked - toggle the state
  return (
    <button className={`country-item ${isActive ? 'active' : ''}`} onClick={() => setIsActive(!isActive)}>
      <img src={country.flagUrl} />
      {country.name}
    </button>
  );
};

看看同样的 codesandbox,只是这次先点击最前面两个国家,触发背景变化:

然后再点击 "排序 "按钮:

以 id 为基础的列表和你想象的完全一样。但基于索引的列表现在的表现却很奇怪:如果我点击列表中的第一个项目,然后点击排序--无论排序如何,前两个项目都会保持选中状态。这就是上述行为的症状:React 认为,key="0" 的项目(数组中的第一个项目)在状态更改前后是完全一样的,因此它会重新使用相同的组件实例,保持原来的状态(即此项目的 isActive 设置为 true),并更新 props 值(从第一个国家到最后一个国家)。

如果我们不进行排序,而是在数组的开头添加一个项目,也会发生完全相同的情况:React 会认为 key="0" 的项目(第一个项目)保持不变,而最后一个项目是新项目。

因此,如果选择了第一个项目,在基于索引的列表中,选择将停留在第一个项目,每个项目都会重新渲染,最后一个项目甚至会触发 "挂载"。而在基于 id 的列表中,只有新添加的项目才会被挂载和渲染,其他项目都会静静地等待。请在 codesandbox 中查看。调节 CPU,在基于索引的列表中添加一个新项目的延迟再次以肉眼可见!即使对 CPU 进行 6 倍的节流,基于 id 的列表速度也非常快。

为什么 "index" 作为 "key" 属性也可能是个好实践?

经过前面几节的介绍,我们很容易就会说 "只要在'key'属性中使用唯一的项目 id 就可以了",不是吗?在大多数情况下确实如此,而且如果你一直使用 id,可能没有人会注意或介意。但是,当你掌握了知识,你就拥有了超能力。现在,既然我们知道了 React 渲染列表时到底发生了什么,我们就可以作弊,用 index 代替 id,让某些列表变得更快。

典型场景:分页列表

列表中的Item数量有限,你点击了一个按钮,然后希望在相同大小的列表中显示相同类型的不同Item。如果使用 key="id" 方法,那么每次更改页面时,都会以完全不同的 id 加载全新的项目集。这意味着 React 无法找到任何 "现有" 的项目,也无法卸载整个列表,并加载全新的项目集。但是!如果使用 key="index" 方法,React 会认为新 "页面 "上的所有项目都已存在,因此只会用新数据更新这些项目,而不会挂载实际组件。如果项目组件很复杂,即使数据集相对较小,这种方法也会明显更快。

请看 codesandbox 中的示例:

请注意控制台输出——在右侧基于 "id" 的列表中切换页面时,每个项目都会被重新加载。但在左边基于 "索引" 的列表中,项目只会被重新渲染。速度更快在 CPU 有节流的情况下,即使是 50 个非常简单的列表(只有一个文本和一个图片),在基于 "id "的列表和基于 "索引" 的列表中切换页面的差别也是显而易见的。

在使用各种类似动态列表的数据时,情况也会完全一样,在保留列表外观的同时,用新的数据集替换现有项目:自动完成组件、类似谷歌的搜索页面、分页表格。只是需要注意在这些项目中引入状态:它们必须是无状态的,或者状态应与 props 同步。

使用 key 来重置组件状态

在 React 应用程序中重置状态是一件很常见的事情。你会在 props 中获取一些新数据,然后想要将状态设置回初始值,而这通常是通过 useEffect 来完成的。然而,useEffects 可能会很混乱,而且难以理解,这是因为你(和我)都用错了。让我们看看如何通过使用 key 属性更好地解决这个问题。

普通组件场景

先来看一个简单的例子(codesandbox):

import "./styles.css";
import { useState } from "react";

function Counter() {
  const [value, setValue] = useState(0);

  return (
    <div>
      <div>{value}</div>

      <button onClick={() => setValue((prev) => prev + 1)}> add </button>
    </div>
  );
}

export default function App() {
  const [key, setKey] = useState(Math.random());

  return (
    <div className="App">
      <Counter key={key} />
      <button onClick={() => setKey(Math.random())}> reset </button>
    </div>
  );
}

点击 reset,Counter 组件中的 value 将会被重置为 0。


再来看一个复杂一点的例子:

React 组件的状态(由 useState 定义)在渲染过程中保持不变,只有在手动更改时才会受到影响。重置状态的一种常见方法是手动修改它们:

const Demo = () => {
  const [name, setName] = useState('default');
  const [address, setAddress] = useState(null);

  const reset = () => {
    setName('default');
    setAddress(null);
  }

  return (
    // ...
    <button onClick={()=>reset()} >Reset</button>
  );
}

这样做并不理想,原因有三:

  1. 初始状态是重复的,我们可能需要将其提取到另一个变量中,以防止错误不匹配
  2. 更新时需要保持两者同步
  3. 如果状态是通过自定义 Hooks 添加的,我们就没有办法直接从组件中重置它。

更好的办法是通过添加一个名为 key 的特殊 props 来重置。众所周知,我们需要在映射组件列表时传递 key。但它也可以用来重置组件状态。但这需要在父组件中添加一个新的状态,以便重置子组件。

const Demo = ({reset}: {reset:()=>void})=>{
  const [name, setName] = useState('default');
  const [address, setAddress] = useState(null);

  return (
    <button onClick={()=>reset()}>Reset</button>
  );
}

const Parent = ()=>{
  const [resetKey, setResetKey] = useState(0);

  return (
    // ...
    <Demo key={resetKey} onReset={ ()=> setResetKey(resetKey+1)} />
  )
}

我们现在要做的是强制更改 props 中 key 的值,从而手动重置组件状态。从技术上讲,这并不是它自己的工作,而是需要父组件的帮助,但这似乎是最简洁的重置方法。这样做的缺点是需要重新渲染父组件及其所有子组件。但由于没有改变其他状态,理想情况下这应该不是什么大问题。


还有一种场景,当用户浏览应用程序时,你可能需要重置某些状态以显示正确的数据。请看下面一个简单文章组件的示例:

function Article({ articleId }) {
  const [likes, setLikes] = useState(0)
  
  useEffect(() => {
    setLikes(0)
  }, [articleId])
  
  /** ... */
}

在上面的代码中,文章组件会获取文章 ID 作为 props ,而本地状态会记录用户希望给文章点赞的数量。这里使用 useEffect,它会监听 articleId 并在其发生变化时重置点赞值。这是因为我们希望当用户浏览到另一篇文章并想给该文章点赞时,能给用户一个干净的界面。这只是一个简单的例子,但让 useEffect 必须监听 articleId 并在其发生变化时重置所有内容是非常难受的,因为这还只是一个数据。

useEffect 钩子很方便,但在大多数情况下,你并不需要它。UseEffect 用于同步副作用,而不是将状态设置回默认值。

除了在列表元素上使用 key 外,还可以在单个组件上使用它。这将解决我们的状态重置问题,因为 React 将为每个 key 创建一个新的组件实例,而不是在同一挂载组件上强制添加新数据并使用 useEffect 重置每个状态。让我们为上面示例中的组件添加 key 属性,以重置状态。请看下面的示例:

function ArticleContainer({ articleId }) {
  return (
    <Article key={articleId} articleId={articleId} />  
  )
}

function Article({ articleId }) {
  const [likes, setLikes] = useState(0)
  
  /** ... */
}

就是这样!添加一个属性后,就可以去掉 useEffect 了。

表单场景

下面来一个简单的例子:

import { useState } from 'react'
import "./styles.css";

export default function App() {
  const [resetKey, setResetKey] = useState(0)

  return (
    <div className="App">
      <button onClick={() => setResetKey(k => k + 1)}>切换</button>
      <form key={resetKey}>
        <label for="fname">First name:</label><br />
        <input type="text" id="fname" name="fname" /><br />
        <label for="lname">Last name:</label><br />
        <input type="text" id="lname" name="lname" />
      </form>
    </div>
  );
}

页面长这样,试着在表单中输入一些内容,然后点击切换,就会发现表单状态被重置了。

还有比如一些表单切换场景:

import { useState } from 'react'
import "./styles.css";

export default function App() {
  const [flag, setFlag] = useState(true)

  return (
    <div className="App">
      <button onClick={() => setFlag(f => !f)}>切换</button>
      {
        flag ? <form key={'FORM_1'}>
          <div>Form_1</div>
          <label for="fname">First name:</label><br />
          <input type="text" id="fname_1" name="fname" /><br />
          <label for="lname">Last name:</label><br />
          <input type="text" id="lname_1" name="lname" />
        </form> : <form key={'FORM_2'}>
          <div>Form_2</div>
          <label for="fname">First name:</label><br />
          <input type="text" id="fname" name="fname" /><br />
          <label for="lname">Last name:</label><br />
          <input type="text" id="lname" name="lname" />
        </form>
      }
    </div>
  );
}

我们只需要给每个表单设置唯一的 key 属性,切换的时候会自动重置表单状态。

注意性能影响

虽然这是一个不错的技巧,可以降低程序的复杂性,但必须记住,这种方法会让 React 卸载整个组件实例和 DOM 树,所以,在实际的应用中,你应该限制非列表组件使用 key,并避免在顶层组件上设置,因为这可能会导致(难以发现的)性能问题。

总结

  1. 切勿在 "key" 属性中使用随机值:它会导致项目在每次渲染时都重新挂载。当然,除非这是你的本意
  2. 在 "静态" 列表(不存在增加、删除等操作)中使用数组索引作为 "key" 并无不妥——这些列表的Item编号和顺序保持不变
  3. 当列表可以重新排序或项目可以随机添加时,使用项目唯一标识符("id")作为 "key"
  4. 对于具有无状态项目的动态列表,也可以使用数组的索引作为 "key",在这种情况下,项目会被新的项目替换--分页列表、搜索和自动完成结果等。这将提高列表的性能。

参考