RadEventListener:客户端框架性能

2020-08-26 14:09:06

“反应”很受欢迎,足够受欢迎,因此受到了公平的批评。然而,这种对Reaction的批评并不是完全没有根据的:Reaction和ReactDOM总共大约120 KiB的缩小JavaScript,这肯定会导致启动时间变慢。如果完全依赖于Reaction中的客户端呈现,则会造成混乱。即使您在服务器上呈现组件,并在客户机上对其加水,它仍然会搅动,因为组件加水的计算代价很高。

当涉及到需要复杂状态管理的应用程序时,Reaction当然有它的位置,但是根据我的专业经验,它不属于我看到使用的大多数场景。当在慢速和快速设备上哪怕是一点点反应都可能成为问题时,使用它是一种刻意的选择,有效地将拥有低端硬件的人拒之门外。

如果这听起来像是我对Reaction怀恨在心,那么我必须承认我真的很喜欢它的组件化模型。它使组织代码变得更容易。我认为JSX很棒。服务器渲染也很酷--尽管这就是我们现在所说的“通过网络发送HTML”。

不过,尽管我很乐意在服务器上使用Reaction组件(或Preact,这是我的偏好),但要弄清楚何时适合在客户机上使用它还是有些困难。以下是我对Reaction性能的研究结果,因为我试图以最适合用户的方式迎接这一挑战。

最近,我一直在逐步开发一个名为bylines.fyi的RSS提要应用附带项目。这个应用在后端和前端都使用JavaScript。我不认为客户端框架是可怕的东西,但是关于我在日常工作和研究中经常遇到的客户端框架实现,我经常观察到两件事:

框架有可能抑制对它们抽象的事物(即Web平台)的更深层次的理解。如果不知道框架所依赖的至少一些较低级别的API,我们就无法知道哪些项目从框架中受益,哪些项目没有框架会更好。

你也许可以论证我第一点的正确性,但第二点正变得越来越难以反驳。你可能还记得不久前Tim Kadlec在HTTPArchive上做了一些关于web框架性能的研究,得出的结论是Reaction并不是一个出色的表现。

尽管如此,我还是想看看是否有可能在减轻其对客户端的不良影响的同时,在服务器上使用我认为最好的反应。对我来说,同时希望使用一个框架来帮助组织我的代码,但也限制了该框架对用户体验的负面影响是有意义的。这需要进行一些试验,看看哪种方法最适合我的应用程序。

我确保在服务器上呈现我使用的每个组件,因为我认为提供标记的负担应该由Web应用程序的服务器承担,而不是由用户的设备承担。然而,我需要在我的RSS提要应用程序中添加一些JavaScript,才能让可切换的移动导航正常工作。

这个场景恰如其分地描述了我所说的简单状态。根据我的经验,简单状态的一个主要例子是A到B的线性相互作用。我们打开一件东西,然后把它关掉。很有说服力,但很简单。

不幸的是,我经常看到有状态反应组件用于管理简单状态,这是一种对性能有问题的权衡。虽然目前这可能是一句含糊其辞的话,但当你继续阅读时,你会发现这一点的。也就是说,需要强调的是,这只是一个微不足道的例子,但它也是一只金丝雀。大多数开发人员--我希望--不会仅仅为了他们网站上的一件事而仅仅依靠Reaction来驱动如此简单的行为。因此,了解您将要看到的结果旨在告知您如何构建应用程序,以及框架选择对运行时性能的影响如何扩展,这一点非常重要。

我的RSS提要应用程序还在开发中。它不包含第三方代码,便于在安静的环境中进行测试。我进行的实验比较了三种实现的移动导航切换行为:

未加水的服务器呈现的无状态Preact组件。取而代之的是,常规的OL事件监听器在客户机上提供移动NAV功能。

我相信这一系列的移动硬件将在广泛的设备性能上提供例证,即使它在苹果方面略显沉重。

