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 はシフトテーブルを仮想化しました。アプリは、ビューポート内のコンポーネントのみをマウントし、画面外のコンポーネントのマウントを解除します。
このケースでは、複雑な 2 次元グリッド テーブルの有効化に関する要件があったため、AirSHIFT は React-virtualized を使用しました。また、今後は軽量の react-window を使用するように実装を変換する方法も検討しています。
結果
テーブルを仮想化しただけで、スクリプト実行時間が 6 秒短縮されました(CPU が 4 倍に減速し、高速 3G でスロットリングされた Macbook Pro 環境)。これは、リファクタリング プロジェクトで最も効果的なパフォーマンス改善でした。
2. User Timing API による監査
次に、AirSHIFT チームは、ユーザー入力で実行されるスクリプトをリファクタリングしました。Chrome DevTools の炎グラフを使用すると、メインスレッドで実際に何が起こっているかを分析できます。しかし、AirSHIFT チームは、React のライフサイクルに基づいてアプリケーション アクティビティを分析するほうが簡単だと判断しました。
React 16 では、User Timing API を介してパフォーマンス トレースが提供されます。これは、Chrome DevTools の [Timings] セクションで可視化できます。AirSHIFT は、[タイミング] セクションを使用して、React ライフサイクル イベントで実行されている不要なロジックを見つけました。
結果
AirSHIFT チームは、すべてのルート ナビゲーションの直前に不要な React Tree Reconciliation が発生していることを発見しました。つまり、React はナビゲーションの前にシフトテーブルを不必要に更新していました。この問題の原因は、不要な Redux の状態の更新が原因です。この問題を修正することで、スクリプト実行時間が約 750 ミリ秒短縮されました。AirSHIFT では、他のマイクロ最適化も行い、最終的にスクリプト作成時間を合計 1 秒短縮しました。
3. コンポーネントの遅延読み込みと高コストのロジックをウェブワーカーに移動する
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 は JavaScript をメインスレッドからワーカー スレッドに約 100 ミリ秒シフトしました(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 倍向上しています。
パフォーマンスの最適化がリリースされた後、あるユーザーは次のように述べています。
シフト表の読み込みを高速化してくださり、ありがとうございます。 シフト勤務の調整が非常に効率的になりました。