发布于 2026-01-06 0 阅读
0

React 中的列表优化——解决性能问题和反模式

React 中的列表优化——解决性能问题和反模式

我是 Federico,一名专注于前端开发和系统编程的软件工程师。您可以在TwitterYouTubeGitHub上了解更多关于我的工作信息

这篇文章最初发表在我的个人博客上

React 是最流行的前端框架,这并非偶然。它不仅由全球最大的公司之一投资,还围绕着一些关键概念(单向数据流、不可变数据、函数式组件、Hooks)构建,这些概念使得创建健壮的应用程序变得前所未有的轻松。话虽如此,它也并非完美无缺。

在 React 中很容易编写低效代码,其中最常见的问题就是无谓的重新渲染。通常,你会从一个简单的应用程序开始,然后逐步添加功能。起初,应用程序规模较小,效率低下的问题并不明显;但随着复杂性的增加,组件层级也会随之增加,重新渲染的次数也会相应增加。一旦应用程序的运行速度变得难以忍受(以你的标准来看),你就会开始分析性能并优化那些存在问题的部分。

本文将探讨列表的优化过程,列表是 React 中性能问题的常见来源。这些技巧大多适用于 React 和 React Native 应用。

从一个有问题的例子入手

我们将从一个有问题的例子入手,逐步讨论识别和解决不同问题的过程。

问题列表示例

示例是一个简单的可选项目列表,存在一些性能问题。点击项目可以切换选中状态,但操作明显卡顿。我们的目标是让选择操作更加流畅。完整的代码如下所示(也提供了一个Codesandbox 示例)。

import { useState } from "react";

// Create mock data with elements containing increasing items
const data = new Array(100)
  .fill()
  .map((_, i) => i + 1)
  .map((n) => ({
    id: n,
    name: `Item ${n}`
  }));

export default function App() {
  // An array containing the selected items
  const [selected, setSelected] = useState([]);

  // Select or unselect the given item
  const toggleItem = (item) => {
    if (!selected.includes(item)) {
      setSelected([...selected, item]);
    } else {
      setSelected(selected.filter((current) => current !== item));
    }
  };

  return (
    <div className="App">
      <h1>List Example</h1>
      <List data={data} selectedItems={selected} toggleItem={toggleItem} />
    </div>
  );
}

const List = ({ data, selectedItems, toggleItem }) => {
  return (
    <ul>
      {data.map((item) => (
        <ListItem
          name={item.name}
          selected={selectedItems.includes(item)}
          onClick={() => toggleItem(item)}
        />
      ))}
    </ul>
  );
};

const ListItem = ({ name, selected, onClick }) => {
  // Run an expensive operation to simulate a load
  // In real-world JS applications, this could be either a custom
  // JS elaboration or a complex render.
  expensiveOperation(selected);

  return (
    <li
      style={selected ? { textDecoration: "line-through" } : undefined}
      onClick={onClick}
    >
      {name}
    </li>
  );
};

// This is an example of an expensive JS operation that we might
// execute in the render function to simulate a load.
// In real-world applications, this operation could be either a custom
// JS elaboration or just a complex render
const expensiveOperation = (selected) => {
  // Here we use selected just because we want to simulate
  // an operation that depends on the props
  let total = selected ? 1 : 0;
  for (let i = 0; i < 200000; i++) {
    total += Math.random();
  }
  return total;
};

Enter fullscreen mode Exit fullscreen mode

如果你想练习一下,可以先暂停阅读,尝试自己找出问题所在。

让我们深入分析一下。

缺少关键属性

从控制台我们可以注意到的第一件事是,我们key在渲染列表项时没有传递 prop。

缺少关键属性警告

这是由以下代码引起的:

{data.map((item) => (
  <ListItem
    name={item.name}
    selected={selectedItems.includes(item)}
    onClick={() => toggleItem(item)}
  />
))}
Enter fullscreen mode Exit fullscreen mode