启动时间。对于React和Preact,这包括加载框架代码所花费的时间以及客户端上的组件水合时间。对于事件侦听器方案,这只包括事件侦听器代码本身。

补水时间。对于Reaction和Preact方案,这是启动时间的子集。由于MacOS上Safari的远程调试崩溃的问题,我无法单独在iOS设备上测量补水时间。事件侦听器实现产生的水合成本为零。

移动导航开放时间。这让我们深入了解框架在其事件处理程序抽象中引入了多少开销,以及这与无框架方法相比有何不同。

移动导航关闭时间。事实证明,这比打开菜单的成本要低得多。我最终决定不在本文中包含这些数字。

应该注意的是,对这些行为的测量仅包括脚本编写时间。任何布局、油漆和合成成本都将是这些测量之外的额外费用。应该注意记住,这些活动与触发它们的脚本一起竞争主线程时间。

要在每台设备上测试三个移动NAV实现中的每一个,我遵循以下步骤:

在诺基亚2上,我在MacOS上使用Chrome进行远程调试。在iPhone上,我使用Safari的远程调试功能。

我在每台设备上访问了在我的本地网络上运行的RSS feed应用程序,进入了可以运行移动导航切换代码的同一页面。正因为如此,网络性能不是我测量的一个因素。

在没有应用CPU或网络节流的情况下,我开始在分析器中进行记录,并重新加载页面。

我停止了分析器,并记录了前面列出的四种行为中每种行为涉及的CPU时间。

我清理了演出时间表。在Chrome中,我还点击了垃圾收集按钮,以释放之前会话记录中我的应用程序代码可能占用的所有内存。

对于每个设备的每个场景,我都重复了这个过程十次。十次迭代似乎只获得了足够的数据来查看几个离群值,同时获得了相当准确的图像,但是我会让您在我们检查结果的时候做出决定。如果您不想详细了解我的发现,您可以在此电子表格中查看结果并得出您自己的结论,以及每个实现的移动导航代码。

我最初想用图表表示这些信息,但是由于我测量的内容很复杂,我不确定如何在不影响可视化效果的情况下显示结果。因此,我将在一系列表中显示最小、最大、中值和平均CPU时间,所有这些表都有效地说明了我在每次测试中遇到的结果范围。

诺基亚2是一款搭载ARM Cortex-A7处理器的低成本安卓设备。它不是一台动力十足的设备,而是一种便宜且容易买到的设备。目前安卓在全球的使用率约为40%,虽然不同设备的安卓设备规格差别很大,但低端安卓设备并不少见。这是一个我们必须认识到的问题,因为它既是财富问题,也是快速网络基础设施近在咫尺的问题。

我相信它说的是,平均需要160毫秒以上的时间来解析和编译反应,并使一个组件水合。提醒您一下,本例中的启动成本包括浏览器评估移动导航运行所需脚本所需的时间。对于Reaction和Preact,它还包括水合时间,在这两种情况下,这都会导致我们在启动过程中有时会体验到离奇的山谷效果。

Preact的表现要好得多,比React节省了大约73%的时间,考虑到Preact在10KiB没有压缩的情况下是多么微小,这是有意义的。不过,值得注意的是,Chrome的帧预算约为10ms,以避免60fps的抖动。简陋的启动和简陋的任何其他东西一样糟糕,并且是计算第一次输入延迟时的一个因素。不过,综合考虑,Preact的表现相对较好。

至于addEventListener实现,事实证明,没有开销的小脚本的解析和编译时间非常低,这并不令人惊讶。即使在采样的最大时间为12ms时,您也只能勉强位于扬克斯堡大都市区的外环。现在让我们单独来看一下水合成本。

对于反应,这仍然在易克斯峰附近。当然,一个组件的中位数70ms的水合时间不是什么大事,但想想当你在同一页面上有一大堆组件时,水合成本是如何增长的。我在这款设备上测试的Reaction网站感觉更像是耐力测试,而不是用户体验,这一点也就不足为奇了。

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

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

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

