radEventListener:客户端框架性能的故事

Avatar of Jeremy Wagner
Jeremy Wagner

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

React 很受欢迎,受欢迎到它也收到了不少批评。然而,这些针对 React 的批评并非完全没有道理:React 和 ReactDOM 总共大约有 120 KiB 的压缩 JavaScript 代码,这无疑会拖慢 启动时间。当完全依赖 React 的客户端渲染时,它会变得很卡顿。即使您在服务器端渲染组件并在客户端进行水合,它仍然会卡顿,因为 组件水合 在计算上代价很高。

在需要复杂状态管理的应用程序中,React 当然有其用武之地,但在我的职业生涯中,我发现它并不适合我所见到的大多数场景。当即使一点点的 React 都会在速度快慢不一的设备上造成问题时,使用它就成为一个需要仔细考虑的选择,它实际上排除了使用低端硬件的用户。

如果听起来我似乎对 React 有成见,那么我必须承认我真的很喜欢它的组件化模型。它使代码组织更容易。我认为 JSX 非常棒。服务器渲染也很酷——即使这只是我们如今用来表达“通过网络发送 HTML”的方式。

尽管如此,即使我愉快地在服务器端使用 React 组件(或者像我更喜欢的 Preact),但确定何时在客户端使用它却有点挑战。以下是我的 React 性能发现,我试图以对用户最友好的方式应对这一挑战。

设置场景

最近,我一直在开发一个名为 bylines.fyi 的 RSS 订阅应用程序的副项目。这个应用程序在后端和前端都使用了 JavaScript。我不认为客户端框架是可怕的东西,但我经常观察到我在日常工作和研究中遇到的客户端框架实现的两个问题。

  1. 框架有可能阻碍我们对它们抽象的事物(即 Web 平台)的更深入理解。如果不了解框架所依赖的一些底层 API,我们就无法知道哪些项目受益于框架,哪些项目最好不使用框架。
  2. 框架并不总是为良好的用户体验提供清晰的路径。

您可能可以争辩我第一点的有效性,但第二点正变得越来越难以反驳。您可能还记得不久前 Tim KadlecHTTPArchive 上对 Web 框架性能进行了一些研究,并 得出结论,React 的性能并不出色

尽管如此,我仍然想知道是否可以在服务器端使用我认为 React 最好的部分,同时减轻它对客户端的不良影响。对我来说,同时希望使用框架来帮助组织我的代码,以及限制框架对用户体验的负面影响是有道理的。这需要进行一些实验才能找到最适合我的应用程序的方法。

实验

我确保在我的服务器上渲染我使用的每个组件,因为我相信提供标记的负担应该由 Web 应用程序的服务器承担,而不是用户的设备。但是,为了使可切换的移动导航正常工作,我的 RSS 订阅应用程序需要 一些 JavaScript 代码。

The mobile nav toggle functionality. At left, the mobile nav is in the closed state. On the right, it’s in the open state, which overlays the entire screen with the navigation.

这种情况恰如其分地描述了我所说的 简单状态。以我的经验,简单状态的一个典型例子是线性的 A 到 B 交互。我们打开一个东西,然后我们关闭它。是有状态的,但简单

不幸的是,我经常看到使用有状态的 React 组件来管理简单状态,这对于性能来说是一个有问题的权衡。虽然目前这可能是一个模糊的表述,但随着您继续阅读,您会逐渐明白。也就是说,需要强调的是,这是一个简单的例子,但它也是一个警示。大多数开发人员——我希望如此——不会仅仅依靠 React 来驱动其网站上的一件事的如此简单的行为。因此,了解您将要看到的这些结果的目的是为了告知您如何构建您的应用程序,以及当涉及到运行时性能时,您的框架选择的影响如何扩展。

条件

我的 RSS 订阅应用程序仍在开发中。它不包含任何第三方代码,这使得在安静的环境中进行测试变得很容易。我进行的实验比较了三种实现中的移动导航切换行为。

  1. 在服务器端渲染并在客户端水合的有状态 React 组件 (React.Component)。
  2. 一个有状态的 Preact 组件,也在服务器端渲染并在客户端水合。
  3. 一个服务器端渲染的无状态 Preact 组件,未进行水合。相反,普通的 事件监听器 在客户端提供移动导航功能。

这三种场景都在四种不同的环境中进行了测量。

  1. 在 Chrome 83 上运行的 诺基亚 2 Android 手机
  2. 在 Chrome 83 上运行的 Windows 10 上的 2013 年 华硕 X550CC 笔记本电脑
  3. 在 Safari 13 上运行的旧款 第一代 iPhone SE
  4. 在 Safari 13 上运行的新款 第二代 iPhone SE

我相信这一系列的移动硬件将说明各种设备功能上的性能,即使它在 Apple 方面略微偏重。

测量内容

我想测量每个环境中每种实现的四件事。

  1. 启动时间。对于 React 和 Preact,这包括加载框架代码以及在客户端水合组件所需的时间。对于事件监听器场景,这仅包括事件监听器代码本身。
  2. 水合时间。对于 React 和 Preact 场景,这是启动时间的一个子集。由于 macOS 上 Safari 中远程调试崩溃的问题,我无法单独测量 iOS 设备上的水合时间。事件监听器实现没有产生任何水合成本。
  3. 移动导航打开时间。这让我们深入了解框架在其事件处理程序的抽象中引入了多少开销,以及这与无框架方法相比如何。
  4. 移动导航关闭时间。事实证明,这比打开菜单的成本要低得多。我最终决定不在本文中包含这些数字。

需要注意的是,这些行为的测量包括脚本时间。任何布局、绘制和合成成本都将是这些测量的补充和外部。应该注意的是,这些活动与触发它们的脚本争夺主线程时间。

流程

为了在每个设备上测试这三种移动导航实现,我遵循了以下流程。

  1. 我在 macOS 上使用 Chrome 中的远程调试 来调试诺基亚 2。对于 iPhone,我使用了 Safari 中等效的远程调试功能。
  2. 我在每个设备上访问了在我的本地网络上运行的 RSS 订阅应用程序上的同一页面,该页面可以运行移动导航切换代码。因此,网络性能不是我测量中的一个因素。
  3. 在没有应用 CPU 或网络限流的情况下,我开始在分析器中录制,并重新加载页面。
  4. 页面加载后,我打开移动导航,然后关闭它。
  5. 我停止了分析器,并记录了每个四种行为中涉及的 CPU 时间。
  6. 我清除了性能时间轴。在 Chrome 中,我还点击了 垃圾回收 按钮,以释放可能由我应用程序的代码从上一个会话录制中占用 的任何内存。

我针对每个设备的每种场景重复此过程十次。十次迭代似乎可以获得足够的数据来查看一些异常值,同时获得相当准确的画面,但随着我们回顾结果,我会让您自己决定。如果您不想逐一了解我的发现,您可以查看 此电子表格 中的结果并得出您自己的结论,以及 每种实现的移动导航代码

结果

我最初想用图表来呈现这些信息,但由于我所测量内容的复杂性,我不确定如何在不使可视化效果混乱的情况下呈现结果。因此,我将在一系列表格中呈现 CPU 时间的最小值、最大值、中位数和平均值,所有这些表格都能有效地说明我在每次测试中遇到的结果范围。

诺基亚 2 上的 Google Chrome

诺基亚 2 是一款低成本的 Android 设备,配备了 ARM Cortex-A7 处理器。它不是性能强大的设备,而是一款价格低廉且易于获得的设备。目前全球 Android 的使用率约为 40%,尽管 Android 设备的规格差异很大,但低端 Android 设备并不少见。这是一个我们必须认识到的问题,它与 财富和靠近高速网络基础设施 都有关。

让我们看看启动成本的数据是什么样的。

启动时间
React 组件Preact 组件addEventListener 代码
最小值137.2131.234.69
中位数147.7642.065.99
平均值162.7343.166.81
最大值280.8162.0312.06

我认为,平均需要超过 160 毫秒来解析和编译 React,以及水化一个组件,这说明了一些问题。提醒一下,在这种情况下,启动成本包括浏览器评估移动导航工作所需脚本所需的时间。对于 React 和 Preact,它还包括水化时间,这两种情况下都可能导致我们在启动时偶尔遇到的 诡异谷效应

Preact 的表现要好得多,花费的时间比 React 少约 73%,考虑到 Preact 在未压缩的情况下只有 10 KiB,这是有道理的。尽管如此,重要的是要注意 Chrome 的帧预算约为 10 毫秒,以避免在 60 fps 时出现卡顿。启动时的卡顿与其他任何卡顿一样糟糕,并且是计算 首次输入延迟 的一个因素。不过,总的来说,Preact 的性能相对较好。

至于 addEventListener 实现,事实证明,对于没有开销的小脚本,解析和编译时间非常低,这并不奇怪。即使在采样的最大时间 12 毫秒时,您也几乎没有进入 Janksburg 大都市区的边缘区域。现在让我们看看仅水化成本。

水化时间
React 组件Preact 组件
最小值67.0419.17
中位数70.3326.91
平均值74.8726.77
最大值117.8644.62

对于 React,这仍然在Yikes Peak附近。当然,一个组件的中位水化时间为 70 毫秒并不是什么大问题,但想想 当同一页面上有很多组件时,水化成本是如何扩展的。我测试的 React 网站在此设备上感觉更像是耐力测试而不是用户体验,这并不奇怪。

Preact 的水化时间要少得多,这是有道理的,因为 Preact 的 hydrate 方法文档 指出它“跳过大多数差异化,同时仍然附加事件监听器并设置组件树”。没有报告 addEventListener 场景的水化时间,因为水化不是 VDOM 框架之外的事物。接下来,让我们看看打开移动导航所需的时间。

移动导航打开时间
React 组件Preact 组件addEventListener 代码
最小值30.8911.943.94
中位数43.6214.296.14
平均值43.1614.666.12
最大值53.1920.468.60

我发现这些数据有点令人惊讶,因为 React 执行事件监听器回调的 CPU 时间几乎是您自己注册的事件监听器的七倍。这是有道理的,因为 React 的状态管理逻辑是必要的开销,但人们不得不怀疑对于简单的线性交互来说,这是否值得。

另一方面,Preact 设法将其在事件监听器上的开销限制在执行事件监听器回调只需要“仅”两倍的 CPU 时间。

关闭移动导航所涉及的 CPU 时间要少得多,React 的平均大约时间约为 16.5 毫秒,Preact 和裸事件监听器分别约为 11 毫秒和 6 毫秒。我会发布关闭移动导航测量的完整表格,但我们还有很多东西需要筛选。此外,您可以在我之前提到的 电子表格 中自己查看这些数据。

关于 JavaScript 样本的简要说明

在继续 iOS 结果之前,我想解决的一个潜在问题是 在 Chrome DevTools 中禁用 JavaScript 样本 在远程设备上录制会话时的影响。在编译我的初始结果后,我想知道捕获整个调用栈的开销是否扭曲了我的结果,因此我重新测试了禁用样本的 React 场景。事实证明,此设置对结果没有重大影响。

此外,由于调用栈被截断,我无法测量组件的水化时间。禁用样本与启用样本的平均启动成本分别为 160.74 毫秒和 162.73 毫秒。相应的中位数分别为 157.81 毫秒和 147.76 毫秒。我认为这完全属于“噪声范围”。

第一代 iPhone SE 上的 Safari

初代 iPhone SE 是一款很棒的手机。尽管它已经有些老旧,但由于其更舒适的物理尺寸,它仍然受到忠实用户的喜爱。它搭载了 Apple A9 处理器,这仍然是一款强大的竞争者。让我们看看它在启动时间上的表现。

启动时间
React 组件Preact 组件addEventListener 代码
最小值32.067.630.81
中位数35.609.421.02
平均值35.7610.151.07
最大值39.1816.941.56

这比诺基亚 2 有了很大的改进,也说明了低端 Android 设备与即使是使用时间较长的旧款 Apple 设备之间的差距。

React 的性能仍然不佳,但 Preact 使我们能够达到 Chrome 的典型帧预算。当然,仅事件监听器速度极快,在帧预算中为其他活动留出了大量空间。

不幸的是,我无法在 iPhone 上测量水化时间,因为每次我在 Safari 的 DevTools 中遍历调用栈时,远程调试会话都会崩溃。考虑到水化时间是整体启动成本的一部分,如果诺基亚 2 测试的结果有任何指示,您可以预计它可能至少占启动时间的一半。

移动导航打开时间
React 组件Preact 组件addEventListener 代码
最小值16.915.450.48
中位数21.118.620.50
平均值21.0911.070.56
最大值24.2019.791.00

React 在这里表现不错,但 Preact 似乎更有效地处理事件监听器。裸事件监听器速度极快,即使在这款旧款 iPhone 上也是如此。

第二代 iPhone SE 上的 Safari

