[译] React Key:实现高性能列表的最佳实践

Posted on:  at 

原文地址:https://www.developerway.com/posts/react-key-attribute
原文作者:NADIA MAKAREVICH
原文发布于:2022 年 05 月 09 日

了解 React "key "属性的工作原理、如何正确使用该属性、如何使用该属性提高列表性能,以及为什么有时将数组索引作为键是个好主意。

React 的“key”属性可能是 React 中使用最多的“自动驾驶”功能之一😅,我们当中有谁能坦诚地说他们使用它是因为“......一些合理的原因”,而不是“因为 eslint 规则向我抱怨我应该这样做”。我猜大多数人在面对“React 为什么需要‘key’属性”这个问题时,都会回答 “呃......我们应该把唯一值放在那里,这样 React 才能识别列表项,这样对性能更好”。从技术上讲,这个答案是正确的。有时。

但“识别项目”到底是什么意思?如果跳过“key”属性会发生什么?应用程序会崩溃吗?如果我在这里输入一个随机字符串会怎样?值的唯一性如何判断?可以直接使用数组的索引值吗?这些选择会产生什么影响?它们对性能有什么影响?

让我们一起调查吧!

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

首先,在开始编码之前,让我们先搞清楚理论:“key”属性是什么,为什么 React 需要它。

