React SPA のパフォーマンス最適化に関する実際のケーススタディ。
ウェブサイトのパフォーマンスは、読み込み時間だけではありません。特に、ユーザーが日常的に使用する生産性向上デスクトップ アプリでは、高速でレスポンシブなエクスペリエンスをユーザーに提供することが重要です。Recruit Technologies のエンジニアリング チームは、リファクタリング プロジェクトを実施してウェブアプリの AirSHIFT を改善し、ユーザー入力のパフォーマンスを向上させました。方法は次のとおりです。
応答が遅い、生産性が低い
AirSHIFT は、レストランやカフェなどの店舗オーナーがスタッフのシフトを管理するのに役立つデスクトップ ウェブ アプリケーションです。React で構築されたこのシングルページ アプリケーションは、日、週、月などで整理されたシフト スケジュールのさまざまなグリッド表など、豊富なクライアント機能を備えています。
Recruit Technologies のエンジニアリング チームが AirSHIFT アプリに新機能を追加したところ、パフォーマンスの低下に関するフィードバックが増え始めました。AirSHIFT のエンジニアリング マネージャーである古川 洋介氏は次のように述べています。
ユーザー調査で、ある店舗オーナーが、シフト表の読み込みを待つ間、ボタンをクリックした後に席を離れてコーヒーを淹れると言っていたことに驚きました。
調査の結果、多くのユーザーが、10 年前の 1 GHz Celeron M ノートパソコンなど、低スペックのパソコンで巨大なシフトテーブルを読み込もうとしていることがわかりました。
AirSHIFT アプリは、高負荷なスクリプトでメインスレッドをブロックしていましたが、エンジニアリング チームは、高速な Wi-Fi 接続を備えた高スペックのコンピュータで開発とテストを行っていたため、スクリプトの負荷の高さに気づいていませんでした。

CPU とネットワーク スロットリングを有効にして Chrome DevTools でパフォーマンスをプロファイリングした結果、パフォーマンスの最適化が必要であることが明らかになりました。AirSHIFT は、この問題に対処するためにタスクフォースを結成しました。アプリをユーザー入力に迅速に応答させるために、以下の 5 つのことに重点を置きました。
1. 大規模なテーブルを仮想化する
シフト表を表示するには、仮想 DOM の作成と、スタッフの数と時間帯に比例した画面へのレンダリングという、複数の負荷の高いステップが必要でした。たとえば、レストランに 50 人の従業員がいて、月ごとのシフト表を確認すると、50(従業員)× 30(日)の表になり、レンダリングするセル コンポーネントは 1,500 個になります。これは、特に低スペックのデバイスで非常にコストの高いオペレーションです。実際は、状況はさらに悪化していました。調査の結果、200 人のスタッフを管理している店舗があり、1 か月の表に約 6,000 個のセルのコンポーネントが必要であることがわかりました。
このオペレーションの費用を削減するため、AirSHIFT はシフトテーブルを仮想化しました。アプリは、ビューポート内のコンポーネントのみをマウントし、画面外のコンポーネントをマウント解除するようになりました。