您可能已经知道,该key属性对于 React 中动态列表的正确运行至关重要,因为它有助于框架识别哪些项目已更改、已添加或已删除。

初学者常犯的一个反模式是通过传递项目的索引来解决问题:

{data.map((item, index) => (
  <ListItem
    key={index}
    name={item.name}
    selected={selectedItems.includes(item)}
    onClick={() => toggleItem(item)}
  />
))}
Enter fullscreen mode Exit fullscreen mode

尽管这种方法适用于简单的用例,但当列表动态变化(例如添加或删除项目)时,会导致许多意想不到的行为。例如,如果您删除列表中间索引为 N 的项目,那么位于 N+1 的所有列表项的键值都会发生变化。这会导致 React 混淆哪些映射组件对应哪些项目。如果您想了解更多关于使用索引作为键值的潜在缺陷,这篇文章是一个很好的参考资料。

因此,您应该指定一个键属性,其中包含能够唯一标识正在渲染的项的信息。如果您接收的数据来自后端,则可以使用数据库的唯一 ID 作为键。否则,您可以在创建项时使用nanoid生成客户端随机 ID。

幸运的是,我们自己的每个项目都有自己的 id 属性,所以我们应该按如下方式处理:

{data.map((item) => (
  <ListItem
    key={item.id}
    name={item.name}
    selected={selectedItems.includes(item)}
    onClick={() => toggleItem(item)}
  />
))}
Enter fullscreen mode Exit fullscreen mode

添加密钥解决了之前的警告,但选择项目时仍然存在明显的延迟。是时候认真对待这个问题,打开性能分析器了。

分析列表

现在我们已经解决了key警告问题,可以着手解决性能问题了。在这个阶段,使用性能分析器可以帮助我们找到性能瓶颈,从而指导优化,所以我们接下来就将这样做。

在使用 React 时,主要有两种性能分析工具:浏览器内置的性能分析器(例如 Chrome 开发者工具中的分析器)和 React DevTools 扩展提供的性能分析器。两者各有用途。根据我的经验,React DevTools 的性能分析器是一个很好的起点,因为它能提供组件级的性能信息,有助于追踪导致问题的具体组件;而浏览器内置的性能分析器则作用于更底层,主要适用于性能问题并非直接由组件引起的情况,例如,由执行缓慢的方法或 Redux reducer 导致的性能问题。

因此,我们将从 React DevTools 的性能分析器开始,请确保已安装该扩展程序。然后,您可以从 Chrome 的开发者工具 > 性能分析器访问该工具。在开始之前,我们将设置两个有助于优化过程的参数:

  • 在 Chrome 的“性能”选项卡中,将 CPU 节流设置为 x6。这将模拟较慢的 CPU 速度,使性能下降更加明显。

CPU降频

  • 在 React DevTools Profiler 选项卡中,点击齿轮图标 > Profiler > “记录每次组件渲染的原因”。这将有助于我们追踪不必要的重新渲染的原因。

React 分析器设置

配置完成后,我们就可以对示例待办事项应用进行性能分析了。请点击“录制”按钮,然后在列表中选择一些项目,最后点击“停止录制”。以下是选择 3 个项目后得到的结果:

React 分析器结果

在右上角,您可以看到以红色高亮显示的提交记录,简而言之,它们就是导致 DOM 更新的渲染操作。正如您所见,当前提交的渲染耗时 2671 毫秒。通过将鼠标悬停在各个元素上,我们可以看出大部分时间都花在了渲染列表项上,平均每个列表项耗时 26 毫秒。

渲染单个项目耗时 26 毫秒本身并不算糟糕。只要整个操作耗时少于 100 毫秒,用户仍然会感觉响应迅速。我们最大的问题在于,选择单个项目会导致所有项目重新渲染,而这正是我们将在下一节中解决的问题。

此时我们应该问自己一个问题:执行操作后,预期需要重新渲染多少个项目?在本例中,答案是 1,因为点击操作的结果是选中一个新项目,其他项目不受影响。另一种情况是单选列表,其中任何时候最多只能选中一个项目。在这种情况下,点击一个项目应该导致重新渲染两个项目,因为我们需要同时渲染选中的项目和取消选中的项目。

使用 React.memo 防止重新渲染

在上一节中,我们讨论了选择单个项目会导致整个列表重新渲染。
理想情况下,我们希望只重新渲染那些外观受新选择影响的项目。
我们可以使用React.memo 的高阶组件来实现这一点。

简而言之,React.memo它会将新旧 props 进行比较,如果相等,则重用之前的渲染结果。
否则,如果 props 不同,则重新渲染组件。
需要注意的是,React 对 props 执行的是浅比较,因此在传递对象和方法作为 props 时必须考虑到这一点。
你也可以重写比较函数,但我并不建议这样做,因为这会降低代码的可维护性(稍后会详细介绍)。

现在我们已经了解了 的基础知识React.memo,让我们通过ListItem用它包裹 来创建另一个组件:

import { memo } from "react";

const MemoizedListItem = memo(ListItem);
Enter fullscreen mode Exit fullscreen mode

现在我们可以在列表中使用MemoizedListItem`before` :ListItem

  {data.map((item) => (
    <MemoizedListItem
      key={item.id}
      name={item.name}
      selected={selectedItems.includes(item)}
      onClick={() => toggleItem(item)}
    />
  ))}
Enter fullscreen mode Exit fullscreen mode

太好了!我们现在已经缓存了ListItem。如果您继续尝试使用该应用程序,您会发现有些问题……
该应用程序仍然很慢!

如果我们像之前那样打开性能分析器并记录一个选项,应该会看到类似以下内容:

记忆化后的 React 分析器

正如你所见,我们仍在重新渲染所有项目!这是为什么呢?
如果你将鼠标悬停在某个列表项上,你会看到“为什么会重新渲染?”部分。在本例中,它显示“” Props changed: (onClick),这意味着由于我们传递给每个项目的回调函数,
我们的项目正在重新渲染。onClick

正如我们之前讨论过的,默认情况下React.memo会对props进行浅比较
。 这实际上意味着会对每个 prop 调用严格相等运算符===。在我们的例子中,检查
大致相当于:

function arePropsEqual(prevProps, nextProps) {
  return prevProps.name === nextProps.name &&
         prevProps.selected === nextProps.selected &&
         prevProps.onClick === nextProps.onClick
}
Enter fullscreen mode Exit fullscreen mode

虽然name`a` 和 `b`selected是按比较的(因为它们分别是字符串和布尔值这种基本类型),但 `c`是引用onClick比较的(因为它是一个函数)。 创建列表项时,我们将回调函数作为匿名闭包传递:

onClick

onClick={() => toggleItem(item)}
Enter fullscreen mode Exit fullscreen mode

每次列表重新渲染时,每个项目都会收到一个新的回调函数
从相等性的角度来看,回调函数发生了变化,因此列表MemoizedListItem也会重新渲染。

如果你仍然不明白相等性的概念,可以打开浏览器中的 JavaScript 控制台。
输入`function true === true('function')`,你会发现结果是 `function('function') true`。
但如果你输入 `function ( (() => {}) === (() => {})'function')`,你会得到 ` falsefunction('function')` 作为结果。
这是因为两个函数只有在它们具有相同的身份标识时才相等,而
每次我们创建一个新的闭包时,都会生成一个新的身份标识。

因此,我们需要一种方法来保持回调函数的身份onClick稳定,以防止不必要的重新渲染,
而这正是我们将在下一节中讨论的内容。

一种常见的反模式

在讨论提出的解决方案之前,我们先来分析一下这类案例中常见的(反)模式。
鉴于该React.memo方法接受自定义比较器,你可能会想提供一个人为
地将某些值排除 onClick在检查范围之外的比较器。例如:

const MemoizedListItem = memo(
  ListItem,
  (prevProps, nextProps) =>
    prevProps.name === nextProps.name &&
    prevProps.selected === nextProps.selected
    // The onClick prop is not compared
);
Enter fullscreen mode Exit fullscreen mode

