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

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

前言

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

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

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 需要它?

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

  1. 首先,React 会生成元素 "before"和 "after"的 "快照

  2. 其次,它会尝试识别页面上已经存在的元素,以便重新使用它们,而不是从头开始创建它们

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

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

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

  1. 来自数据库的数据:如果数据来自数据库,则可以使用数据库键/ID,因为它们具有唯一性。
  2. 本地生成的数据:如果数据是在本地生成和持久化的(例如记事本应用程序中的笔记),在创建项目时可使用递增计数器、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 组件重新渲染时会发生什么情况?

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

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

tsx 复制代码
countries.map((country, index) => <Item country={country} key={index} />);

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

现在有意思的部分来了:如果我们不使用索引,而是在 "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 受控的情况下点击排序按钮。

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

  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,因此没有组件需要重新渲染。

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

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>
  );
}

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

  1. 初始状态是重复的,我们可能需要将其提取到另一个变量中,以防止错误不匹配
  2. 更新时需要保持两者同步
  3. 如果状态是通过自定义 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,并避免在顶层组件上设置,因为这可能会导致(难以发现的)性能问题。

总结

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

参考

相关推荐
桂月二二4 小时前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062065 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb5 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角5 小时前
CSS 颜色
前端·css
浪浪山小白兔6 小时前
HTML5 新表单属性详解
前端·html·html5
lee5767 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm
2401_897579657 小时前
AI赋能Flutter开发:ScriptEcho助你高效构建跨端应用
前端·人工智能·flutter
光头程序员7 小时前
grid 布局react组件可以循数据自定义渲染某个数据 ,或插入某些数据在某个索引下
javascript·react.js·ecmascript
limit for me7 小时前
react上增加错误边界 当存在错误时 不会显示白屏
前端·react.js·前端框架
浏览器爱好者7 小时前
如何构建一个简单的React应用?
前端·react.js·前端框架