フロントエンドが遅いと言われた時に
Webアプリケーションで、ブラウザ上でJavaScriptを用いてAPIからデータを取得して画面に反映する挙動が遅いと言われた時にフロントエンド側が確認することと対処方法。
ユーザのボタンクリックなどのアクションをトリガーとして、APIからデータを取得し画面に反映するというシナリオがあった際に、ユーザのアクションから画面への反映に時間を要しているという状況を想定しています。
Largest Contentful Paint (LCP)が遅い場合に関しては、別のアプローチになるので今回は除外します。
本当にフロントエンドがボトルネックになっているか
まず最初にすることは、APIのレスポンスタイムと、APIのデータを画面に反映する処理のどちらがボトルネックになっているかの確認です。
ユーザのアクションから画面への反映までの時間の中でAPIのレスポンスが大部分を占めている場合、プリフェッチなどの対処方法はありますが、多くの場合、バックエンドのパフォーマンス向上を行う方が効率的です。
APIのレスポンスタイムを確認する簡易な方法は、Chrome DevToolsのNetworkなどを用いて、Webアプリケーションにアクセスし、該当のアクションで呼び出されるAPIの応答時間を確認することです。
ただし、本番サービスのアクセスログ等を保存しており、APIのレスポンスタイムを算出できる環境が整っているのであれば、実際のレスポンスタイムを確認することをお勧めします。
多くのユーザが同時に接続している場合や極端にデータが多い場合など、ある一定の条件下でレスポンスの遅延が発生することもあり得るためです。
ボトルネックになっている処理の特定
APIのレスポンスが極端に遅いわけではなく、またデータの取得が完了しても画面への反映に時間を要する場合は、フロントエンド側での対処が必要になるため、具体的にフロントエンドのどの処理で時間を要しているのかを詳しく確認します。
確認にはChrome DevToolsのPerformanceなどを用います。
Performanceの具体的な利用方法はProfiling site speed with the Chrome DevTools Performance tabに詳しく記述されていますが、主にCPU flame chartを用いて時間を要している処理を発見します。
開発用のPCは高性能である傾向が多いため、CPU throttlingを利用して、性能の低いデバイスでどの程度のパフォーマンスとなるのかの確認が必要です。
パフォーマンスに影響を与える処理について、その対処方法から経験上3つのパターンに大別できます。
ロジックコード
APIから取得したデータを画面に反映する過程で変換するアプリケーションの処理です。
配列のデータを変換する場合、繰り返しを用いることが多いと思いますが、特に複数の配列を扱う場合や、ブラウザが保持している画像のピクセルデータなど巨大なデータを対象とする場合などは、データのサイズが大きくなった場合に計算量が非常に大きくなる可能性があります。
DOM操作
最近はライブラリを用いることが多かったり、ブラウザを実行するPCやスマートフォンの性能の向上により、パフォーマンスに対する影響の割合は少なくなってきていますが、DOMの追加や削除などは基本的に重い処理であり、また
DOM操作を直接実行するWebアプリケーションにおいて、配列に対する繰り返し処理の中で都度DOMの追加や探索を行うような実装がされている場合、データ量によっては画面の描画までに時間を要する可能性があります。
ライブラリの処理
最近Reactで遭遇したパターンなのですが、差分検出に時間がかかるパターンです。
差分検出処理に記述されているように、Reactは次の仮定に基づいたアルゴリズムを実装しています。
- 異なる型の 2 つの要素は異なるツリーを生成する。
- 開発者は key プロパティを与えることで、異なるレンダー間でどの子要素が変化しない可能性があるのかについてヒントを出すことができる。
言い換えると、これらに当てはまらないパターンでは差分検出のための計算量が多くなるということです。
例えばFullCalendarで大量のイベントを扱う場合、ほぼ同じ構造のツリーを生成し、表示する期間を変更するたびに描画されるイベントが変更されるため、ツリーの末端まで再帰的に差分計算が行われる状況になっていました。
パフォーマンス改善のための対処方法
前述したパターンそれぞれの対処方法の概要を記述します。
ロジックコード
より効率的なコードに変更することとなります。
配列から該当の要素を探索して取得するような処理がある場合、探索された要素が再利用される場合はMapなどに格納する、要素の順番が保証できるのであればインデックスでアクセスする、といった手法が考えられます。
また、どうしても計算がメインスレッドを利用する時間を短くできない場合は、WebWorkerの利用が考えられます。WebAssemblyと合わせて利用することで、処理速度そのものを向上することも可能となります。
DOM操作
DOMの変更や探索を行うAPIの実行回数を減らすことが基本となります。
配列を画面に反映する場合、作成した要素に対して繰り返し処理の中で追加を行い、それをDOMに追加するようにすると、DOMの変更を一度にまとめて行うことができます。
DOMの探索では例えば、querySelectorは以下のように要素数が増えた場合、時間がかかる可能性があると述べられています。
Note: 照合処理は、文書マークアップにおける最初の要素を経由して文書ノードの深さ優先前順走査 (depth-first pre-order traversal) を使用して実行され、子ノードのカウント順で順次ノードを反復して行われます。
そのため、探索した特定の要素を繰り返し処理の中で利用する場合、繰り返し処理の前に変数として保持することで一度のみの探索で処理を行うことができます。
ライブラリの処理
このパターンでは取りうる選択肢は次の3つになると思います。
- 別のライブラリに変更する
- ライブラリにパッチを当てる
- アプリケーションがフリーズしていないことをユーザに示すインタラクションを返す
React + FullCalendarで発生した状況は、一週間に数百のイベントが存在する際に、前述の通り差分検出処理に無視できないほどの時間を要する、ユーザ視点では表示する日付や表示項目を変更しようとすると、数秒間アプリケーションが反応しなくなるという状況でした。
今回は、Next.jsを利用しているため別のライブラリへの変更やパッチを当てることは現実的ではないため、ユーザに対して操作が受け付けられた反応をすぐに返し、処理中であることを表示するという方法を今回は採用しました。
細かなチューニングはいくつか行っているのですが、Reactの機能を用いた改善を一つ紹介します。
最初の実装は、useEffectで複数のAPIのデータと表示設定の状態を購読し、同期的にカレンダーイベントを算出して状態を更新するという実装となっていました。
この実装では、表示設定の状態を変更しようとした場合、カレンダーの差分検出処理を経てDOMへのコミットが完了するまで、表示設定の変更も画面に反映されず、結果としてフリーズしているように見えるという挙動となっていました。
対策として、startTransitionを用いてカレンダーイベントの優先度を下げる。setTimeoutとclearTimeoutを用いて同一イベントループ内で依存する値が複数更新された時に、最後の計算のみを行うように変更しました。
startTarnsitionの利用によって、優先度の高い表示設定の状態がすぐに画面に反映されるようになり、また、計算自体の頻度を減らすことでカレンダーのイベント表示までの時間が不要に長くなることを回避しました。
あまり整理されていない文章になってしまいましたが、要点は以下となります。
- フロントエンドが遅いと言われた場合、どの箇所がボトルネックになっているのかまず確認する
- パフォーマンスに影響を与える処理について、その対処方法から経験上3つのパターンに大別できる
- 配列などに基づくロジックコード
- DOM操作
- ライブラリの処理
Discussion