优化 React 应用
在本文中,我们将尝试理解优化 React 应用程序的方法。我们将通过一个真实示例来探讨开发人员面临的一些最常见的问题。然后,我们将逐步讲解改进应用程序以有效解决这些问题的过程。我们还将学习如何做出明智的决策,并在不同的可能方案之间取得适当的平衡。
注意:本文篇幅较长。不过,您可能已经熟悉我们讨论的一些主题。因此,我建议您选择性地阅读与您相关的部分。
目录
介绍
近年来,React 因其声明式编程的引入,在构建用户应用程序方面越来越受欢迎。以前,每当 UI 发生变更时,开发者都必须编写代码来更新 UI 的不同部分。这个过程在大规模情况下难以管理,并且随着应用程序的发展经常导致错误。React 通过将 UI 定义为状态函数改变了这一现状。现在,开发者只需专注于正确更新状态,React 会负责更新 UI。
React 是一个快速构建正确 UI 的好工具。然而,接下来的问题就是性能。
React 的性能挑战
React 并非没有局限性。多年来,创建高性能的 React 应用程序变得越来越困难,这一点已变得越来越明显。让我们回顾一下 React 如何更新 UI,以了解问题所在。
安排更新
React 维护着一个组件树。这些组件可以具有一些状态。开发者可以使用 请求更新状态setState
。这会指示 React 安排组件状态的更新。React 会执行渲染并提交来处理此组件下子树的更新。
渲染阶段
使用新的更新状态重新评估组件。对于函数组件,这意味着再次调用该函数。React 会递归地重新评估被更新组件的所有子组件。最终,React 会获得组件树中所有组件的输出。React 要求使用纯函数来完成此操作,这意味着如果使用相同状态多次渲染,输出应该相同。输出应该仅依赖于输入,而不依赖于其他外部因素。组件可以使用useEffect描述任何需要单独运行的副作用。
提交阶段
渲染阶段的输出是组件子树下 DOM 的更新表示(通常称为虚拟 DOM)。React 现在拥有子树中过时的 DOM 以及更新后的 DOM。它有一个O(n)
算法来修补过时的 DOM 并进行更新。
问题出在哪里?
提交阶段非常简单。在命令式范式下,开发人员需要手动更新 DOM,这会导致更新效率低下。React 则通过谨慎地一次性更新所需内容来取代了这种做法。
渲染阶段通常计算开销更大,因为需要重新计算整个子树。对于单次更新来说,这本身不是什么大问题。但是,当多个更新快速发生时,就会成为一个真正的问题。React 团队已经意识到了这一点,并已开始努力缓解这个问题。
假设你有一个父组件,它有 3 个状态变量—— s1
、s2
和s3
。它还有 3 个子组件依赖于其中一个变量。
即使只有s1
发生变化,父级及其所有子级都会重新渲染,而不仅仅是父级和子级 1。这没有问题,因为渲染过程是纯粹的,重新渲染其他子级也会返回相同的结果。但是,当对 进行大量快速更新时,s1
性能开销就会变得明显。高度交互的应用程序(例如聊天、视频会议等)通常会出现这种情况。s2
s3
React 引入了批量状态更新、后台并发渲染和 memoization 等措施来解决这个问题。我认为解决这个问题的最佳方法是改进其响应式模型。应用需要能够跟踪在更新给定状态变量时需要重新运行的代码,并明确地更新与此更新对应的 UI。像solid.js和svelte这样的工具就是以这种方式工作的。这也消除了对虚拟 DOM 和 diff 的需求。
我们如何优化?
软件优化通常并非一个简单的过程。每个变更都涉及相应的权衡。有些权衡可能对您的用例来说是可以接受的,而有些则可能是不可接受的。这使得优化成为一个非常具体的过程。通常,它是以下步骤的反复迭代:
- 观察压力下的应用。
- 查找应用程序的哪些部分遇到性能问题。
- 调试这些性能问题的根本原因。
- 想出一些方法来缓解这些问题。
- 比较这些方法之间的利弊,并选择最适合您用例的方法。
通过真实的例子来理解
让我们考虑一个股票监控应用程序。这个模拟应用程序实时监控快速更新的股票价格。它部署在这里。为了获得最佳体验,你应该从这里的源代码克隆并运行该应用程序。
该应用程序包含以下部分:
-
显示每秒发生多少次渲染以及这些更新的平均渲染和提交持续时间。
-
可以绘制渲染次数、平均渲染持续时间和整个分析过程中每秒计算所花费的总时间的折线图。
Total time = (Render + Commit) * Render Count
为了保持一致,我将对每个版本的应用进行1 分钟的性能分析。每次都会触发常规事件200ms
。我们将观察应用的行为,并迭代代码以使其性能更佳。
监控应用程序
该应用已基于Profiler API构建了一个分析器小部件。该应用的开发版本已部署,因此可以轻松进行监控。
此外,您可以使用 React 开发者工具查看组件树并对其进行性能分析。这将详细分析每次重新渲染的原因以及每个组件重新渲染所花费的时间。
Chrome 还具有内置性能监视器,可帮助您查看 React 上下文之外的应用程序的整体行为
没有优化
让我们观察一下未进行任何优化的应用。为了更好地观察性能波动,您可以人为地降低 CPU 速度。我将以6x
降低速度为例进行演示。
继续,然后点击“观察”。记住,在开始观察之前,先开始分析。想要停止时,请点击Unobserve
。

