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 连接的计算机上进行开发和测试。

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

在启用 CPU 和网络节流功能的 Chrome 开发者工具中分析其性能后,很明显需要优化性能。AirSHIFT 组建了一个任务组来处理此问题。他们重点从以下方面着手,提高应用对用户输入的响应速度。

1. 虚拟化大型表

显示排班表需要执行多个成本高昂的步骤:构建虚拟 DOM,并根据工作人员人数和时间空档将其渲染在屏幕上。例如,如果一家餐馆有 50 名在职员工,并想查看他们的每月轮班安排,那么表格的表格将是 50(成员)乘以 30(天数),得到的单元格组件数量为 1,500 个。这是一项开销非常大的操作,尤其是对于低规格设备。事实上,情况更糟糕了。从研究中他们了解到,有些商店管理着 200 名员工,需要在每个月的表格中收集大约 6,000 个单元格组件。

为了降低这项操作的成本,AirSHIFT 对轮班表进行了虚拟化。现在,应用只能在视口内装载组件,而卸载屏幕外组件。

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

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

成果

仅虚拟化表即可将脚本编写时间缩短 6 秒(在 4 倍 CPU 运行速度降低 + 快速 3G 限制 Macbook Pro 环境中)。这是重构项目中影响最大的性能提升。

Chrome 开发者工具“性能”面板记录的屏幕截图(带注释)。
之前:在用户输入内容后大约 10 秒编写脚本。
Chrome DevTools 的 Performance 面板录制的另一个带注释的屏幕截图。
之后:在用户输入内容后执行脚本 4 秒。

2. 使用 User Timing API 进行审核

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

React 16 通过 User Timing API 提供其性能跟踪记录,您可以在 Chrome 开发者工具的“计时”部分直观呈现该 API。AirSHIFT 使用“计时”部分查找在 React 生命周期事件中运行的不必要逻辑。

Chrome DevTools 的“Performance”面板的“Timings”部分。
React 的 User Timing 事件。

成果

AirSHIFT 团队发现在每次路线导航之前都会进行不必要的 React Tree 协调。这意味着 React 在导航之前不必要地更新了移位表。此问题是不必要的 Redux 状态更新。 修复此问题大约可节省 750 毫秒的脚本编写时间。AirSHIFT 还进行了其他微优化,最终使脚本编写时间总共缩短了 1 秒。

3. 延迟加载组件并将开销大的逻辑移至 Web 工作器

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

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

AirSHIFT 团队还将延迟加载的组件中一些开销很大的业务逻辑迁移到 Web 工作器。这通过释放主线程,使其能够专注于响应用户输入,解决了用户输入卡顿问题。

通常情况下,开发者会面临使用 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 时间从主线程移至工作器线程(模拟时将 CPU 节流限制在 4 倍),尽管他们以试验形式处理的逻辑数量有限。

Chrome DevTools 的 Performance 面板录制的屏幕截图,其中显示脚本现在是在网络工作线程(而不是主线程)上发生的。

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

4. 设置性能预算

实现所有这些优化后,确保应用在一段时间内保持高性能至关重要。AirSHIFT 现在使用 bundlesize 不超出当前的 JavaScript 和 CSS 文件大小。除了设置这些基本预算之外,他们还构建了一个信息中心来显示偏移表加载时间的各个百分位,以检查应用在非理想条件下的性能是否出色。

  • 现在,系统会衡量每个 Redux 事件的脚本完成时间
  • 性能数据在 Elasticsearch 中收集
  • 每个事件的第 10、25、50 和 75 百分位性能通过 Kibana 直观呈现

AirSHIFT 现在会监控偏移表加载事件,以确保第 75 百分位的用户在 3 秒内完成该操作。这笔预算目前并未强制执行,但他们正考虑在超出预算时通过 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 秒内加载偏移表(中间值平均数为 1.5 秒),比项目开始前的性能提升 6 倍。

在推出性能优化功能后,有一位用户表示:

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