在这种情况下,即使onClick回调函数发生变化,列表项也不会重新渲染,除非name它们selected被更新。
如果您尝试这种方法,您会发现列表现在运行流畅,但实际上存在一些问题:

React 列表自定义比较器

如您所见,现在选择多个项目的功能与预期不符,项目会被随机选中和取消选中。
这是因为该toggleItem函数并非纯函数,它依赖于项目的先前值selected
如果您onClickReact.memo比较器中移除回调检查,那么您的组件可能会接收到过时的(失效的)
回调版本,从而导致所有这些故障。

在这个例子中,它的实现方式toggleItem并非最优,我们可以很容易地将其转换为纯函数
(实际上,我们将在下一节中这样做)。但我的重点是:通过onClickmemo
比较器中排除回调函数,您将应用程序暴露于不易察觉的过时错误中

有些人可能会认为,只要onClick回调函数保持纯函数状态,这种做法就完全可以接受。
但我个人认为这是一种反模式,原因有二:

  • 在复杂的代码库中,很容易因为错误而将纯函数转换为非纯函数。
  • 编写自定义比较器会增加维护负担。如果将来ListItem需要接受其他color参数怎么办?届时,您需要重构代码以适应新的比较器,如下所示。如果您忘记添加(在拥有多个贡献者的复杂代码库中,这种情况相对容易发生),那么您的组件又会面临因代码过时而导致的 bug 风险。
const MemoizedListItem = memo(
  ListItem,
  (prevProps, nextProps) =>
    prevProps.name === nextProps.name &&
    prevProps.selected === nextProps.selected &&
    prevProps.color === nextProps.color
);
Enter fullscreen mode Exit fullscreen mode

如果自定义比较器不可行,那么我们应该如何解决这个问题呢?

使回调身份稳定

我们的目标是使用React.memo不带自定义比较器的“基础”版本。
选择这种方式既能提高组件的可维护性,又能增强其对未来变更的鲁棒性。
但是,为了使记忆化功能正常工作,我们需要重构回调函数以保持其标识的稳定性,否则
执行的相等性检查React.memo会阻止记忆化。

在 React 中,保持函数标识稳定的传统方法是使用useCallbackhook。
该 hook 接受一个函数和一个依赖项数组,只要依赖项不变,回调函数的标识也不会改变。
让我们重构一下示例,使用useCallback

我们的第一个尝试是将匿名闭包移到() => toggleItem(item)一个单独的方法中useCallback

