AirSHIFT が React アプリのランタイム パフォーマンスを改善した 5 つの方法

React SPA のパフォーマンス最適化の実例。

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

ウェブサイトのパフォーマンスは、読み込み時間だけではありません。高速で応答性の高いエクスペリエンスを提供することは重要です。特に、ユーザーが毎日使用する生産性向上デスクトップ アプリにとっては重要です。Recruit Technologies のエンジニアリング チームは、ウェブアプリの一つである AirSHIFT を改良し、ユーザー入力のパフォーマンスを向上させるリファクタリング プロジェクトを実施しました。その方法を紹介します。

応答が遅く、生産性が低下する

AirSHIFT は、レストランやカフェなどの店舗オーナーがスタッフのシフト勤務を管理するためのデスクトップ ウェブ アプリケーションです。React で構築されたこのシングルページ アプリケーションは、日、週、月などで分類されたシフト スケジュールのさまざまなグリッド テーブルなど、豊富なクライアント機能を提供します。

AirSHIFT ウェブアプリのスクリーンショット。

Recruit Technologies のエンジニアリング チームが AirSHIFT アプリに新機能を追加するにつれ、パフォーマンスの低下に関するフィードバックが増加し始めました。AirSHIFT のエンジニアリング マネージャーである古川洋介氏は、次のように述べています。

ユーザー調査では、ある店舗のオーナーが、ボタンが押された後に席を離れ、コーヒーを淹れるために席を離れてシフト テーブルが読み込まれるのを待つ時間がないと述べたことに衝撃を受けました。

調査の結果、多くのユーザーが 10 年前に発売された 1 GHz の Celeron M ノートパソコンのような低スペックのパソコンで大量のシフト テーブルを読み込もうとしていることが判明しました。

ローエンド デバイスのエンドレス スピナー

AirSHIFT アプリは高価なスクリプトでメインスレッドをブロックしていましたが、エンジニアリング チームはスクリプトがどれほど高価であるかに気づいていませんでした。高速 Wi-Fi 接続を備えた豊富な仕様のパソコンで開発とテストを行っていたからです。

アプリのランタイム アクティビティを示すグラフ。
シフト テーブルの読み込み時に、読み込み時間の約 80% がスクリプトの実行に費やされていました。

CPU とネットワークのスロットリングを有効にして Chrome DevTools でパフォーマンスをプロファイリングしたところ、パフォーマンスの最適化が必要であることが明らかになりました。AirSHIFT はこの問題に対処するために作業部会を結成しました。ユーザー入力に対するアプリの応答性を高めるために同社が重点的に重視した 5 つの点を以下に示します。

1. 大きなテーブルの仮想化

シフトテーブルを表示するには、仮想 DOM を構築し、スタッフの数や時間枠の数に比例して画面にレンダリングするという、複数のコストのかかる手順が必要でした。たとえば、50 人の従業員が働いているレストランで、毎月のシフト スケジュールを確認する場合、50(メンバー)に 30(日)を掛けたテーブルになり、1,500 のセル コンポーネントがレンダリングされます。これは、特に低スペックのデバイスの場合、非常に負荷の高いオペレーションです。実際には、状況はさらに悪化しました。調査の結果、200 人のスタッフを管理する店舗があり、月 1 回のテーブルで約 6,000 のセル コンポーネントが必要であることがわかりました。

このオペレーションの費用を削減するために、AirSHIFT はシフトテーブルを仮想化しました。これで、アプリはビューポート内のコンポーネントのみをマウントし、画面外のコンポーネントのマウントを解除します。

AirSHIFT を使用してビューポートの外側のコンテンツをレンダリングしたことを示す注釈付きスクリーンショット。
変更前: シフトテーブルのすべてのセルをレンダリングする。
AirSHIFT がビューポートに表示されるコンテンツのみをレンダリングするようになったことを示す注釈付きスクリーンショット。
変更後: ビューポート内のセルのみをレンダリングします。

この場合、複雑な 2 次元のグリッドテーブルを実現するための要件があるため、AirSHIFT は react-仮想化を使用しました。また、将来的に軽量の react-window を使用するように実装を変換する方法も検討しています。

結果

テーブルを仮想化することで、スクリプト作成時間が 6 秒短縮されました(4 倍の CPU 速度と高速 3G スロットリング Macbook Pro 環境で)。これは、リファクタリング プロジェクトで最も大きなパフォーマンスの向上でした。

Chrome DevTools の [パフォーマンス] パネルの記録を示す注釈付きスクリーンショット。
変更前: ユーザー入力後 10 秒ほどのスクリプト。
Chrome DevTools の [パフォーマンス] パネルの記録を示す別の注釈付きスクリーンショット。
変更後: ユーザー入力の 4 秒後。

2. User Timing API による監査

次に、AirSHIFT チームは、ユーザー入力に対して実行されるスクリプトをリファクタリングしました。Chrome DevToolsフレームチャートを使用すると、メインスレッドで実際に何が起こっているかを分析できます。しかし、AirSHIFT チームは、React のライフサイクルに基づくアプリケーション アクティビティの分析が容易であることに気づきました。

React 16 では、User Timing API を介してパフォーマンス トレースが提供されます。これは、Chrome DevTools の [Timings] セクションから可視化できます。AirSHIFT は、タイミング セクションを使用して、React のライフサイクル イベントで実行されている不要なロジックを見つけました。

Chrome DevTools の [パフォーマンス] パネルの [速度] セクション
React のカスタム速度イベント。

結果

AirSHIFT チームは、すべてのルート ナビゲーションの直前に、不要な React Tree Reconciliation が発生していることを発見しました。これは、React がナビゲーションの前に不必要にシフト テーブルを更新していたことを意味します。この問題は、不要な Redux 状態の更新が原因で発生していました。この問題を修正した結果、スクリプト作成の時間が約 750 ミリ秒短縮されました。AirSHIFT は他のマイクロ最適化も実施したことで、最終的にスクリプティング時間が合計 1 秒短縮されました。

3.コンポーネントを遅延読み込みし、高コストのロジックをウェブワーカーに移行する

AirSHIFT にはチャットアプリが組み込まれています。多くの店舗オーナーは、シフトテーブルを見ながらチャットでスタッフとやり取りしているため、テーブルの読み込み中にユーザーがメッセージを入力している可能性があります。メインスレッドがテーブルをレンダリングするスクリプトで占有されている場合、ユーザー入力がジャンクになる可能性があります。

このエクスペリエンスを改善するために、AirSHIFT は React.lazy と Suspense を使用して、実際のコンポーネントを遅延読み込みしながらテーブル コンテンツのプレースホルダを表示するようになりました。

また、AirSHIFT チームは、遅延読み込みコンポーネント内の高価なビジネス ロジックの一部をウェブワーカーに移行しました。これにより、メインスレッドが解放され、ユーザー入力への応答に集中できるようになったため、ユーザー入力ジャンクの問題が解決されました。

通常、ワーカーの使用は複雑ですが、手間のかかる作業は 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>
  )
}

費用コンポーネントで、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>;
}

ワーカーで実行される計算ロジックを実装し、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 は JavaScript の約 100 ミリ秒をメインスレッドからワーカー スレッドに移行しました(4 倍の CPU スロットリングでシミュレート)。

スクリプトがメインスレッドではなくウェブワーカーで行われるようになったことを示す、Chrome DevTools の [パフォーマンス] パネルの記録のスクリーンショット。

AirSHIFT は現在、ジャンクをさらに削減するために、他のコンポーネントを遅延読み込みし、ウェブワーカーにより多くのロジックをオフロードできるかどうかを検討しています。

4. パフォーマンス バジェットの設定

これらの最適化をすべて実装するにあたり、時間が経過してもアプリのパフォーマンスを維持することが重要でした。AirSHIFT は、現在の JavaScript と CSS のファイルサイズを超えないように、bundlesize を使用するようになりました。これらの基本的な予算を設定するだけでなく、シフトテーブルの読み込み時間のさまざまなパーセンタイルを表示して、理想的ではない状況でもアプリケーションのパフォーマンスを確認するダッシュボードを構築しました。

  • すべての Redux イベントのスクリプト完了時間が測定されるようになりました
  • パフォーマンス データは Elasticsearch で収集されます。
  • Kibana を使用して、各イベントの 10 パーセンタイル、25 パーセンタイル、50 パーセンタイル、75 パーセンタイルのパフォーマンスを可視化する

AirSHIFT がシフトテーブルの読み込みイベントをモニタリングし、75 パーセンタイルのユーザーで 3 秒以内に完了していることを確認します。これは今のところ未適用の予算ですが、予算を超過したときに Elasticsearch を介した自動通知を検討しています。

75 パーセンタイルが約 2,500 ミリ秒で完了し、50 パーセンタイルが約 1,250 ミリ秒、25 パーセンタイルが約 750 ミリ秒、10 パーセンタイルが約 500 ミリ秒で完了することを示すグラフ。
1 日のパフォーマンス データをパーセンタイルで表示する Kibana ダッシュボード。

結果

上のグラフを見ると、AirSHIFT は 75 パーセンタイルのユーザーでほぼ 3 秒の予算に達し、25 パーセンタイルのユーザーでは 1 秒以内にシフトテーブルがロードされていることがわかります。AirSHIFT は、さまざまな条件やデバイスから RUM のパフォーマンス データをキャプチャすることで、新機能のリリースがアプリのパフォーマンスに実際に影響しているかどうかをチェックできるようになりました。

5. パフォーマンス ハッカソン

こうしたパフォーマンス最適化の取り組みはすべて重要かつ影響力のあるものでしたが、エンジニアリング チームとビジネスチームが機能以外の開発を優先させるのは必ずしも容易ではありません。課題の一つは、これらのパフォーマンス最適化の一部は計画できないことです。実験と試行錯誤の考え方が必要です。

AirSHIFT は現在、エンジニアがパフォーマンス関連の作業に集中できるように、社内で 1 日間のパフォーマンス ハッカソンを実施しています。これらのハッカソンでは、すべての制約を取り除き、エンジニアの創造性を尊重します。つまり、スピードに貢献する実装はすべて検討する価値があります。ハッカソンを加速するため、AirSHIFT はグループを少人数のチームに分割し、各チームが Lighthouse のパフォーマンス スコアを最も改善できるチームを競います。 チーム同士の競争も激しくなる!🔥

ハッカソンの写真。

結果

ハッカソンのアプローチは彼らにとってうまく機能しています。

  • パフォーマンスのボトルネックは、ハッカソン中に実際に複数のアプローチを試し、Lighthouse を使用してそれぞれのアプローチを測定することで、簡単に検出できます。
  • ハッカソンの後は、製品版リリースでどの最適化を優先すべきかをチームに納得させるのは簡単です。
  • これは、スピードの重要性を伝える効果的な方法でもあります。すべての参加者が、コーディングの仕方とそれがパフォーマンスにどう結びついているかを理解できます。

良い副次的な副作用として、Recruit 内の他の多くのエンジニアリング チームがこの実践的なアプローチに興味を持ち、AirSHIFT チームは現在、社内で複数のスピード ハッカソンを主催しています。

まとめ

AirSHIFT がこれらの最適化を行うのは、決して簡単な作業ではありませんでしたが、確実にうまくいきました。現在、AirSHIFT はシフトテーブルを中央値で 1.5 秒以内に読み込みました。これは、プロジェクト実施前のパフォーマンスから 6 倍の改善です。

パフォーマンスの最適化のリリース後、あるユーザーは次のように述べています。

シフト テーブルの読み込みを高速化していただき、ありがとうございました。 シフト勤務の手配がはるかに効率的になりました。