简而言之,如果存在“key”属性,React 就会在重新渲染时,在所有同类型的同级的元素之间,标记和识别元素的身份的一种方法(请参阅文档:https://reactjs.org/docs/lists-and-keys.htmlhttps://reactjs.org/docs/reconciliation.html#recursing-on-children)。换句话说,只有在重新渲染时和相邻的元素中有同类型元素(即扁平列表)时才需要“key”属性(这一点很重要!)。

简单来说,重新渲染时判断重复元素的简化算法如下:

  • 首先,React 会生成元素列表在“重新渲染前”跟“预计重新渲染后”的快照。
  • 其次,它会尝试识别页面上已经存在的元素,以便重新使用它们,而不是从头开始创建它们。
    • 如果元素存在“key”属性,它将假定“渲染前”和“渲染后”具有相同“key”的元素是相同的。
    • 如果“key”属性不存在,则会使用它在同级元素中索引作为默认“key”。
  • 最后:
    • 将“渲染前”存在,但“渲染后”被移除的元素进行卸载(unmount)。
    • 对“渲染前”不存在,但“渲染后”存在的元素执行创建(mount)。
    • 对“渲染前”跟“渲染后”都存在的元素执行重新渲染(re-render)。

通过代码跑一下就更容易理解了,所以我们也来写一下。

为什么随机内容的“key”是个坏主意

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

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 组件重新呈现时会发生什么情况?

  • React 会发现 Item 组件都没有“key”,默认使用国家数组的索引作为 key 的默认值。
  • 我们的数组没有改变,因此所有项目都将被识别为之前和之后都是“已存在”,项目将被重新渲染。

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

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

image.png

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

现在,有趣的部分来了:如果我们不使用索引,而是一些随机字符串作为“key”属性呢?

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

在这种情况下:

  • 在每次 CountriesList 重新渲染,会重新生成“key”属性。
  • 由于“key”属性已经存在,React 将使用它来识别元素是否存在。
  • 由于所有元素的“key”属性都是新的,所有“渲染前”的项目都将被视为“已移除”,每个项目都将被视为“新的”,React 将卸载所有项目并重新创建它们。

image.png

简而言之:当 CountriesList 组件重新呈现时,每个 Item 都会被销毁,然后从头开始重新创建。

与简单的重新渲染相比,重新挂载组件在性能方面的代价要高得多。此外,React.memo 封装 Item 所带来的性能提升也将消失--因为每次重新渲染都要重新创建项,所以记忆化将无法发挥作用。

你可以在 codesandbox 中查看上述示例。点击按钮重新渲染,并注意控制台输出。稍微调低一下 CPU,这样你即使肉眼也能看到点击按钮时的延迟!

怎样调低你的 CPU

在 Chrome 开发者工具的 Performance 页签,点击顶部右侧的“齿轮”展开菜单,找到里面的“CPU throttling”进行配置。

为什么使用列表索引来作为“key”不是好主意

现在我们应该明白,为什么我们需要稳定的“key”属性,并且在重新渲染之间保持不变。那么数组的“索引”呢?即使在官方文档中,也不推荐使用索引,理由是索引会导致错误和影响性能。那么,当我们使用“索引”而不是某个唯一 ID 时,究竟是什么原因导致了这样的后果呢?

首先,我们不会在上面的用例中看到原因。使用索引可能会带来的错误和对性能的影响只会发生在“动态”列表中,即子元素的顺序或数量会在重新渲染时发生变化的列表。为了模仿这种情况,让我们为列表实现排序功能:

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

  // sort countries base on state value with lodash orderBy function
  const sortedCountries = orderBy(countries, 'name', sort);

  // add button that toggles state between 'asc' and 'desc'
  const button = <button onClick={() => setSort(sort === 'asc' ? 'desc' : 'asc')}>toggle sorting: {sort}</button>;

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

每次我点击按钮,列表的顺序都会颠倒。为了建立对照,第一个例子是使用 id 作为“key”:

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

另一个例子是使用索引来作为“key”:

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

同时为了提高性能,对 Item 进行记忆化处理:

const ItemMemo = React.memo(Item);

下面是完整实现的 codesandbox。在 CPU 被调低的情况下点击排序按钮,注意基于“索引”键值的列表速度稍慢,并注意控制台输出:在基于“索引”的列表中,每次点击按钮都会重新显示每个条目,但是 Item 是记忆化的,从理论上不应该会这样。而基于“id”键值的实现,除了键值外,其他属性与基于“索引”的完全相同,它就不会出现这个问题:点击按钮后不会重新渲染任何项目,控制台输出也很干净。

为什么会出现这种情况?秘密当然是“key”值:

  • React 生成“渲染前”跟“渲染后”的列表进行比较,判断那些元素是跟原来一样的。
  • 从 React 的角度来说,相同“key”值的元素是跟原来一样的。
  • 在基于“索引”的实现中,数组中的第一个项目总是 key="0",第二个项目总是 key="1",等等等等--与数组的排序无关

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

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

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

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,只是这次先点击几个国家,触发背景变化,然后再点击“sort”按钮。

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

如果我们不进行排序,而是在数组的开头添加一个项目,也会发生完全相同的情况: React 会认为 key="0" 的项目(第一个项目)保持不变,而最后一个项目才是新项目。因此,如果选择了第一个项目,在基于索引的列表中,选择(isActicve=true)将停留在第一个项目,每个项目都将重新渲染,最后一个项目甚至会触发“挂载”。而在基于 id 的列表中,只有新添加的项目才会被挂载和渲染,其他项目都会静静地等待。请在 codesandbox 中查看。调低 CPU,在基于索引的列表中添加一个新项目的延迟再次以肉眼可见!与之相比,即使对 CPU 进行 6 倍的节流,基于 id 的列表速度也非常快。

为什么使用列表索引来作为“key”是个好点子

经过前面几节的介绍,我们很容易得出“只要在'key'属性中使用唯一的项目 id 就可以了”,这个结论。在大多数情况下确实如此,而且如果你一直使用 id,可能你也认为没有什么值得继续注意的了。但是,当你掌握知识越多,你的能力就越强。现在,既然我们知道了 React 渲染列表时到底发生了什么,我们就可以作弊,用 index 代替 id,让某些列表变得更快。

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

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

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

请确保在对的地方用对“key”值

今天就到这里!希望你喜欢这篇文章,并能更好地理解 React “key”属性的工作原理、如何正确使用它,甚至是如何按照自己的意愿利用规则,在性能游戏中作弊。

几个重点的总结:

  • 永远不要使用随机值作为“key”的值:它会导致在列表每次重渲染时,列表项目的重新挂载。
  • 在“静态”列表中使用数组索引作为“key”值也没有什么坏处:因为这些列表的项号和顺序都会保持不变。
  • 使用唯一标识符(id)作为“key”的值:当列表是动态的,元素的顺序会动态改变,或新元素会被插入到数组中随机一个地方时。
  • 在动态列表中也可以使用数组索引作为“key”值的情景:列表子项是无状态的、如展示性质的分页列表、搜索的自动完成列表等。这样可以把重挂载变为重渲染,提高列表的性能。

祝您度过愉快的一天,并祝愿您的列表项目永远不会重新渲染,除非您明确告诉它们这样做!✌🏼

此外,还可以查看 YouTube 视频,该视频通过一些漂亮的动画来解释文章内容: