优化 React 应用性能
快速摘要
本文旨在解释如何编写高效且性能良好的 React 组件,以及一些我们可以使用的一些常见的分析技术,我们可以使用这些技术来找出应用程序中未优化的渲染行为并提高性能。
观众
本文的目标读者是中高级 React 工程师,他们非常熟悉该库,并且对该库的工作原理(尤其是虚拟 DOM、协调)以及该库如何渲染和更新实际 DOM 有很好的理解。
React 是一个很棒的库,它允许你以声明式的方式编写应用程序。这种方法很棒,因为它抽象了库如何实现特定 UI 状态的所有功能和内部细节,并确保 DOM 与你描述的状态保持同步。这是通过维护虚拟 DOM 和协调过程来实现的。为了正确理解它们,让我们先来了解一下这两个术语。
虚拟 DOM 与协调
顾名思义,虚拟 DOM 本质上是 UI 的虚拟表示,你可以将其视为包含构建实际 DOM 所需所有必要细节的蓝图。React 依赖虚拟 DOM 高效地渲染已更新的组件。任何与应用程序状态相关的交互都可能导致应用程序触发重新渲染,但 React 高效地完成了这部分工作,它首先更新虚拟 DOM 而不是实际 DOM,然后对新旧虚拟 DOM 应用比较算法来检测是否需要更新实际 DOM。
这种 diffing 算法基本上使 React 能够确定需要更新哪些 DOM 元素或属性,并使其高效运行。
您可以在官方React 文档中阅读有关差异算法的更多信息。
React 应用程序出现性能问题的主要原因之一是我们这边的一些错误实现或不必要的重新渲染,特别是当它占用大量资源并进行一些昂贵的计算时,这会导致重复触发这种差异和渲染循环并触发对实际 DOM 的更新,这可能会导致性能下降和体验缓慢。
为了使我们的应用程序获得良好的性能,我们需要确保 React 仅更新受状态变化影响的组件,理想情况下忽略所有其他组件,这将节省因重新渲染不受影响的组件而浪费的 CPU 周期和资源,并提高我们应用程序的性能。
在没有分析或基准测试的情况下优化 React 应用程序不会给我们带来太多好处,因为优化技术会产生成本,如果操作不正确,性能提升可能不值得在代码库中引入复杂性,并且可能会影响性能。
让我们从我创建的一个非常简单的应用程序开始,并在整个过程中对其进行分析,看看优化是否对我们有帮助
// Clone the repo and switch to profiling branch
git clone https://github.com/asjadanis/react-performance-tutorial
git checkout profiling
通过运行 yarn 安装节点模块,然后通过运行 yarn start 启动应用程序,您应该在浏览器中看到如下所示的内容。
现在打开浏览器控制台,运行应用程序并添加一些书籍和课程。如果您发现一些奇怪的渲染行为,那就太好了。如果您不明白,我会为您分解。当您添加书籍时,您会注意到课程列表也会被渲染,反之亦然。这不是理想的行为,也不是我们想要的行为。我们将优化组件,以确保只有那些受状态变化影响的组件才会被渲染。在深入分析之前,让我们快速浏览一下代码,以便了解我们正在处理什么。
// App.js
import { useState } from "react";
import List from "./List";
import "./styles.css";
function App() {
const [books, setBooks] = useState([]);
const [courses, setCourses] = useState([]);
const onAddBook = (item) => {
const updatedItems = [...books, { item, id: `book-${books.length + 1}` }];
setBooks(updatedItems);
};
const onAddCourse = (item) => {
const updatedItems = [
...courses,
{ item, id: `course-${courses.length + 1}` },
];
setCourses(updatedItems);
};
return (
<main className="App">
<section>
<h3> Books </h3>
<List onAddItem={onAddBook} items={books} listKey="books" />
</section>
<section>
<h3> Courses </h3>
<List onAddItem={onAddCourse} items={courses} listKey="courses" />
</section>
</main>
);
}
export default App
// AddItem.js
import { useState } from "react";
const AddItem = (props) => {
const [item, setItem] = useState("");
const onChange = (e) => {
setItem(e.target.value);
};
const addItem = () => {
if (!item) {
return;
}
props.onAddItem(item);
setItem("");
};
return (
<>
<input
value={item}
onChange={onChange}
type="text"
placeholder={props.placeholder}
/>
<button onClick={addItem}> Add </button>
</>
);
};
export default AddItem;
// List.js
import AddItem from "./AddItem";
const List = (props) => {
const { items } = props;
console.log("List rendered: ", props.listKey);
return (
<>
<AddItem onAddItem={props.onAddItem} placeholder="Add book" />
<ul>
{items.map((item) => {
return <li key={item.id}>{item.item}</li>;
})}
</ul>
</>
);
};
export default List;
我们的应用由三个组件组成,首先App.js
是主组件,它包含添加书籍和课程的逻辑,并将处理程序和书籍/课程状态作为 props 传递给List
组件。
该List
组件提供输入控制,用于使用AddItem
组件添加书籍或课程,并映射到书籍和课程列表上以进行渲染。
这很简单,每次我们添加一本书或一门课程时,我们都会更新组件的状态,App.js
从而导致组件及其子组件渲染。到目前为止一切顺利,我们可以直接进入 IDE 并修复此问题。但在本文中,我们将退一步,首先分析一下我们的应用程序,看看到底发生了什么。
我预先配置了 repo,其中包含一个不错的包why-did-you-render,它基本上可以让你在开发模式下看到应用程序中任何可避免的重新渲染。
您可以查看包文档来了解如何使用您的设置来配置它。
注意:不要在生产版本中使用此包,它只应在开发模式下使用,并且应位于您的 devDependencies 中。
分析
首先,你需要设置React 开发者工具,它是一个浏览器扩展程序,可以用来分析 React 应用程序。你需要在你的浏览器中设置它,以便继续进行性能分析部分。设置完成后,请前往应用程序http://localhost:3000/
并打开开发者工具。
现在转到分析器选项卡,您应该能够在开发工具中看到类似以下屏幕截图的内容
为了分析应用程序的性能影响并了解渲染过程,我们需要在使用过程中录制应用程序,我们来做一下。点击录制按钮,然后与应用程序交互,添加一些书籍和课程,然后停止录制。您应该能够看到应用程序组件的火焰图,以及每个组件的渲染时间占总渲染时间的比例。灰色的组件表示它们在本次提交期间未渲染。
从这里开始,您可以逐步浏览图表中的各个提交,并记录哪些组件渲染时间最长,以及是否存在渲染浪费。条形峰值是一个快速的视觉指示器,可以指示哪个提交渲染时间最长,然后您可以点击它来进一步查看导致渲染时间最长的每个组件。在我们的例子中,我们可以看到一个黄色峰值,后面跟着几个绿色峰值,这表示当我们添加一本书或课程时,渲染正在进行。
在这里我们可以看到我们的 App 组件正在渲染,这是有道理的,因为我们正在更新状态。虽然渲染两个列表并未优化,因为我们在给定时间只能更新一个列表,并且我们只希望渲染相应的列表,但在我们的例子中,两个列表都会与它们组成的 AddItem 组件一起重新渲染。现在我们对正在发生的事情有了清晰的了解,让我们通过将 List 组件包装在 React.memo 中来修复此行为,React.memo 是一个高阶组件,它使 React 能够在新 props 与旧 props 相同的情况下跳过特定组件的渲染。请注意,React.memo 仅比较 props,因此如果包装的组件包含内部状态,则更新该状态仍会导致组件重新渲染,这是所需的。
优化组件
为了修复此行为,请转到List
组件并memo
从 React 导入,并使用以下代码包装默认导出:memo
// List.js
import { memo } from "react";
const List = (props) => {
...
...
}
export default memo(List);
看起来不错,现在我们来试试吧。保持浏览器控制台打开,并将一本书添加到列表中。你应该注意到,即使将组件包装在 React.memo 中,两个列表的渲染仍然很奇怪,对吧?你还应该注意到一些额外的控制台日志,告诉我们为什么 List 组件会重新渲染,如下所示。
这些控制台日志来自我们之前讨论过的why-did-you-renderonAddItem
包,它使我们能够查看 React 应用中任何可避免的重新渲染。这里它告诉我们组件由于 props 的变化(特别是函数)而重新渲染。这是由于 JavaScript 中的引用相等性造成的,每次App
组件渲染时,它都会为处理程序创建新的函数,而引用相等性会失效,因为两个函数不会指向内存中的同一个地址——这就是 JavaScript 的工作原理。为了更好地理解这个概念,你应该阅读更多关于 JavaScript 中引用相等性的内容。
为了修复 React 中的这种行为,我们可以将处理程序包装在useCallback hook 中,该 hook 基本上会返回处理程序的 memoized 版本,并且只有当提供的依赖项之一发生变化时,它才会更改。这将确保不会创建函数的新实例,并防止重新渲染。请注意,memoization并非 React 独有的功能,而是一种编程中用于存储昂贵计算结果并在计算完成后返回缓存结果的通用优化技术。
让我们将处理程序包装在一个useCallback
import { useCallback } from "react";
const onAddBook = useCallback((item) => {
setBooks((books) => [...books, { item, id: `book-${books.length + 1}` }]);
}, []);
const onAddCourse = useCallback((item) => {
setCourses((courses) => [
...courses,
{ item, id: `course-${courses.length + 1}` },
]);
}, []);
我们传递了一个空的依赖项列表,因为我们不希望我们的处理程序在每次渲染时重新初始化,但如果需要,您可以在其中添加依赖项,现在让我们运行该应用程序并查看它的行为,如果您现在添加任何书籍或课程,您会立即注意到只有相应的列表会重新渲染,这很好,但我们也要对其进行分析,看看我们是否获得了显着的性能提升,尽管我们的示例应用程序非常简单直接,但如果它有点复杂,请考虑每个列表项都有一个子数组,可以进一步列出并包含一些资源密集型的逻辑,等等,您可以想象在这种情况下重新渲染肯定会成为一个问题。以下是分析后的结果,您也可以自己尝试一下。
我们可以在上面看到,在记忆化之后,火焰图中最高峰的总渲染持续时间约为2.8 毫秒,而之前为7.3 毫秒List
,并且我们的第二个组件没有渲染,这听起来很棒,我们通过大约 15-20 分钟的调试、分析、优化,成功节省了大约4.5 毫秒的渲染时间浪费,并且在我们的案例中性能优势没有任何视觉差异,因为应用程序非常简单并且在重新渲染时不会占用大量资源,但这并不意味着我们所做的一切都是徒劳的,目的是了解重新渲染背后的行为和原因,并客观地接近优化应用程序,而不是随机地将所有内容包装在一起React.memo
。React.useCallback
现在,我们已经开发了一个基本的心理模型,我们可以在处理 React 应用程序中与性能相关的问题时使用它。
这里需要注意的另一件事是,React 足够智能,能够确定哪些 DOM 节点需要真正更新。在上面的例子中,即使 List 组件不必要地重新渲染,React 也不会触发实际的 DOM 更新,除非必要。你可以在浏览器的开发者工具中验证这一点。由于 React 负责了更昂贵的部分,即上面简单示例中的 DOM 更新,我们甚至可能不需要优化组件。当我们的组件渲染成本高昂,或者在渲染阶段包含一些不必要的、浪费 CPU 周期的昂贵计算时,这种优化会更有成效。
一般准则
使用时请记住以下几点React.memo
- React.memo默认对 props 进行浅层比较
- 您可以将自定义函数作为第二个参数传入,以
React.memo
添加自定义逻辑来比较道具。 - 如果您需要对道具进行深入比较,请记住,根据道具的复杂程度,这会产生额外的成本。
React.memo
当你的组件在被赋予相同的 props 时渲染相同的内容,或者渲染的计算成本与上面的列表组件不同时,使用它才有意义。
如果您正在使用 React 类组件,则可以使用shouldComponentUpdate
生命周期方法或React.PureComponent
实现相同的行为,但请确保通过分析来辅助它。
您可以使用useMemo钩子来记忆每次渲染时任何计算量大的计算,确保提供一个依赖数组,以防记忆值依赖于某些其他字段,并且如果其中任何字段发生变化则需要重新计算。
结论
本博客旨在构建一个解决 React 应用程序中优化问题的思维模型,并强调使用性能分析技术来客观地实现目标。如果使用不当,优化技术会带来成本,并且会将所有内容都打包进去,memo
或者useCallback
无法神奇地提高应用程序的速度,但正确使用优化技术并在整个过程中进行性能分析绝对可以节省时间。
欢迎随时在评论区分享您的想法,或在Twitter上与我联系。