今天来深入研究一个 "基本"但不简单 的面试题:React 中的 key 属性到底有什么用?
前言
关于 React 的 "key"
属性,我们可能经常会遇到控制台中显示下图所示的 warning
。
如果你配置了 eslint-plugin-react
,eslint
还会来搞你一下:
react/jsx-key
会警告你在 JSX 中 map 数组时忘记指定 key 属性。不过需要注意的是:eslint-plugin-react
中的 react/jsx-key
对于 React.Fragment
语法(<>...</>
)不起作用。
比如:
tsx
array.map((item) => (
<>
<div>{item.title}</div>
<div>{item.description}</div>
</>
))
官方guthub文档 中了解到,在旧版本中,为了让 react/jsx-key
适用于 React.Fragment
速记语法,必须启用 checkFragmentShorthand
:
json
"react/jsx-key": ["error", { checkFragmentShorthand: true }]
据说在最新的版本中 checkFragmentShorthand
已经默认启用。
我猜想大多数人在面对 "React 中的 key 属性有什么用?" 这个问题时,都会回答 "呃......我们应该把唯一值作为组件的标记,这样 React 才能识别哪些 item 发生了变化,避免重渲染,这样对性能更好"。某种程度上讲,这个回答可能是有问题的,下面会讲到。
如果不加 "key" 属性会发生什么?程序会崩溃吗?如果我在这里输入一个随机字符串会怎样?值的唯一性如何?可以直接使用数组的索引值吗?这些选择会产生什么影响?它们对性能有什么影响?
让我们一起来深入研究一下!
React 中的 key 属性是如何工作的?
首先,在开始编码之前,我们先弄清楚理论:什么是 "key" 属性,为什么 React 需要它?
简单回顾一下重新渲染过程的简化算法如下:
-
首先,React 会生成元素 "before"和 "after"的 "快照
-
其次,它会尝试识别页面上已经存在的元素,以便重新使用它们,而不是从头开始创建它们
- 如果存在 "key" 属性,它会认为 "before" 和 "after" 键相同的项目是相同的
- 如果不存在 "key" 属性,它将使用同级索引作为默认 "key" 。
-
最后
- 删除在 "before" 阶段存在但在 "after" 阶段不存在的项目(即卸载它们,
removed -> unmount
) - 从头开始创建在 "before" 变量中不存在的项目(即加载它们,
added -> mount
) - 更新 "before" 存在并在 "after" 继续存在的项目(即重新渲染它们,
exists -> re-render
)
- 删除在 "before" 阶段存在但在 "after" 阶段不存在的项目(即卸载它们,
最新的官方文档 这样说:
想象一下,你桌面上的文件没有名字。取而代之的是,你可以按顺序来称呼它们--第一个文件、第二个文件,以此类推。你可能会习惯,但一旦删除文件,就会变得混乱。第二个文件会变成第一个文件,第三个文件会变成第二个文件,以此类推。
文件夹中的文件名和数组中的 JSX key 属性的作用类似。它们能让我们在同级项目之间唯一地识别一个项目。一个精心选择的 key 提供了比数组中的位置更多的信息。即使位置因重新排序而发生变化,key 也能让 React 在项目的整个生命周期中识别该项目。
官方也给出了"陷阱"
提示:
- 你可能会倾向于使用数组中项目的索引作为其键。事实上,如果不指定键,React 就会使用索引作为键。但是,如果项目被插入、删除,或者数组被重新排序,那么渲染项目的顺序就会随着时间的推移而改变。索引作为键通常会导致一些微妙而令人困惑的错误。
- 同样,也不要临时生成键,例如
key={Math.random()}
。这会导致键值在不同的渲染中永远不会匹配,从而导致每次都要重新创建所有组件和 DOM。这样做不仅速度慢,还会丢失列表项中的任何用户输入。取而代之的是根据数据使用一个稳定的 ID。
请注意,你的组件不会接收 key 作为 props 。它只会被 React 本身用作提示。如果你的组件需要一个 ID,你必须将其作为单独的 props 传递:<Profile key={id} userId={id} />
.
官方甚至还给出了怎么获取 key,不同的数据源提供了不同的 key 来源:
- 来自数据库的数据:如果数据来自数据库,则可以使用数据库键/ID,因为它们具有唯一性。
- 本地生成的数据:如果数据是在本地生成和持久化的(例如记事本应用程序中的笔记),在创建项目时可使用递增计数器、
crypto.randomUUID()
或uuid
等软件包。
或者:
如前面面试问题中所言,使用唯一的 key 能避免重渲染吗?
答案是:不能!
我准备了一个简单的例子(codesandbox),主要代码如下:
tsx
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 是不好的实践?
让我们先实现一个国家列表。我们将有一个项目组件,用于渲染国家信息:
tsx
const Item = ({ country }) => {
return (
<button className="country-item">
<img src={country.flagUrl} />
{country.name}
</button>
);
};
和一个渲染实际列表的 CountriesList
组件:
tsx
const CountriesList = ({ countries }) => {
return (
<div>
{countries.map((country) => (
<Item country={country} />
))}
</div>
);
};
现在,我的项目上没有 "key" 属性。那么,当 CountriesList
组件重新渲染时会发生什么情况?
- React 会发现这里没有 "key" ,并退回到使用国家数组的索引作为键的状态
- 我们的数组没有变化,因此所有项目都将被识别为 "已存在",并且项目将重新渲染
从本质上讲,这与在项目中明确添加 key={index}
没有什么区别
tsx
countries.map((country, index) => <Item country={country} key={index} />);
简而言之:当 CountriesList
组件重新渲染时,每个 Item
也会重新渲染。如果用 React.memo
对 Item
进行包装,我们甚至可以摆脱这些不必要的重新渲染,从而提高列表组件的性能。
现在有意思的部分来了:如果我们不使用索引,而是在 "key" 属性中添加一些随机字符串呢?
tsx
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 时,究竟是什么原因导致了这样的后果呢?
首先,我们在上面的示例中看不到这些情况。所有这些错误和对性能的影响只会发生在 "动态" 列表中------在列表中,项目的顺序或数量会在重新渲染时发生变化。为了模拟这种情况,让我们为列表实现排序功能:
tsx
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
作为关键字,在两个变量中实现列表:
tsx
sortedCountries.map((country) => <ItemMemo country={country} key={country.id} />);
和数组索引作为键:
tsx
sortedCountries.map((country, index) => <ItemMemo country={country} key={index} />);
为了提高性能,我们将立即对 Item 组件进行 memo:
tsx
const ItemMemo = React.memo(Item);
下面是完整实现的 codesandbox 。在 CPU 受控的情况下点击排序按钮。
注意基于 "索引" 的列表速度稍慢,并注意控制台输出:
- 在基于 "索引" 的列表中,每次点击按钮都会重新显示每个Item,尽管
Item
是memoised
的,从技术上讲不应该这样做。 - 基于 "id" 的实现,除了 key 值外,与基于 "key" 的完全相同,不会出现这个问题:点击按钮后不会重新渲染任何项目,控制台输出也很干净。
为什么会出现这种情况?问题当然是 "key" 值:
- React 会生成
"before"
和"after"
的元素列表,并尝试识别 "相同" 的项目。 - 从 React 的角度来看,"相同" 的项目就是具有相同 key 值的项目
- 在基于 "索引" 的实现中,无论数组如何排序,数组中的第一个项总是
key="0"
,第二个项总是key="1"
因此,当 React 进行比较时,当它在 "before"
(之前)和 "after"
(之后)列表中看到 key="0"
的项目时,它会认为这是完全相同的项目,只是 props
值不同:在我们反转数组后,国家值发生了变化。因此,它对同一个项目做了应该做的事:触发重新渲染循环。因为它认为国家 props
值已经改变,因此会绕过memo
函数,触发实际项目的重新渲染。
而基于 id 的行为是正确和高效的:项目能被准确识别,每个项目都被 memo
,因此没有组件需要重新渲染。
如果我们为项目组件引入一些状态,这种行为就会特别明显。例如,让我们在点击时改变其背景:
tsx
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):
tsx
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 定义)在渲染过程中保持不变,只有在手动更改时才会受到影响。重置状态的一种常见方法是手动修改它们:
tsx
const Demo = () => {
const [name, setName] = useState('default');
const [address, setAddress] = useState(null);
const reset = () => {
setName('default');
setAddress(null);
}
return (
// ...
<button onClick={()=>reset()} >Reset</button>
);
}
这样做并不理想,原因有三:
- 初始状态是重复的,我们可能需要将其提取到另一个变量中,以防止错误不匹配
- 更新时需要保持两者同步
- 如果状态是通过自定义 Hooks 添加的,我们就没有办法直接从组件中重置它。
更好的办法是通过添加一个名为 key 的特殊 props 来重置。众所周知,我们需要在映射组件列表时传递 key。但它也可以用来重置组件状态。但这需要在父组件中添加一个新的状态,以便重置子组件。
tsx
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 键的值,从而手动重置组件状态。从技术上讲,这并不是它自己的工作,而是需要父组件的帮助,但这似乎是最简洁的重置方法。这样做的缺点是需要重新渲染父组件及其所有子组件。但由于没有改变其他状态,理想情况下这应该不是什么大问题。
还有一种场景,当用户浏览应用程序时,你可能需要重置某些状态以显示正确的数据。请看下面一个简单文章组件的示例:
tsx
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 属性,以重置状态。请看下面的示例:
tsx
function ArticleContainer({ articleId }) {
return (
<Article key={articleId} articleId={articleId} />
)
}
function Article({ articleId }) {
const [likes, setLikes] = useState(0)
/** ... */
}
就是这样!添加一个属性后,就可以去掉 useEffect
了。
表单场景
下面来一个简单的例子:
tsx
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>
);
}
页面长这样,试着在表单中输入一些内容,然后点击切换,就会发现表单状态被重置了。
还有比如一些表单切换场景:
tsx
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,并避免在顶层组件上设置,因为这可能会导致(难以发现的)性能问题。
总结
- 切勿在 "key" 属性中使用随机值:它会导致项目在每次渲染时都重新挂载。当然,除非这是你的本意
- 在 "静态" 列表(不存在增加、删除等操作)中使用数组索引作为 "key" 并无不妥------这些列表的Item编号和顺序保持不变
- 当列表可以重新排序或项目可以随机添加时,使用项目唯一标识符("id")作为 "key"
- 对于具有无状态项目的动态列表,也可以使用数组的索引作为 "key",在这种情况下,项目会被新的项目替换--分页列表、搜索和自动完成结果等。这将提高列表的性能。
参考
- React key attribute: best practices for performant lists
- React reconciliation: how it works and why should we care
- youtube - The mystery of React key: how to write performant lists
- youtube - React reconciliation: how it works and why should we care
- The mystery of React key: how to write performant lists