分析
让我们看一下分析小部件生成的图表并了解趋势:
时间 = 0 - 18 秒
- 渲染次数从
10-11
每秒渲染次数开始。它会暂时保持在该值。 0
渲染持续时间从到线性增加50ms
- 总花费时间从 线性
0
增加到500ms
。
这些趋势表明,随着时间的推移,JS 线程的负载会增加。渲染时长增加,也会导致总渲染时间增加。500ms = 10 x 50ms
这意味着 JS 线程可以处理目前为止的所有渲染任务。
时间 = 18 秒 - 33 秒
- 渲染数量迅速下降,
10
从5
- 渲染持续时间几乎呈线性增长,
110ms
- 总共花费的时间在
500ms
到之间650ms
。
仔细观察就会发现,这些趋势是由于渲染时长随时间推移而增加造成的。这会阻塞线程JS
更多时间,从而减少每秒可用于其他渲染的时间。总耗时显示了渲染次数减少和渲染时长增加之间的相关性。当JS
线程节流并减少处理的渲染次数时,总耗时会减少。但是,由于平均渲染时长也会增加,因此渲染总耗时会再次增加。
时间 = 33 秒 - 60 秒
- 渲染次数持续下降。到最后,它从
5
平均3-4
渲染次数下降到平均渲染次数。下降幅度不像以前那么大 - 渲染时长仍在持续增加。不过,现在增长速度已经放缓。这就是为什么渲染次数没有像以前那样急剧下降的原因。
- 总时间持续波动。平均值仍然
500-600ms
左右波动较大。
这仅仅是因为 JS 线程接近吞吐量极限,因此处理的渲染数量减少了。计算量的增长变得缓慢。此时,应用程序变得卡顿且响应速度较慢。
以下是使用 Chrome Inspector 进行性能分析的屏幕截图。
如果你查看顶部,会发现有很多滞后的帧。这些更新虽然显示出来,但速度却落后于主线程。这些帧被标记为黄色,并被标记为“部分呈现的帧”92%
。在观察时间内,JS 线程处于繁忙状态。
现在让我们看一下 Chrome Inspector 下的 React 分析器。
这进一步告诉我们两件事:
- 该
Table
组件的渲染成本最高 - 在渲染过程中,组件的钩子
1
和交替变化。2
Unoptimised
添加优化
我们熟悉该应用的行为方式。表格组件是瓶颈。我们可以从减少渲染它的时间入手。让我们看一下未优化的应用版本 ( src/routes/unoptimised/unoptimised.tsx
)。
这些是 React Profiler 报告的钩子:
stockEventList
由表使用。averagePrices
用于折线图绘制。
由于 useEffect 的作用,这些组件会交替更新line 35
。由于它们用于维护 UI 的不同部分,我们可以在子组件上添加 memoization 机制,以确保它们只重新渲染其所使用的组件。因此,更新组件时averagePrices
不应重新渲染表格,更新组件时stockEventList
也不应重新渲染折线图。
权衡
此更改将减少渲染持续时间。记忆化将增加内存使用量。React 文档表示这不会造成损害。
它还会引入重构。作为开发人员,你会尽量减少对代码库的修改。这确保了现有逻辑不会中断。然而,使用 React,你经常需要修剪并重构大型组件,将其拆分成更小的组件。这是因为你的函数直接映射到 UI 的一部分。Solid.js采用了一种有趣的组件处理方法,将其简单地视为实现模块化和可重用性的一种手段。拆分成组件不会影响更新时运行的代码量。
添加记忆
最终代码可以在()下看到src/routes/add-memo/add-memo.tsx
。以下是所做的更改列表:
- 表格和折线图组件被包装起来,
useMemo
以确保当不相关的变化导致应用程序重新渲染时,它们不会重新渲染。 - 将内联常量移至函数组件之外。这确保了我们在重新渲染时不会创建新的等效对象,因为这些对象可能会导致子组件重新渲染。
React 计划迁移到一个新的编译器,它可以帮你完成部分工作。但目前,这项责任落在了开发者身上。
分析
让我们再次看一下分析图:
这次有一些有趣的趋势:
- 平均渲染时长缩短了。从最长的 缩短了一半,降至
180ms
。90ms
这是因为表格不会在每次交替渲染时重新渲染。 - 这反映在 JS 线程能够毫无问题地处理渲染,
39s
这比以前有了很大的改进18s
。 - 渲染次数维持在
10
最高值39s
,然后开始下降。 - 总花费时间增加到
39s
之前的范围,然后在该范围内波动。
基于这些以及未优化版本的洞察,我们了解到,当总耗时达到 时,JS 线程就会开始吃力500-600ms
。从长远来看,此版本的应用也会遇到与之前相同的问题。虽然耗时会更长,但应用最终的响应速度会变慢。
现在让我们看一下 React Profiler。
趋势的变化是因为每次交替渲染的规模都变小了。然而,表格的渲染时长却持续增加。
添加优化
如果每隔 发送一次常规事件200ms
,则每秒应该都会5
渲染一次。然而,我们发现渲染次数(当应用能够处理负载时)是该数字的两倍。让我们专注于进一步减少渲染次数。如果您查看代码 ( src/routes/add-memo/add-memo.tsx
),您会发现其中有useEffect
一个line 51
。
useEffect
当库存事件列表发生变化时,此方法会运行。它会计算最近 50 条记录的平均值,并将表格滚动到底部。然而,useEffect
它用于与外部系统同步。由于触发的时间,这一点非常重要。它在渲染之后useEffect
运行,并作为一个延迟事件。如果它运行与 React 相关的代码,那么可能有更好的位置来放置这些代码。这是开发人员必须记住的众多神奇规则之一,但 React 的设计并没有严格执行。
在前面的例子中,stockEventList
的更新会导致重新渲染。这会触发 ,useEffect
从而更新 averagePrices 并引发另一次重新渲染。我们可以将 useEffect 下的代码添加到股票事件的观察者中。由于多个状态更新是同时进行的,因此它们将被批量处理为单个更新。
权衡
这项更改将再次引入重构。React 代码的许多问题都是因为组件没有定期更新而产生的。它们可能会变得过于庞大和混乱,从而难以管理。组件可以通过useState
和useEffect
钩子实现动态变化,因此很容易忘记组件内部哪些代码在何时运行。由于组件状态多次更新,某些代码即使在不需要时也可能反复运行。如果由于不相关的状态更新而运行该代码,则会导致功能错误。这违背了 React 创建正确应用程序的初衷。开发人员需要跟踪代码并描述更改的依赖关系。React 提供了一些有用的规则,可以通过IDEs
和 强制执行linting
。
因此,在更新 React 代码时,你应该始终尝试重构代码,以确保只有正确的代码运行最少的次数。目前,这项责任落在了开发人员身上,这在编写代码时带来了额外的复杂性。
减少渲染
为了批量更新状态,我们将代码从useEffect
移到了观察者逻辑中。结果可以在这里找到 ( src/routes/reduce-renders/reduce-renders.tsx
)
您可能注意到的另一个变化是useStableCallback
钩子。它是一个钩子,它会创建一个引用不变但调用时始终运行最新代码的函数。它的灵感来自类组件。React 计划通过useEffectEvent实现类似的功能。
分析
现在,两个状态变量会在一次渲染中同时更新。这是因为 React 现在已经将这些更新批量处理了。
让我们再次看一下分析趋势:
该应用程序的行为与以前的版本类似。以下是趋势:
- 渲染次数较低(
5
当应用程序可以处理所有更新时),这意味着没有额外的渲染。 - 平均渲染持续时间已恢复到最大值
180ms
。 - 这是因为渲染次数减少了一半,但每次渲染的成本几乎翻了一番。在之前的版本中,每次替换渲染的开销都非常小。然而,这次,每次更新都会导致表格重新渲染。因此,每次渲染的计算量都与未优化版本相同。
- 所花费的总时间
500ms-600ms
大约与之前相同的时间达到该范围(39s
)。
添加优化
我们减少了渲染次数。应用比以前更好了,但速度仍然变慢,响应也更慢。根本原因是渲染时长增加。如果你查看 React 分析器,就会发现这是因为 table 组件
该表会渲染所有行。每次更新时,所有行都会重新渲染。随着条目数量的增加,重新渲染所有行所花费的时间也会增加。平均价格图并未显示这种趋势,因为我们仅显示了最近 50 条条目的平均值。它内部使用 Canvas,在快速更新下性能非常出色。
如果我们限制渲染的行数,使其仅显示可见项,就可以降低重新渲染表格的成本。即使表格中有很多行,也只会渲染屏幕上显示的行。这可以通过虚拟化来实现。我将在应用中使用react-virtuoso 。
权衡
此项更改将确保应用不会变慢并变得无响应。然而,虚拟化技术牺牲了滚动的流畅度。快速滚动会导致表格组件内部出现空白闪烁。当列表滚动到底部显示新的股票事件时,也会出现这种情况。我们也不会将行渲染到视图之外,因为这可能会隐藏在浏览器的搜索功能中。
减少计算
该应用程序的下一个版本可以在 下找到src/routes/reduce-computation/components/reduce-computation.tsx
。
以下是所做的更改:
- 图表和表格的代码被分解成更小的部分。它们可以使用 分别进行记忆
memo
。 - 此外,平均值的逻辑也已移至
Chart
组件中。这样可以防止组件更新时表格被重新渲染Chart
。 - 我们还消除了
averagePrices
状态并简化了平均值的计算。之前我们循环遍历50
列表的最后条目。现在我们根据之前的平均值创建新的条目。 - 在表格组件中,我们将其替换
NextTable
为TableVirtuoso
分析
以下是趋势:
- 平均渲染时长要短得多,
0.8ms-1.5ms
平均介于两者之间。它没有增加。有一些波动看起来很明显。然而,这些只是微小的变化,由于渲染时长较短而被放大了。 - 总花费时间也限制为
18ms-33ms
。它不会随着时间的推移而增加。 - 渲染的数量是恒定的,但其值要高得多
18-20
。
为了了解渲染次数较高的原因,我们可以检查 React 分析器。
Hook 2
会导致组件额外重新渲染Virtuoso Table
。此钩子负责跟踪视图中有哪些行。组件的工作原理是,在屏幕上显示可见行,并在这些行的上方和下方留出空间,以适应列表的大小。每次更新时,我们都会滚动到末尾,这会导致Virtuoso Table
组件内部重新渲染。
JS 线程占用的13.8%
观察时间比之前低得多92%
。没有丢帧,并且应用程序在较长时间内保持响应。
添加优化
这款应用仍然感觉不太好用。更新速度太快,无法进行任何有意义的交互。如果我们能以更长的时间间隔同时处理多个股票事件就更好了。这也能减少 JS 线程的负载。此外,滚动到底部似乎给应用带来的问题比价值更大。用户很难集中注意力在一个不断滚动的列表中。它会导致虚拟表格的额外重新渲染,并在列表滚动时导致空白闪烁。如果表格以反向顺序显示列表,并将新条目添加到顶部,则可以消除这个问题。您应该与您的UX
团队讨论这个问题。
权衡
通过同时处理多个更新来降低 UI 更新速度,可以提高性能UX
。然而,同时添加多个事件也会导致视觉偏移量增大,并使状态管理变得复杂。此外,在渲染表格时反转列表也会产生额外的开销。不过,由于更新速度较慢,这应该不会造成太大的影响。
优化更新
此时,引入状态管理库是一个很好的选择。React 提供了context 和 reducer来实现复杂的状态管理。使用它们,你可以在应用程序的任何地方公开你的状态,并根据需要进行更新。但它们也有一定的局限性:
- Context 可以暴露一个对象中组合的多个值。即使其中一个值发生变化,Context 也会导致所有使用它的地方重新渲染。
- 你无法控制更新的触发方式。如果上下文提供程序中发生了重新渲染,并改变了上下文值,那么所有消费者都会重新渲染。
- 开发人员需要完成大部分有关状态管理逻辑的繁重工作
引入状态管理库可以帮助简化这个过程。具体方法如下:
- 您可以在消费者组件中精确指定整个状态存储中的数据。只有当该特定值发生更改时,消费者才会重新渲染。因此,您可以对更新时重新渲染的范围进行细粒度的控制。
- 你可以使用瞬态更新。在这种情况下,你可以订阅组件中 store 的值,但你可以选择何时更新导致重新渲染。这在状态更新频繁的应用中非常有用。这是因为外部 store 通常在 React 作用域之外运行,你可以控制它与组件的集成方式。
- 状态管理库提供了一种系统化的方法来维护与状态相关的逻辑。这在大型团队中非常有用。
- 大多数库都带有专用的开发工具。它们还附带大量社区维护的插件,使复杂的状态操作变得非常容易。
就我们的情况而言,积分1
和2
可以帮助我们在快速更新的应用程序中提供更好的更新处理方式。我已经在应用程序中使用了zustand 。
了解商店的结构
商店代码可在 找到src/routes/optimise-updates/store/stock-store.ts
。其工作原理如下:
- 商店维护着库存事件 ID 列表。您可以在表格组件中读取此列表。
- 股票事件记录,按其 ID 索引。表格中的各个行都可以从此记录中读取条目。当条目需要更新时,此功能也非常有用。在这种情况下,只有相关的行组件会重新渲染。在之前的版本中,表格组件接受一个事件对象列表作为
prop
。更改单个条目会导致表格组件重新渲染。
我们还有一个自定义钩子,它利用了瞬态更新。我们会限制更新,而不是在状态改变时立即重新渲染组件。代码可以在 中找到src/routes/optimise-updates/hooks/use-throttled-store.ts
。
使用此钩子将确保更新被限制在指定的时间间隔内。默认值为,600ms
这意味着3
与股票事件相关的更新将触发1
重新渲染。
现在我们可以在chart
andtable
组件中使用它了。 需要注意的是,ID 列表在渲染之前会被反转。这样就避免了每次触发事件时都滚动到底部。如果需要,反转列表也可以在 store 中维护。
我们在组件中读取股票事件数据StockTableRow
。这不需要节流。这是因为它从table
已经受到节流更新的组件获取 ID。
最终代码可以在 找到src/routes/optimise-updates/components/optimise-updates.tsx
。
分析
最初,渲染次数和总耗时出现了峰值。这是因为虚拟表格在计算布局时,条目会导致溢出。然而,一旦表格滚动显示出来,就不会再出现这种情况。为了避免峰值,可以假设所有行的高度都相同。我们可以通过在 props 中提供大小来让 virtuoso 跳过此计算。
初始峰值之后:
- 渲染数量保持在
1-3
- 渲染持续时间保持在
0.1-0.7ms
- 总共花费的时间保持在 之间
0.5-2ms
。
与上一版本相比,这是一个巨大的优化,上一版本的总耗时最多为33ms
。性能监视器进一步显示,UI 更新速度现在有所减缓。JS 线程仅将5.38%
时间用于渲染工作。
添加优化
现在应用程序似乎已经稳定了。但是,考虑到存储,从长远来看,它会无限增长。这将导致内存使用量增加。我们可以从存储中移除旧条目以释放内存。在实际使用中,当再次需要时,您会重新获取这些条目。这将确保内存不会无限增长。
权衡
这会增加状态更新的复杂性。处理删除旧条目的更新可能需要更长时间。
优化内存
更新后的存储区位于:src/routes/optimise-memory/store/stock-store.ts
。当 ID 列表的大小达到 时200
,我们会从存储区中删除前几个条目。因此,仅会维护100
最多 个条目。200
分析
更新后的代码可以在 中找到src/routes/optimise-memory/components/optimise-memory.tsx
。以下是内存优化前的性能分析:
内存占用并没有下降太多。之前是在 的范围内11.0-19.4 MB
。现在在 的范围内11.2-19.3 MB
。这可能是因为事件对象太小,占用的内存不多。同时,令人担忧的是,在此期间出现了一系列丢帧现象。这是因为存储被清除,阻塞了 JS 线程。
哪里出了问题?
这是一个过度优化的例子。我们可以节省一些内存使用量,但计算成本更高。没有内存使用优化的版本已经足够好了,因为在实际使用中,不会有人长时间监控股票,导致内存被占满。
但是,如果我们想确保内存不会无限增长,我们可以尝试调整存储的清除点以及一次删除的条目数量。调整这些参数应该能让我们的性能达到可接受的范围。
总结
优化是一个棘手的过程。你需要观察你的应用程序,正确调试瓶颈,并通过良好的设计来缓解这些瓶颈。由于 React 的声明式模型,你无法完全控制哪些代码在何时运行,这增加了复杂性。根据你的用例,可能还有其他优化方案。一些常见的优化方案包括:
- 包拆分和延迟加载:将构建的应用程序拆分成可延迟加载的较小块。这些块仅在需要时才发送到客户端应用程序。这有助于减少应用程序运行时在客户端加载的代码量。
- 服务器端渲染:这是旧式服务器渲染应用程序和新式单页应用程序的混合体。您可以选择性地在服务器上渲染组件,并随时将其提供给客户端。这有助于提高安全性,从客户端抽象出逻辑,并提供更短的加载时间和更佳的搜索引擎可见性。
- Web Workers 和 Web Assembly:对于计算量大的应用程序,您可以将工作转移到单独的线程。这些线程称为 Web Workers。您还可以
C++
通过 Web Assembly 运行已编译(或其他受支持的语言)的代码,从而在浏览器中获得高性能。
我是不是漏掉了什么?这篇文章还能改进吗?有没有你喜欢的内容?欢迎在评论区留言告诉我!
致谢
封面照片由 RealToughCandy[dot]com 提供: https: //www.pexels.com/photo/hand-holding-react-sticker-11035471/
文章来源:https://dev.to/yashmahalwal/optimising-react-apps-56c6