在 2020 年年中,我入手了新款 iPhone SE。它与 iPhone 8 和类似手机具有相同的物理尺寸,但处理器与 iPhone 11 上使用的 Apple A13 相同。对于其相对较低的 400 美元的零售价来说,它非常快。鉴于如此强大的处理器,它的处理能力如何呢?

启动时间
React 组件Preact 组件addEventListener 代码
最小值20.265.190.53
中位数22.206.480.69
平均值22.026.360.68
最大值23.677.180.88

我想在某种程度上,当加载单个框架并水化一个组件的相对较小的工作负载时,会存在收益递减的情况。在某些情况下,第二代 iPhone SE 比其第一代版本的速度略快,但差别不大。我想这款手机处理更大且持续的工作负载的能力会比其前代产品更好。

移动导航打开时间
React 组件Preact 组件addEventListener 代码
最小值13.1512.060.49
中位数16.4112.570.53
平均值16.1112.630.56
最大值17.5113.260.78

React 的性能略有改善,但其他方面变化不大。奇怪的是,Preact 在此设备上打开移动导航的平均时间似乎比其第一代对应产品更长,但我将其归因于异常值扭曲了相对较小的数据集。我当然不会根据此假设第一代 iPhone SE 是一款更快的设备。

老旧 Windows 10 笔记本电脑上的 Chrome

诚然,这些是我最期待看到的结果:2013 年的华硕笔记本电脑,搭载 Windows 10 和当时的 Ivy Bridge i5 处理器,如何处理这些事情?

启动时间
React 组件Preact 组件addEventListener 代码
最小值43.1513.111.81
中位数45.9514.542.03
平均值45.9214.472.39
最大值48.9816.493.61

考虑到设备已有七年历史,这些数据还不错。Ivy Bridge i5 在当时是一款优秀的处理器,再加上它 采用主动冷却(而不是像移动设备处理器那样 被动冷却),因此它可能不像移动设备那样经常遇到 热节流 场景。

水化时间
React 组件Preact 组件
最小值17.757.64
中位数23.558.73
平均值23.128.72
最大值26.259.55

Preact 在这里表现良好,并且设法保持在 Chrome 的帧预算内,速度几乎是 React 的三倍。如果你在页面启动时要渲染十个组件,情况可能会有很大不同,甚至可能在 Preact 中也是如此。

移动导航打开时间
Preact 组件addEventListener 代码
最小值6.062.500.88
中位数10.433.090.97
平均值11.243.211.02
最大值14.444.341.49

对于这种隔离的交互,我们看到的性能与高端移动设备类似。看到这样一台老旧的笔记本电脑仍然能够保持相当不错的性能,这令人鼓舞。也就是说,这台笔记本电脑在浏览网页时风扇经常旋转,所以主动散热可能是这款设备的救星。如果这款设备的 i5 是被动散热的,我怀疑它的性能可能会下降。

浅调用栈获胜

React 和 Preact 启动时间比完全摒弃框架的解决方案更长,这并不奇怪。工作量越少,处理时间就越少。

虽然我认为启动时间至关重要,但不可避免地,你将以牺牲一些速度为代价来换取更好的开发者体验。尽管我强烈认为我们往往过于偏向开发者体验而不是用户体验

问题也出在框架加载**之后**我们做了什么。客户端水合(hydration)我认为被滥用了太多,有时完全没有必要。每次在 React 中水合一个组件时,你就是在向主线程抛出这些东西

A React stateful component hydration call stack captured in Chrome DevTools.

回想一下,在诺基亚 2 上,我测得的水合移动导航组件的**最短**时间约为 67 毫秒。在 Preact 中(你将在下面看到水合调用栈)大约需要 20 毫秒。

A Preact stateful component hydration call stack captured in Chrome DevTools.

这两个调用栈的比例尺并不相同,但 Preact 的水合逻辑更简单,可能是因为正如 Preact 的文档所说,“大多数差异比较被跳过了”。这里发生的事情要少得多。当你使用 `addEventListener` 而不是框架更接近底层时,你可以获得更快的速度。

事件监听器附加到 DOM 元素的调用栈。

并非所有情况都需要这种方法,但你会惊讶于你可以完成什么,当你的工具是addEventListenerquerySelectorclassListsetAttribute/getAttribute等等。

这些方法——以及许多类似的方法——正是框架本身所依赖的。诀窍在于评估哪些功能可以安全地在框架提供的功能之外交付,并在有意义时依赖框架。