关闭移动导航所需的CPU时间要少得多,Reaction的平均时间约为16.5ms,Preact和Bare Event侦听器的平均进入时间分别约为11ms和6ms。我会把关闭移动导航的测量结果的完整表格贴出来,但我们还有很多东西要筛选。此外,你可以自己在我前面提到的电子表格中查看这些数字。

在讨论iOS结果之前,我想要解决的一个潜在症结是在远程设备上记录会话时禁用Chrome DevTools中的JavaScript示例的影响。在编译了我的初始结果之后,我想知道捕获整个调用堆栈的开销是否会影响我的结果,所以我重新测试了禁用的Reaction场景样本。事实证明,这种设置对结果没有显著影响。

此外,由于调用堆栈被截断,我无法测量组件水合时间。禁用采样与启用采样的平均启动成本分别为160.74毫秒和162.73毫秒。中位数分别为157.81毫秒和147.76毫秒。我会直截了当地认为这是“在喧嚣中”。

最初的iPhone SE是一款很棒的手机。尽管它年代久远,但由于其更舒适的体型,它仍然享有专心致志的所有权。它出厂时配备了苹果A9处理器,该处理器仍然是一个强有力的竞争者。让我们看看它在启动时的表现如何。

与诺基亚2相比,这是一个很大的改进,也说明了低端Android设备与更老、续航里程很长的苹果设备之间的鸿沟。

Reaction的性能仍然不是很好,但是Preact让我们在Chrome的典型框架预算内。当然,只有事件收听者速度惊人,在框架预算中为其他活动留出了足够的空间。

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

React在这里做得很好,但是Preact似乎更有效地处理事件侦听器。即使是在这部旧iPhone上,赤裸裸的事件监听器也是闪电般的快。

2020年年中,我拿起了新的iPhone SE。它的外形尺寸与iPhone8和类似的手机相同,但处理器与iPhone11中使用的Apple A13相同。由于其相对较低的400美元零售价,这一速度非常快。考虑到如此强大的加工机,它是如何处理的呢?

我想在某种程度上,当涉及到加载单个框架和合并一个组件的相对较小的工作量时,回报会递减。在某些情况下,第二代iPhoneSE的速度比第一代机型要快一些,但也不是很快。我可以想象,这款手机将比它的前身更好地处理更大、更持久的工作负荷。

这里的反应性能稍好一些,但其他的也不多。奇怪的是,Preact在这款设备上打开移动导航的平均时间似乎比第一代要长,但我会将其归因于偏向相对较小的数据集的异常值。我当然不会假设第一代iPhone SE是基于这一点的速度更快的设备。

诚然,这些都是我最兴奋看到的结果:一台搭载Windows10的2013年华硕笔记本电脑和一台当时的常春藤桥i5笔记本电脑是如何处理这些事情的?

当你考虑到这款设备已经有七年的历史时,这些数字还不错。Ivy Bridge i5在当时是一个很好的处理器,当你把这一点与它主动冷却(而不是像移动设备处理器那样被动冷却)的事实结合在一起时,它可能不会像移动设备那样经常遇到散热节流的情况。

Preact在这方面做得很好,并且设法保持在Chrome的框架预算之内,而且速度几乎是Reaction的三倍。如果您在启动时对页面上的10个组件加水,甚至可能在Preact中,情况看起来可能会有很大不同。

当谈到这种孤立的交互时,我们看到的性能与高端移动设备相似。看到这样一台旧笔记本电脑仍然保持相当好的状态,这是令人鼓舞的。这就是说,这款笔记本电脑的风扇在浏览网页时经常旋转,所以主动散热可能是这款设备的救命稻草。如果这款设备的i5被被动冷却,我怀疑它的性能可能会下降。

与完全避开框架的解决方案相比,为什么Reaction和Preact启动所需的时间更长,这一点并不神秘。工作量越少,处理时间就越短。

虽然我认为启动时间很关键,但您可能不可避免地会牺牲一些速度来换取更好的开发体验。不过,我极力主张,我们往往过于注重开发人员体验,而不是用户体验。

龙还在于我们在框架加载后所做的事情。我认为客户端的水合作用经常被滥用,有时甚至完全没有必要。每次在Reaction中为组件加水时,这就是您在主线程上抛出的内容:

回想一下,在诺基亚2上,我测量的移动导航组件加水的最短时间约为67ms。在Preact中-您将在下面看到水合调用堆栈-大约需要20ms。

这两个调用堆栈的规模不同,但Preact的水合逻辑得到了简化,可能是因为正如Preact的文档所述,“跳过了最大的差异”。这里发生的事情要少得多。当您通过使用addEventListener而不是框架更接近金属时,您可以获得更快的速度。

并不是每种情况都需要这种方法,但是当您的工具是addEventListener、querySelector、classList、setAttribute/getAttribute等时,您会惊讶于所能完成的工作。

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

比方说,如果这是一个调用堆栈,用于在客户机上请求API数据并在那种情况下管理UI的复杂状态,我会发现这一成本更容易接受。然而,事实并非如此。当用户轻触按钮时,我们只是在屏幕上显示一个NAV。这就像使用推土机,而铲子更适合这项工作。

Preact需要大约三分之一的时间来完成Reaction所做的相同工作,但在该预算设备上,它经常超过框架预算。这意味着在某些设备上打开导航会很慢,因为布局和油漆工作可能没有足够的时间来完成,除非进入长时间的任务区域。

在本例中,我需要一个事件侦听器。它在预算设备上完成工作的速度是Reaction的7倍。

这不是一篇反应激烈的文章,而是一篇恳请我们考虑如何工作的恳求。如果我们仔细评估哪些工具对这项工作有意义,其中一些性能陷阱是可以避免的,即使是对于具有大量复杂交互性的应用程序也是如此。公平地说,这些缺陷可能存在于许多VDOM框架中,因为它们的性质增加了为我们管理各种事情所需的开销。

即使您正在做一些不需要Reaction或Preact的工作,但是您想要利用组件化的优势,也可以考虑从一开始就将其全部保留在服务器上。这种方法意味着您可以决定是否以及何时适合将功能扩展到客户端-以及您将如何做到这一点。

对于我的RSS提要应用程序,我可以通过将轻量级事件侦听器代码放在该应用程序页面的入口点,并使用资产清单来放置每个页面工作所需的最少量脚本来实现这一点。

现在让我们假设您有一个真正需要Reaction提供的功能的应用程序。你与许多状态有复杂的交互性。这里有一些你可以做的事情,可以试着让事情进行得更快一些。

检查所有有状态组件-即任何扩展React.Component的组件-并查看是否可以将它们重构为无状态组件。如果组件不使用生命周期方法或状态,您可以将其重构为无状态。

然后,如果可能,避免将JavaScript发送到客户端以获取这些无状态组件,并将它们加水。如果组件是无状态的,则只在服务器上呈现它。组件,以尽可能减少服务器响应时间,因为服务器呈现有其自身的性能陷阱。

如果您有一个具有简单交互性的有状态组件,请考虑预先呈现/服务器呈现该组件,并将其交互性替换为独立于框架的事件侦听器。这完全避免了水合作用,并且用户交互将不必通过框架的状态管理逻辑进行过滤。

如果您必须对客户端上的有状态组件进行消隐,请考虑懒惰地消隐不在页面顶部附近的组件。触发回调的交叉点观察器对此非常有效,它将为页面上的关键组件提供更多的主线程时间。

对于延迟补水的组件,评估是否可以使用requestIdleCallback在主线程空闲时间安排它们的补水。

如果可能,考虑从Reaction切换到Preact。考虑到它的运行速度比在客户机上的反应快得多,有必要与您的团队进行讨论,看看这是否可能。Preact的最新版本与Reaction几乎是1:1的比例,而Preact/compat在简化这一过渡方面做得很好。我不认为Preact是一个平底锅。

.