React 中的列表优化——解决性能问题和反模式
我是 Federico,一名专注于前端开发和系统编程的软件工程师。您可以在Twitter、YouTube和GitHub上了解更多关于我的工作信息。
这篇文章最初发表在我的个人博客上。
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;
};
如果你想练习一下,可以先暂停阅读,尝试自己找出问题所在。
让我们深入分析一下。
缺少关键属性
从控制台我们可以注意到的第一件事是,我们key在渲染列表项时没有传递 prop。
这是由以下代码引起的:
{data.map((item) => (
<ListItem
name={item.name}
selected={selectedItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
您可能已经知道,该key属性对于 React 中动态列表的正确运行至关重要,因为它有助于框架识别哪些项目已更改、已添加或已删除。
初学者常犯的一个反模式是通过传递项目的索引来解决问题:
{data.map((item, index) => (
<ListItem
key={index}
name={item.name}
selected={selectedItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
尽管这种方法适用于简单的用例,但当列表动态变化(例如添加或删除项目)时,会导致许多意想不到的行为。例如,如果您删除列表中间索引为 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)}
/>
))}
添加密钥解决了之前的警告,但选择项目时仍然存在明显的延迟。是时候认真对待这个问题,打开性能分析器了。
分析列表
现在我们已经解决了key警告问题,可以着手解决性能问题了。在这个阶段,使用性能分析器可以帮助我们找到性能瓶颈,从而指导优化,所以我们接下来就将这样做。
在使用 React 时,主要有两种性能分析工具:浏览器内置的性能分析器(例如 Chrome 开发者工具中的分析器)和 React DevTools 扩展提供的性能分析器。两者各有用途。根据我的经验,React DevTools 的性能分析器是一个很好的起点,因为它能提供组件级的性能信息,有助于追踪导致问题的具体组件;而浏览器内置的性能分析器则作用于更底层,主要适用于性能问题并非直接由组件引起的情况,例如,由执行缓慢的方法或 Redux reducer 导致的性能问题。
因此,我们将从 React DevTools 的性能分析器开始,请确保已安装该扩展程序。然后,您可以从 Chrome 的开发者工具 > 性能分析器访问该工具。在开始之前,我们将设置两个有助于优化过程的参数:
- 在 Chrome 的“性能”选项卡中,将 CPU 节流设置为 x6。这将模拟较慢的 CPU 速度,使性能下降更加明显。
- 在 React DevTools Profiler 选项卡中,点击齿轮图标 > Profiler > “记录每次组件渲染的原因”。这将有助于我们追踪不必要的重新渲染的原因。
配置完成后,我们就可以对示例待办事项应用进行性能分析了。请点击“录制”按钮,然后在列表中选择一些项目,最后点击“停止录制”。以下是选择 3 个项目后得到的结果:
在右上角,您可以看到以红色高亮显示的提交记录,简而言之,它们就是导致 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);
现在我们可以在列表中使用MemoizedListItem`before` :ListItem
{data.map((item) => (
<MemoizedListItem
key={item.id}
name={item.name}
selected={selectedItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
太好了!我们现在已经缓存了ListItem。如果您继续尝试使用该应用程序,您会发现有些问题……
该应用程序仍然很慢!
如果我们像之前那样打开性能分析器并记录一个选项,应该会看到类似以下内容:
正如你所见,我们仍在重新渲染所有项目!这是为什么呢?
如果你将鼠标悬停在某个列表项上,你会看到“为什么会重新渲染?”部分。在本例中,它显示“” 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
}
虽然name`a` 和 `b`selected是按值比较的(因为它们分别是字符串和布尔值这种基本类型),但 `c`是 按引用onClick比较的(因为它是一个函数)。 创建列表项时,我们将回调函数作为匿名闭包传递:onClick
onClick={() => toggleItem(item)}
每次列表重新渲染时,每个项目都会收到一个新的回调函数。
从相等性的角度来看,回调函数发生了变化,因此列表MemoizedListItem也会重新渲染。
如果你仍然不明白相等性的概念,可以打开浏览器中的 JavaScript 控制台。
输入`functiontrue === 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
);
在这种情况下,即使onClick回调函数发生变化,列表项也不会重新渲染,除非name它们selected被更新。
如果您尝试这种方法,您会发现列表现在运行流畅,但实际上存在一些问题:
如您所见,现在选择多个项目的功能与预期不符,项目会被随机选中和取消选中。
这是因为该toggleItem函数并非纯函数,它依赖于项目的先前值selected。
如果您onClick从React.memo比较器中移除回调检查,那么您的组件可能会接收到过时的(失效的)
回调版本,从而导致所有这些故障。
在这个例子中,它的实现方式toggleItem并非最优,我们可以很容易地将其转换为纯函数
(实际上,我们将在下一节中这样做)。但我的重点是:通过onClick从memo
比较器中排除回调函数,您将应用程序暴露于不易察觉的过时错误中。
有些人可能会认为,只要onClick回调函数保持纯函数状态,这种做法就完全可以接受。
但我个人认为这是一种反模式,原因有二:
- 在复杂的代码库中,很容易因为错误而将纯函数转换为非纯函数。
- 编写自定义比较器会增加维护负担。如果将来
ListItem需要接受其他color参数怎么办?届时,您需要重构代码以适应新的比较器,如下所示。如果您忘记添加(在拥有多个贡献者的复杂代码库中,这种情况相对容易发生),那么您的组件又会面临因代码过时而导致的 bug 风险。
const MemoizedListItem = memo(
ListItem,
(prevProps, nextProps) =>
prevProps.name === nextProps.name &&
prevProps.selected === nextProps.selected &&
prevProps.color === nextProps.color
);
如果自定义比较器不可行,那么我们应该如何解决这个问题呢?
使回调身份稳定
我们的目标是使用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>
);
};
我们现在面临一个问题:之前,匿名闭包会item在.map迭代中捕获当前值,然后将其toggleItem
作为参数传递给函数。但现在,我们没有handleClick在迭代内部声明处理程序,那么如何在回调中访问“选中项”呢?
让我们讨论一下可能的解决方案:
重构 ListItem 组件
目前,ListItem`'s`onClick回调函数没有提供任何关于所选项目的信息。
如果它能提供这些信息,我们就可以轻松解决这个问题,所以让我们重构 ` ListItemand`List组件以提供这些信息。
首先,我们修改ListItem组件,使其接受完整的对象,并移除该属性,item因为该属性现在是多余的。 然后,我们为该事件添加一个处理程序,使其也能接收该对象作为参数。这就是最终结果:nameonClickitem
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>
);
};
如您所见,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>
);
};
太好了!我们来试试重构后的版本:
它能运行……但是速度还是很慢!如果我们打开性能分析器,可以看到整个列表仍在渲染中:
从性能分析器中可以看到,onClick身份仍在变化!这意味着handleClick每次重新渲染时,我们的身份都会发生变化。
另一种常见的反模式
在深入探讨正确的解决方案之前,我们先来讨论一下这类情况下常见的反模式。
鉴于该函数useCallback接受一个依赖项数组,你可能会想指定一个空数组来保持标识不变:
const handleClick = useCallback((item) => {
toggleItem(item);
}, []);
尽管这种方法保持了身份的稳定性,但它仍然存在我们在前面章节中讨论过的相同过时错误。
如果我们运行它,你会注意到项目会被取消选中,就像我们指定自定义比较器时发生的情况一样:
一般来说,你应该始终在useCallback、useEffect和 中指定正确的依赖项useMemo,否则,你将使
应用程序面临难以调试的过时错误。
解决 toggleItem 标识问题
正如我们之前讨论过的,我们的回调函数的问题handleClick在于,它的toggleItem依赖项标识在每次渲染时都会发生变化,导致它自身也会重新渲染:
const handleClick = useCallback((item) => {
toggleItem(item);
}, [toggleItem]);
toggleItem我们第一次尝试用useCallback与之前相同的方式进行包装handleClick:
const toggleItem = useCallback(
(item) => {
if (!selected.includes(item)) {
setSelected([...selected, item]);
} else {
setSelected(selected.filter((current) => current !== item));
}
},
[selected]
);
但这并不能解决问题,因为这个回调函数依赖于外部状态变量selected,而该变量每次setSelected调用时都会改变。如果我们希望它的身份保持稳定,我们需要一种方法来使其成为toggleItem纯函数。幸运的是,我们可以使用useState函数式更新来实现我们的目标:
const toggleItem = useCallback((item) => {
setSelected((prevSelected) => {
if (!prevSelected.includes(item)) {
return [...prevSelected, item];
} else {
return prevSelected.filter((current) => current !== item);
}
});
}, []);
如您所见,我们将之前的逻辑封装在setSelected调用中,从而提供了计算新选定项目所需的先前状态值。
如果我们运行重构后的示例,它不仅能正常运行,而且速度也很快!我们还可以运行常用的性能分析器来了解发生了什么:
如您所见,选择项目后,我们只会渲染当前选中的项目,而其他项目则会被缓存。
关于功能状态更新的说明
在我们刚才讨论的例子中,将我们的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]);
每次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]);
将列表虚拟化?
在文章结尾,我想简要介绍一下列表虚拟化,这是一种常用的提升长列表性能的技术。
简而言之,列表虚拟化的核心思想是只渲染列表中的一部分元素(通常是当前可见的元素),而延迟渲染其他元素。
例如,如果一个列表包含一千个元素,但任何时候都只有 10 个元素可见,那么我们可以先渲染这 10 个元素,其余元素可以在需要时(例如滚动后)按需渲染。
与渲染整个列表相比,列表虚拟化具有两个主要优势:
- 初始启动速度更快,因为我们只需要渲染列表的一个子集
- 由于每次只渲染一部分项目,因此内存占用更低。
话虽如此,列表虚拟化并非万能灵药,因为它会增加复杂性,而且可能出现故障。
就我个人而言,如果列表只有几百个元素,我会避免使用虚拟化列表,因为我们在本文中讨论的记忆化技术通常就足够有效了(较旧的移动设备可能需要更低的阈值)。一如既往,正确的方法取决于具体的使用场景,因此我强烈建议在深入研究更复杂的优化技术之前,先对列表进行性能分析。
我们将在以后的文章中介绍虚拟化技术。在此期间,您可以阅读更多关于 React 中虚拟列表(例如使用react-window等库)以及 React Native 中虚拟列表(例如使用内置的FlatList组件)的内容。
结论
本文深入探讨了列表优化。我们从一个存在问题的示例入手,逐步解决了大部分性能问题。
此外,我们还讨论了您应该注意的主要反模式,以及相应的解决方法。
总之,列表通常是 React 中性能问题的根源,因为默认情况下,每次数据发生变化时所有元素都会重新渲染。React.memo虽然可以使用 `require` 来有效缓解这个问题,但你可能需要重构应用程序以确保 props 的标识稳定。
如果您感兴趣,最终代码可以在这个 CodeSandbox中找到。
PS:我们的例子中还有一个小小的useMemo优化需要添加,你能发现吗? :)