この場合、AirSHIFT は複雑な 2 次元グリッド テーブルを有効にするための要件があったため、react-virtualized を使用しました。また、今後は軽量の react-window を使用するように実装を変換する方法も検討しています。
結果
テーブルを仮想化しただけで、スクリプト実行時間が 6 秒短縮されました(CPU が 4 倍に減速し、高速 3G でスロットリングされた Macbook Pro 環境)。これは、リファクタリング プロジェクトで最も効果的なパフォーマンス改善でした。
![Chrome DevTools の [Performance] パネルの記録に注釈を付けたスクリーンショット。](https://web.dev/static/case-studies/five-ways-airshift-improved-their-react-app/image/an-annotated-screenshot-794941f94cea4.png?authuser=8&hl=ja)
![Chrome DevTools の [Performance] パネルの記録の別のアノテーション付きスクリーンショット。](https://web.dev/static/case-studies/five-ways-airshift-improved-their-react-app/image/another-annotated-screens-b3f5653187315.png?authuser=8&hl=ja)
2. User Timing API による監査
次に、AirSHIFT チームは、ユーザー入力で実行されるスクリプトをリファクタリングしました。Chrome DevTools の炎グラフを使用すると、メインスレッドで実際に何が起こっているかを分析できます。しかし、AirSHIFT チームは、React のライフサイクルに基づいてアプリケーション アクティビティを分析するほうが簡単だと判断しました。
React 16 は User Timing API を介してパフォーマンス トレースを提供します。これは、Chrome DevTools の [タイミング] セクションで可視化できます。AirSHIFT は、[タイミング] セクションを使用して、React ライフサイクル イベントで実行されている不要なロジックを見つけました。
![Chrome DevTools の [Performance] パネルの [Timings] セクション。](https://web.dev/static/case-studies/five-ways-airshift-improved-their-react-app/image/the-timings-section-the-4366edc5cfe5a.png?authuser=8&hl=ja)
結果
AirSHIFT チームは、ルート ナビゲーションの直前に不要な React ツリー調整が行われていることを発見しました。つまり、React はナビゲーションの前にシフトテーブルを不必要に更新していました。この問題は、不要な Redux 状態の更新が原因で発生していました。この問題を修正することで、スクリプト実行時間が約 750 ミリ秒短縮されました。AirSHIFT では、他のマイクロ最適化も行い、最終的にスクリプト作成時間が合計 1 秒短縮されました。
3. コンポーネントを遅延読み込みし、負荷の高いロジックを Web Worker に移動する
AirSHIFT にはチャット アプリケーションが組み込まれています。多くのショップオーナーは、シフト表を見ながらチャットでスタッフとやり取りしています。つまり、表の読み込み中にユーザーがメッセージを入力している可能性があります。メインスレッドがテーブルをレンダリングするスクリプトで占有されている場合、ユーザー入力がジャンクになる可能性があります。
このエクスペリエンスを改善するため、AirSHIFT では React.lazy と Suspense を使用して、実際のコンポーネントを遅延読み込みしながら、表コンテンツのプレースホルダを表示するようになりました。
AirSHIFT チームは、遅延読み込みコンポーネント内の費用のかかるビジネス ロジックの一部を ウェブワーカーに移行しました。これにより、メインスレッドを解放してユーザー入力への応答に集中できるようにすることで、ユーザー入力のジャンクの問題が解決しました。
通常、デベロッパーはワーカーの使用に複雑さを感じますが、今回は Comlink が面倒な作業を代行しました。以下は、AirSHIFT が最も費用のかかるオペレーションの 1 つである総労働費用の計算を自動化した方法の疑似コードです。
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 では約 100 ミリ秒の JavaScript をメインスレッドからワーカースレッドに移行しました(4 倍の CPU スロットリングでシミュレート)。
AirSHIFT では現在、他のコンポーネントを遅延読み込みし、より多くのロジックをウェブワーカーにオフロードして、ジャンクをさらに軽減できるかどうかを検討しています。
4. パフォーマンス バジェットの設定
こうした最適化をすべて実装した後、アプリのパフォーマンスが時間の経過とともに低下しないようにすることが重要でした。AirSHIFT で bundlesize が使用されるようになり、現在の JavaScript ファイルと CSS ファイルのサイズを超えないようになりました。これらの基本的な予算を設定するだけでなく、シフトテーブルの読み込み時間のさまざまなパーセンタイルが表示されるダッシュボードを構築し、理想的でない条件でもアプリケーションのパフォーマンスが良好かどうかを確認しました。
- すべての Redux イベントのスクリプト完了時間が測定されるようになりました
- パフォーマンス データは Elasticsearch で収集されます。
- 各イベントの 10 パーセンタイル、25 パーセンタイル、50 パーセンタイル、75 パーセンタイルのパフォーマンスが Kibana で可視化されます。
AirSHIFT は、シフト表の読み込みイベントをモニタリングし、75 パーセンタイル以上のユーザーに対して 3 秒以内に完了するようにしました。現時点では適用されていない予算ですが、予算超過時に Elasticsearch を介した自動通知を検討しています。

結果
上のグラフから、AirSHIFT が 75 パーセンタイルのユーザーのほとんどで 3 秒の予算を達成し、25 パーセンタイルのユーザーに対しては 1 秒以内にシフト表を読み込んでいることがわかります。さまざまな条件とデバイスから RUM パフォーマンス データをキャプチャすることで、AirSHIFT は、新機能のリリースが実際にアプリケーションのパフォーマンスに影響しているかどうかを確認できるようになりました。
5. パフォーマンス ハッカソン
これらのパフォーマンス最適化の取り組みはすべて重要で効果的でしたが、エンジニアリング チームとビジネス チームに非機能開発を優先させることは必ずしも簡単ではありません。課題の一部は、これらのパフォーマンスの最適化の一部を計画できないことです。テストと試行錯誤の精神が必要です。
AirSHIFT では、エンジニアがパフォーマンス関連の作業にのみ集中できるように、社内で 1 日間のパフォーマンス ハッカソンを実施しています。これらのハッカソンでは、すべての制約を取り除き、エンジニアの創造性を尊重します。つまり、スピードに貢献する実装はすべて検討に値します。ハッカソンを加速させるため、AirSHIFT ではグループを小規模なチームに分割し、各チームがLighthouse のパフォーマンス スコアを最も大きく改善できるチームを競い合います。チームは非常に競争的になります。🔥
結果
ハッカソン アプローチはうまく機能しています。
- パフォーマンスのボトルネックを見つけるには、ハッカソン中に複数のアプローチを実際に試し、Lighthouse でそれぞれを測定します。
- ハッカソンの後、本番環境リリースに向けて優先すべき最適化をチームに納得させることは比較的簡単です。
- また、スピードの重要性を訴求する効果的な方法でもあります。参加者は、コードの書き方とパフォーマンスの関係を理解できます。
良い副作用として、リクルート内の他の多くのエンジニアリング チームがこの実践的なアプローチに興味を持ち、AirSHIFT チームは現在、社内で複数のスピード ハッカソンを開催しています。
概要
AirSHIFT にとって、これらの最適化に取り組むのは決して簡単な道のりではありませんでしたが、その成果は確実にありました。現在、AirSHIFT はシフト表を中央値で 1.5 秒以内に読み込んでいます。これは、プロジェクト前のパフォーマンスから 6 倍の改善です。
パフォーマンスの最適化がリリースされた後、あるユーザーは次のように述べています。
シフト表の読み込みを高速化してくださり、ありがとうございます。 シフト勤務の調整が非常に効率的になりました。