React 触发点击事件处理程序以打开移动导航的调用栈。

如果这是例如在客户端请求 API 数据并在这种情况下管理 UI 的复杂状态的调用栈,我会认为这种成本更可以接受。然而,事实并非如此。我们只是在用户点击按钮时让导航出现在屏幕上。这就像用推土机来做铲子更适合的工作。

Preact 至少找到了中间地带

Preact 触发点击事件处理程序以打开移动导航的调用栈。

Preact 完成 React 所做的相同工作所需的时间大约是它的三分之一,但在该预算设备上,它经常超出帧预算。这意味着在某些设备上打开该导航会动画缓慢,因为布局和绘制工作可能没有足够的时间完成而不会进入长时间任务区域。

一个简单的事件监听器打开移动导航的调用栈。

在这种情况下,事件监听器正是我需要的。在该预算设备上,它的速度是 React 的七倍。

结论

这不是一篇针对 React 的攻击文章,而是一封呼吁大家思考我们工作方式的信。如果我们注意评估哪些工具适合这项工作,即使对于具有大量复杂交互性的应用程序,也可以避免其中一些性能陷阱。公平地说,这些陷阱可能存在于许多 VDOM 框架中,因为它们的本质增加了必要的开销来为我们管理各种事情。

即使你正在处理不需要 React 或 Preact 的项目,但想要利用组件化,可以考虑一开始就将其全部保留在服务器端。这种方法意味着你可以决定何时以及是否适合将功能扩展到客户端——以及**如何**这样做。

在我的 RSS 订阅应用程序中,我可以通过在该应用程序页面的入口点中放置轻量级的事件监听器代码,并使用资源清单来放置每个页面工作所需的最小脚本量来管理这一点。

现在假设你有一个真正需要 React 提供的功能的应用程序。你拥有具有大量状态的复杂交互。以下是一些可以尝试使事情运行得更快的方法。

  1. 检查所有有状态组件——即任何扩展 `React.Component` 的组件——并查看它们是否可以重构为无状态组件。如果组件不使用生命周期方法或状态,你可以将其重构为无状态组件。
  2. 然后,如果可能,避免向客户端发送这些无状态组件的 JavaScript,以及避免对其进行水合。如果一个组件是无状态的,只在服务器端渲染它。尽可能预渲染组件以最大程度地减少服务器响应时间,因为服务器端渲染也有其自身的性能陷阱
  3. 如果你有一个具有简单交互的有状态组件,可以考虑预渲染/服务器端渲染该组件,并将其交互性替换为独立于框架的事件监听器。这完全避免了水合,用户交互不必经过框架的状态管理逻辑。
  4. 如果必须在客户端水合有状态组件,请考虑延迟水合页面顶部附近的组件。一个交叉观察器触发回调非常适合此目的,并且会为页面上的关键组件提供更多主线程时间。
  5. 对于延迟水合的组件,评估是否可以在主线程空闲时间使用requestIdleCallback安排其水合。
  6. 如果可能,考虑从 React 切换到 Preact。鉴于它在客户端上的运行速度比 React 快得多,值得与你的团队讨论一下这是否可行。最新版本的 Preact 在大多数情况下与 React 几乎 1:1,并且 `preact/compat` 在简化此转换方面做得非常好。我不认为 Preact 是性能的灵丹妙药,但它可以让你更接近你需要达到的目标。
  7. 考虑将你的体验适应低设备内存的用户。navigator.deviceMemory(在 Chrome 和衍生浏览器中可用)使你能够为低内存设备上的用户更改用户体验。如果有人拥有这样的设备,那么它的处理器可能也不快。

无论你决定如何处理这些信息,我的论点的核心是:如果你使用 React 或任何 VDOM 库,都应该花一些时间调查它对一系列设备的影响。获得一台廉价的 Android 设备,看看你的应用程序使用起来感觉如何。将这种体验与你的高端设备进行对比。

最重要的是,如果结果是你的应用程序实际上排除了无法负担高端设备的部分受众,则不要遵循“最佳实践”。继续努力让一切变得更快。如果我们的日常工作有任何迹象,这是一项会让你忙碌一段时间的工作,但这没关系。使网络更快使网络在更多地方更易访问。使网络更易访问使网络更具**包容性**。这是我们都应该尽力去做的好事。


我要感谢Eric Bailey对本文提供的编辑反馈,以及 CSS-Tricks 团队愿意发布它。