AirSHIFT 通过五种方式提升其 React 应用运行时性能

一个 React SPA 性能优化的真实案例。

Kento Tsuji
Kento Tsuji
Satoshi Arai
Satoshi Arai
Yusuke Utsunomiya
Yusuke Utsunomiya
Yosuke Furukawa
Yosuke Furukawa

网站性能不仅仅是加载时间。为用户提供快速且响应及时的体验至关重要,尤其是对于用户每天都在使用的效率类桌面应用。Recruit Technologies 的工程团队完成了一个重构项目,以改进其一款 Web 应用 AirSHIFT,从而提升用户输入性能。具体做法如下。

响应缓慢,工作效率低下

AirSHIFT 是一款桌面版 Web 应用,可帮助餐厅和咖啡馆等商店所有者管理员工的轮班工作。此单页应用采用 React 构建,提供丰富的客户端功能,包括按天、周、月等方式整理的各种排班表格。

AirSHIFT Web 应用的屏幕截图。

随着 Recruit Technologies 工程团队向 AirSHIFT 应用添加新功能,他们开始收到更多有关性能缓慢的反馈。AirSHIFT 的工程经理 Yosuke Furukawa 表示:

在一次用户研究中,我们发现一位商店老板说,她会在点击某个按钮后离开座位去冲泡咖啡,以便打发等待班次表格加载的时间,这让我们感到非常震惊。

经过研究,工程团队发现,许多用户都尝试在配置较低的计算机(例如 10 年前的 1 GHz Celeron M 笔记本电脑)上加载庞大的移位表。

低端设备上的无限旋转图标。

AirSHIFT 应用使用耗时的脚本阻塞了主线程,但工程团队并未意识到这些脚本的开销有多大,因为他们是在配置丰富且 Wi-Fi 连接速度快的电脑上进行开发和测试的。

显示应用运行时活动的图表。
加载班次表时,运行脚本占用了约 80% 的加载时间。

在 Chrome DevTools 中启用 CPU 和网络节流功能对其性能进行性能分析后,他们清楚地认识到需要进行性能优化。AirSHIFT 成立了一个专门小组来解决此问题。下面列出了他们为提高应用对用户输入的响应速度而重点关注的 5 个方面。

1. 虚拟化大型表

显示班次表需要执行多个耗费资源的步骤:构建虚拟 DOM 并根据员工人数和时间空档数量将其渲染到屏幕上。例如,如果某家餐厅有 50 名员工,并且想要查看他们的每月轮班表,则需要创建一个表格,其中 50 名员工乘以 30 天,即需要呈现 1,500 个单元格组件。这是一个非常耗时的操作,对于低规格设备来说尤其如此。事实证明,情况更糟糕。通过研究,他们了解到,有些商店管理着 200 名员工,因此单个月度表格中需要大约 6,000 个单元格组件。

为了降低此操作的费用,AirSHIFT 虚拟化了轮班表。该应用现在仅会挂载视口内的组件并卸载屏幕外组件。

带注释的屏幕截图,演示了 AirSHIFT 用于在视口之外呈现内容。
之前:渲染所有轮班表格单元格。
带注释的屏幕截图,显示 AirSHIFT 现在仅渲染视口中可见的内容。
后:仅在视口内呈现单元格。

在本例中,AirSHIFT 使用了 react-virtualized,因为需要启用复杂的二维网格表格。他们还在探索如何将实现转换为使用轻量级 react-window

结果

仅仅虚拟化表就缩短了脚本化时间 6 秒(在 CPU 降速 4 倍 + 快速 3G 节流 Macbook Pro 环境中)。这是重构项目中效果最显著的性能改进。

Chrome DevTools 性能面板记录的带注释屏幕截图。
之前:用户输入后大约需要 10 秒的时间来执行脚本。
另一张带注释的 Chrome DevTools 性能面板记录屏幕截图。
后:在用户输入后,脚本执行 4 秒。

2. 使用 User Timing API 进行审核

接下来,AirSHIFT 团队重构了根据用户输入运行的脚本。借助 Chrome 开发者工具火焰图,您可以分析主线程中实际发生的情况。但 AirSHIFT 团队发现,根据 React 的生命周期分析应用活动更容易。

React 16 通过 User Timing API 提供性能轨迹,您可以在 Chrome DevTools 的 Timings 部分直观地查看这些轨迹。AirSHIFT 使用“Timings”(时间)部分查找在 React 生命周期事件中运行的不需要的逻辑。

Chrome DevTools 的“Performance”面板中的“Timings”部分。
React 的用户时间事件。

结果

AirSHIFT 团队发现,在每次路线导航之前都会发生不必要的 React 树一致性检查。这意味着,React 在导航之前会不必要地更新 shift 表。不必要的 Redux 状态更新导致了这个问题。 修复此问题后,脚本运行时间缩短了约 750 毫秒。AirSHIFT 还进行了其他微优化,最终使脚本编写时间总共缩短了 1 秒。

3. 延迟加载组件并将耗费大量资源的逻辑移至 Web Worker

AirSHIFT 内置了聊天应用。许多商店所有者会在查看班次表时通过聊天功能与员工沟通,这意味着用户可能会在表格加载时输入消息。如果主线程被用于渲染表格的脚本占用,用户输入可能会卡顿。

为了改进此体验,AirSHIFT 现在使用 React.lazy 和 Suspense 显示表格内容的占位符,同时延迟加载实际组件。

AirSHIFT 团队还将延迟加载的组件中一些开销较高的业务逻辑迁移到了网络工作器。这通过释放主线程来解决用户输入卡顿问题,以便主线程专注于响应用户输入。

通常,开发者在使用工作器时会遇到复杂问题,但这一次 Comlink 为他们解决了这些问题。以下是 AirSHIFT 将其最耗费资源的操作之一(计算总工资费用)转换为 Worker 操作的伪代码。

在 App.js 中,使用 React.lazy 和 Suspense 在加载时显示回退内容

/** App.js */
import React, { lazy, Suspense } from 'react'

// Lazily loading the Cost component with React.lazy
const Hello = lazy(() => import('./Cost'))

const Loading = () => (
  <div>Some fallback content to show while loading</div>
)

// Showing the fallback content while loading the Cost component by Suspense
export default function App({ userInfo }) {
   return (
    <div>
      <Suspense fallback={<Loading />}>
        <Cost />
      </Suspense>
    </div>
  )
}

在“费用”组件中,使用 comlink 执行计算逻辑

/** Cost.js */
import React from 'react';
import { proxy } from 'comlink';

// import the workerlized calc function with comlink
const WorkerlizedCostCalc = proxy(new Worker('./WorkerlizedCostCalc.js'));
export default async function Cost({ userInfo }) {
  // execute the calculation in the worker
  const instance = await new WorkerlizedCostCalc();
  const cost = await instance.calc(userInfo);
  return <p>{cost}</p>;
}

实现在 worker 中运行的计算逻辑,并使用 comlink 公开该逻辑

// WorkerlizedCostCalc.js
import { expose } from 'comlink'
import { someExpensiveCalculation } from './CostCalc.js'

// Expose the new workerlized calc function with comlink
expose({
  calc(userInfo) {
    // run existing (expensive) function in the worker
    return someExpensiveCalculation(userInfo);
  }
}, self);

结果

尽管他们在试用中仅将一小部分逻辑进行了 worker 化处理,但 AirSHIFT 还是将大约 100 毫秒的 JavaScript 从主线程移到了 worker 线程(使用 4 倍 CPU 节流进行了模拟)。

Chrome DevTools 性能面板记录的屏幕截图,显示脚本现在是在 Web Worker 上(而非主线程)运行。

AirSHIFT 目前正在探索是否可以延迟加载其他组件,并将更多逻辑分流到 Web 工作器,以进一步减少卡顿。

4. 设置效果预算

实施所有这些优化后,确保应用长期保持良好性能至关重要。AirSHIFT 现在使用 bundlesize 来确保不超过当前的 JavaScript 和 CSS 文件大小。除了设置这些基本预算之外,他们还构建了一个信息中心,用于显示班次表加载时间的各种百分位数,以检查应用在非理想情况下是否仍能提供出色的性能。

  • 现在,系统会衡量每个 Redux 事件的脚本完成时间
  • 性能数据会收集到 Elasticsearch
  • 使用 Kibana 可视化每个事件的第 10、25、50 和 75 百分位性能

AirSHIFT 现在会监控班次表格加载事件,以确保其在 75 百分位数用户的设备上在 3 秒内完成。目前,此预算尚未强制执行,但他们正在考虑在超出预算时通过 Elasticsearch 发送自动通知。

一张图表,显示第 75 百分位完成时间约为 2500 毫秒、第 50 百分位完成时间约为 1250 毫秒、第 25 百分位完成时间约为 750 毫秒,第 10 百分位完成时间约为 500 毫秒。
按百分位显示每日效果数据的 Kibana 信息中心。

结果

从上图可以看出,AirSHIFT 现在在满足第 75 个百分位数用户的 3 秒预算,同时也能让第 25 个百分位数用户在 1 秒内加载班次表。通过捕获各种条件和设备的 RUM 性能数据,AirSHIFT 现在可以检查新功能版本是否实际上会影响应用的性能。

5. 性能黑客马拉松

尽管所有这些性能优化工作都很重要且有影响,但要让工程团队和业务团队优先考虑非功能开发工作并不总是容易。其中一个难点是,其中一些性能优化无法事先规划。需要进行实验并采用试错思维。

AirSHIFT 目前正在举办为期一天的内部性能黑客马拉松,让工程师专注于性能相关工作。在这些黑客马拉松中,他们会消除所有限制,并尊重工程师的创造力,这意味着任何有助于提高速度的实现都值得考虑。为了加快黑客马拉松的进度,AirSHIFT 会将团队分成小团队,各个团队之间展开竞争,看谁能取得最大的 Lighthouse 性能得分提升。 各个团队的竞争非常激烈!🔥?

黑客马拉松的照片。

结果

黑客马拉松方法对他们来说非常有效。

  • 在黑客马拉松期间实际尝试多种方法并使用 Lighthouse 衡量每种方法,即可轻松发现性能瓶颈。
  • 在黑客马拉松结束后,我们很容易就能说服团队优先考虑哪些优化措施,以便顺利发布正式版。
  • 这也是宣传速度重要性的一种有效方式。每位学员都能了解编码方式与性能之间的相关性。

一个好处是,Recruit 内的许多其他工程团队也对这种实践方法产生了兴趣,AirSHIFT 团队目前正在公司内部举办多场速度黑客马拉松。

摘要

对 AirSHIFT 来说,进行这些优化绝非易事,但成效显著。现在,AirSHIFT 平均需要 1.5 秒即可加载班次表,与该项目实施前的性能相比,速度提高了 6 倍。

性能优化发布后,一位用户表示:

非常感谢您让班次表快速加载。 现在,安排轮班工作效率更高了。