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 的工程经理古川洋介表示:

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

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

在低端设备上显示无限旋转图标。

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

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

在启用了 CPU 和网络节流功能的 Chrome 开发者工具中分析性能之后,开发者很明显地发现需要进行性能优化。AirSHIFT 成立了一个专门小组来解决此问题。下面列出了他们为提高应用对用户输入的响应速度而重点关注的 5 个方面。

1. 虚拟化大型表

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

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

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

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

结果

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

Chrome 开发者工具性能面板记录的屏幕截图。
之前:在用户输入后编写脚本大约 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 团队还将延迟加载的组件中一些开销较高的业务逻辑迁移到了网络工作器。这通过释放主线程来解决用户输入卡顿问题,以便主线程专注于响应用户输入。

通常情况下,开发者在使用 worker 时会面临复杂性,但这次 Comlink 为他们做了繁重的工作。以下是 AirSHIFT 如何对其开销最高的一项操作进行工作器处理的伪代码:计算总人工成本。

在 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>
  )
}

在 Cost 组件中,使用 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);

结果

尽管他们在试用中将逻辑的工作化程度有限,但 AirSHIFT 还是将大约 100 毫秒的 JavaScript 从主线程移到了工作器线程(使用 4 倍 CPU 节流进行模拟)。

Chrome 开发者工具性能面板录制的屏幕截图,其中显示了脚本编写现在发生在 Web Worker,而不是主线程上。

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

4. 设置性能预算

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

  • 现在会测量每个 Redux 事件的脚本完成时间
  • 性能数据会收集到 Elasticsearch
  • 使用 Kibana 直观显示每个事件的第 10、25、50 和 75 个百分位的效果

AirSHIFT 现在正在监控偏移表加载事件,以确保其在 3 秒内为第 75 百分位的用户完成。目前,这是尚未强制执行的预算,但他们考虑在超出预算时通过 Elasticsearch 自动发送通知。

显示第 75 百分位大约在 2500 毫秒内完成的图表,第 50 百分位在大约 1250 毫秒内完成,第 25 百分位在大约 750 毫秒内完成,第 10 百分位在大约 500 毫秒内完成。
按百分位显示每日性能数据的 Kibana 信息中心。

结果

从上图可以看出,对于第 75 个百分位的用户,AirSHIFT 现在大多达到了 3 秒的预算,而对于第 25 个百分位的用户,其会在一秒内加载转换表。通过捕获各种条件和设备的 RUM 性能数据,AirSHIFT 现在可以检查新功能版本是否实际上会影响应用的性能。

5. 性能黑客马拉松

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

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

黑客马拉松的照片。

结果

黑客马拉松的做法非常适合他们。

  • 在黑客马拉松期间实际尝试多种方法并使用 Lighthouse 测量每种方法,可以轻松发现性能瓶颈。
  • 在黑客马拉松结束后,要说服团队在正式版发布时应优先考虑哪种优化,要比较容易。
  • 它也是倡导速度重要性的有效方式。每个参与者都可以了解您的编码方式与实现性能之间的关系。

Recruit 的许多其他工程团队都对这种实践方法感兴趣,而 AirSHIFT 团队现在正在公司内部推动了多项速度黑客马拉松,这也带来了很多好处。

摘要

对于 AirSHIFT 而言,这绝对不是进行这些优化的最简单过程,但绝对是回报的。现在,AirSHIFT 的加载时间中间值不到 1.5 秒,比项目之前的性能提高了 6 倍。

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

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