const List = ({ data, selectedItems, toggleItem }) => {
  const handleClick = useCallback(() => {
    toggleItem(??????) // How do we get the item?
  }, [toggleItem])

  return (
    <ul>
      {data.map((item) => (
        <MemoizedListItem
          key={item.id}
          name={item.name}
          selected={selectedItems.includes(item)}
          onClick={handleClick}
        />
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

我们现在面临一个问题:之前,匿名闭包会item.map迭代中捕获当前值,然后将其toggleItem
作为参数传递给函数。但现在,我们没有handleClick在迭代内部声明处理程序,那么如何在回调中访问“选中项”呢?
让我们讨论一下可能的解决方案:

重构 ListItem 组件

目前,ListItem`'s`onClick回调函数没有提供任何关于所选项目的信息。
如果它能提供这些信息,我们就可以轻松解决这个问题,所以让我们重构 ` ListItemand`List组件以提供这些信息。

首先,我们修改ListItem组件,使其接受完整的对象,并移除该属性,item因为该属性现在是多余的。 然后,我们为该事件添加一个处理程序,使其也能接收该对象作为参数。这就是最终结果:name
onClickitem

const ListItem = ({ item, selected, onClick }) => {
  // Run an expensive operation to simulate a load
  // In real-world JS applications, this could be either a custom
  // JS elaboration or a complex render.
  expensiveOperation(selected);

  return (
    <li
      style={selected ? { textDecoration: "line-through" } : undefined}
      onClick={() => onClick(item)}
    >
      {item.name}
    </li>
  );
};
Enter fullscreen mode Exit fullscreen mode

如您所见,onClick现在将当前项作为参数提供。

等等!你在li`<click>`事件onClick处理程序中又用了匿名闭包,我们是不是应该避免使用匿名闭包来防止重新渲染?
虽然我们可以useCallback在组件内部创建一个新的记忆化回调ListItem来处理点击事件,但这在这种情况下并不会带来任何性能提升。
我们之前讨论过的匿名闭包的问题List在于它破坏了 ` <click>` 元素React.memo的记忆化MemoizedListItem。既然我们没有对 `<click>` 元素进行记忆化li,那么为这个回调函数使用稳定的标识符就没有任何性能优势。

然后我们可以重构List组件,使其传递itemprop 而不是,并在回调函数name使用新获得的信息itemhandleClick

const List = ({ data, selectedItems, toggleItem }) => {
  const handleClick = useCallback(
    (item) => {  // We now receive the selected item
      toggleItem(item);
    },
    [toggleItem]
  );

  return (
    <ul>
      {data.map((item) => (
        <MemoizedListItem
          key={item.id}
          item={item}  // We pass the full item instead of the name
          selected={selectedItems.includes(item)}
          onClick={handleClick}
        />
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

太好了!我们来试试重构后的版本:

React 列表示例,更改后

它能运行……但是速度还是很慢!如果我们打开性能分析器,可以看到整个列表仍在渲染中:

更改后的性能分析结果

从性能分析器中可以看到,onClick身份仍在变化!这意味着handleClick每次重新渲染时,我们的身份都会发生变化。

另一种常见的反模式

在深入探讨正确的解决方案之前,我们先来讨论一下这类情况下常见的反模式。
鉴于该函数useCallback接受一个依赖项数组,你可能会想指定一个空数组来保持标识不变:

  const handleClick = useCallback((item) => {
    toggleItem(item);
  }, []);
Enter fullscreen mode Exit fullscreen mode

尽管这种方法保持了身份的稳定性,但它仍然存在我们在前面章节中讨论过的相同过时错误
如果我们运行它,你会注意到项目会被取消选中,就像我们指定自定义比较器时发生的情况一样:

React 列表示例(含 bug)

一般来说,你应该始终在useCallbackuseEffect和 中指定正确的依赖项useMemo,否则,你将使
应用程序面临难以调试的过时错误。

解决 toggleItem 标识问题

正如我们之前讨论过的,我们的回调函数的问题handleClick在于,它的toggleItem依赖项标识在每次渲染时都会发生变化,导致它自身也会重新渲染:

  const handleClick = useCallback((item) => {
    toggleItem(item);
  }, [toggleItem]);
Enter fullscreen mode Exit fullscreen mode

toggleItem我们第一次尝试用useCallback与之前相同的方式进行包装handleClick

  const toggleItem = useCallback(
    (item) => {
      if (!selected.includes(item)) {
        setSelected([...selected, item]);
      } else {
        setSelected(selected.filter((current) => current !== item));
      }
    },
    [selected]
  );
Enter fullscreen mode Exit fullscreen mode

但这并不能解决问题,因为这个回调函数依赖于外部状态变量selected,而该变量每次setSelected调用时都会改变。如果我们希望它的身份保持稳定,我们需要一种方法来使其成为toggleItem纯函数。幸运的是,我们可以使用useState函数式更新来实现我们的目标:

  const toggleItem = useCallback((item) => {
    setSelected((prevSelected) => {
      if (!prevSelected.includes(item)) {
        return [...prevSelected, item];
      } else {
        return prevSelected.filter((current) => current !== item);
      }
    });
  }, []);
Enter fullscreen mode Exit fullscreen mode

如您所见,我们将之前的逻辑封装在setSelected调用中,从而提供了计算新选定项目所需的先前状态值。

如果我们运行重构后的示例,它不仅能正常运行,而且速度也很快!我们还可以运行常用的性能分析器来了解发生了什么:

将鼠标悬停在正在渲染的项目上:
修复后的 React 示例

将鼠标悬停在其他项目上:
修复后的 React 示例 2

如您所见,选择项目后,我们只会渲染当前选中的项目,而其他项目则会被缓存。

关于功能状态更新的说明

在我们刚才讨论的例子中,将我们的toggleItem方法转换为函数式编程模式useState相对简单。但
在实际应用中,情况可能并非如此简单。

例如,您的函数可能依赖于多个状态片段:

  const [selected, setSelected] = useState([]);
  const [isEnabled, setEnabled] = useState(false);

  const toggleItem = useCallback((item) => {
    // Only toggle the items if enabled
    if (isEnabled) {
      setSelected((prevSelected) => {
        if (!prevSelected.includes(item)) {
          return [...prevSelected, item];
        } else {
          return prevSelected.filter((current) => current !== item);
        }
      });
    }
  }, [isEnabled]);
Enter fullscreen mode Exit fullscreen mode

每次isEnabled值发生变化时,你的toggleItem身份也会随之改变。
在这种情况下,你应该将两个子状态合并到同一个useState调用中,或者更好的是,将它们合并为一个useReducer调用。
鉴于 ` useReducer's`dispatch函数具有稳定的身份,你可以将这种方法扩展到复杂的状态。
此外,这同样适用于Redux 的`'s`dispatch函数,因此你可以将项目切换逻辑移到 Redux 层,并将我们的toggleItem函数转换为如下形式:

  const dispatch = useDispatch();

  // Given that the dispatch identity is stable, the `toggleItem` will be stable as well
  const toggleItem = useCallback((item) => {
    dispatch(toggleItemAction(item))
  }, [dispatch]);
Enter fullscreen mode Exit fullscreen mode

将列表虚拟化?

在文章结尾,我想简要介绍一下列表虚拟化,这是一种常用的提升长列表性能的技术。
简而言之,列表虚拟化的核心思想是只渲染列表中的一部分元素(通常是当前可见的元素),而延迟渲染其他元素。
例如,如果一个列表包含一千个元素,但任何时候都只有 10 个元素可见,那么我们可以先渲染这 10 个元素,其余元素可以在需要时(例如滚动后)按需渲染。

与渲染整个列表相比,列表虚拟化具有两个主要优势:

  • 初始启动速度更快,因为我们只需要渲染列表的一个子集
  • 由于每次只渲染一部分项目,因此内存占用更低。

话虽如此,列表虚拟化并非万能灵药,因为它会增加复杂性,而且可能出现故障。
就我个人而言,如果列表只有几百个元素,我会避免使用虚拟化列表,因为我们在本文中讨论的记忆化技术通常就足够有效了(较旧的移动设备可能需要更低的阈值)。一如既往,正确的方法取决于具体的使用场景,因此我强烈建议在深入研究更复杂的优化技术之前,先对列表进行性能分析。

我们将在以后的文章中介绍虚拟化技术。在此期间,您可以阅读更多关于 React 中虚拟列表(例如使用react-window等库)以及 React Native 中虚拟列表(例如使用内置的FlatList组件)的内容。

结论

本文深入探讨了列表优化。我们从一个存在问题的示例入手,逐步解决了大部分性能问题。
此外,我们还讨论了您应该注意的主要反模式,以及相应的解决方法。

总之,列表通常是 React 中性能问题的根源,因为默认情况下,每次数据发生变化时所有元素都会重新渲染。
React.memo虽然可以使用 `require` 来有效缓解这个问题,但你可能需要重构应用程序以确保 props 的标识稳定。

如果您感兴趣,最终代码可以在这个 CodeSandbox中找到。

PS:我们的例子中还有一个小小的useMemo优化需要添加,你能发现吗? :)

文章来源:https://dev.to/federicoterzi/optimizing-lists-in-react-solving-performance-problems-and-anti-patterns-2ph4