[译] React Key:实现高性能列表的最佳实践
原文地址: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.html 和 https://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} />);
简而言之:当 CountriesList
组件重新渲染时,每个 Item
也会重新渲染。如果用 React.memo
对 Item
进行包装,我们甚至可以避免这些不必要的重新渲染,从而提高列表组件的性能。
现在,有趣的部分来了:如果我们不使用索引,而是一些随机字符串作为“key”属性呢?
countries.map((country, index) => <Item country={country} key={Math.random()} />);
在这种情况下:
- 在每次
CountriesList
重新渲染,会重新生成“key”属性。 - 由于“key”属性已经存在,React 将使用它来识别元素是否存在。
- 由于所有元素的“key”属性都是新的,所有“渲染前”的项目都将被视为“已移除”,每个项目都将被视为“新的”,React 将卸载所有项目并重新创建它们。
简而言之:当 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 值已经改变,因此会绕过记忆化函数,触发实际项目的重新渲染。
基于 id 的行为是正确和高效的:项目能被准确识别,每个项目都被备忘,因此没有组件需要重新渲染。
如果我们为 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 视频,该视频通过一些漂亮的动画来解释文章内容: