SPA の First View 表示速度を改善する

AUTHOR :   ギックス

rocket_eyechatch

この記事は GiXo アドベントカレンダー の 8日目の記事です。
昨日は、機械学習基盤 “Refeed” のアーキテクチャでした。

MLOps Div. の堀越です。本記事では、SPA(シングルページアプリケーション)で実装された Web フロントエンドの First View 表示速度を改善した内容について書きたいと思います。

背景

これまで私はトチカチや昨日の記事で紹介のあった Refeed といった Web アプリケーションのフロントエンドを開発してきました。先日の MLOps Div. の紹介記事テックリード採用ページに書かれているとおり、 MLOps Div. は「プロダクトのプロトタイプを素早く作成しプロダクションレベルへ引き上げること」をミッションのひとつとしていて、フロントエンド ではそれを SPA で作成することが多いです。しかし非機能要件の 1 つである SPA のパフォーマンス測定や改善にはあまり着手できていませんでした。パフォーマンスを意識せずに開発していると、JavaScript のサイズや無駄なレンダリングを見落としがちになります。

今のところパフォーマンスが悪くて何か問題が発生したということはありません。しかし一度パフォーマンスを意識した設計・運用にできれば、それ以降はある程度パフォーマンスが改善されたアプリケーションになってユーザーに恩恵があり、ソースコードはメンテされてその後の改善もしやすくなるため Win-Win です。(後述しますがパフォーマンス改善には整理されたソースコードが必要)

ということで、Advent Calendar のネタを求めていた私は SPA のパフォーマンス改善に着手しました。

そもそもパフォーマンスとは?

一口に SPA のパフォーマンスといっても観点はいくつかあるため軽く触れておきます。

SPA のパフォーマンス

本記事ではシンプルに「SPA のパフォーマンス= Web ページの表示速度」として書いています。もう少し詳細に書くと「対象の Web ページにアクセスしてから最初のコンテンツ(First View)が表示されるまでの速度」です。

SPA の課題

SPA は最初の通信でページの表示に必要な HTML を全て取得するため、ページの切り替えを高速に行うことが可能です。その反面、何もチューニングをしていないと初回の読み込みに必要なファイルサイズが大きくなってしまい、First View を表示するまでの時間が遅くなりがちです。

SPA で実装されたトチカチと Refeedについてパフォーマンスの計測を行ったところ、ご多分に漏れずどちらの Web ページも First View を表示するまでの時間に改善の余地があったため、First View の表示速度改善に焦点をあてることにしました。

題材としては View ライブラリに React を使った SPA である Refeed (社内向けの機械学習実行基盤の GUI)を選択しました。理由は単純にアプリケーションのサイズがトチカチと比べてまだ小さく、検証しながら進めやすそうだったからです。(社内向けアプリなので事故ったときの影響が小さいというのもある)

パフォーマンスの計測

Refeed のパフォーマンス計測には Lighthouse を使用しました。Lighthouse は Web ページの品質改善の指針を「パフォーマンス」、「PWA」、「アクセシビリティ」、「ベストプラクティス」、「SEO」の各指標でチェックしてくれるツールです。First View に関する指標は「パフォーマンス」に含まれています。

現状のスコア

これまで開発してきた SPA(Refeed)のパフォーマンススコアは 50 点でした。まだ開発途上のアプリケーションということもあり、肥大化して重いというほどではありませんが、それでもギリギリ赤点を免れたレベルです。

Lighthouse は各指標のスコアの内訳も示してくれます。各 Metrics の定義については割愛しますが、上記の Metrics はどれも First View の表示速度に関わるものです。現状はページにアクセスしてからコンテンツが表示されて操作可能になるまで約5秒かかっています。

なぜパフォーマンスが落ちるのか

First View の表示速度が遅い原因はいろいろ考えられます。「初回ロードされる JavaScript のバンドルファイルが大きい」、「Web サーバ、API のレスポンスが遅い」、「JavaScript の処理が重くレンダリングをブロックしている」、、など。

今回のケースの場合、SPA の First View はログイン画面の表示になります(認証済の場合はすぐにコンテンツページへリダイレクトされますが)。そのためサーバサイド API のレスポンスは関与しませんし、JavaScript の重い処理もありません。よって単純に「JavaScript のバンドルファイルが大きい」ことが原因と考えました。

JavaScript におけるバンドルファイルとは、各ソースコードに記述したモジュール同士の依存関係(importrequire)を事前に全て解決してまとめたファイルを指します。バンドルファイルのおかげで SPA の高速なページ遷移が可能になるわけですが、その分ファイルサイズも大きくなりファイルのダウンロードにかかる時間も長くなるため、初回の表示速度に影響を与えます。

パフォーマンス悪化の原因については Lighthouse 上でも記載があり、「不要な JavaScript のコードを削除すること」がパフォーマンススコアの改善に最も寄与するとあるため、バンドルファイルのサイズ削減に着手することにしました。

バンドルファイルのサイズ削減

バンドルファイルのサイズ削減を進めるには、まず現状のバンドルファイルのサイズとその内訳を知る必要があります。バンドルファイルのサイズの内訳を確認するにあたっては webpack-bundle-analyzer を使用しました。

元々は create-react-app を使ってビルドしてたのですが、手を加えずともよろしくやってくれて便利な反面、各要素を個別チューニングしていくにはブラックボックス過ぎるため webpack へ乗せ替えました。

現状のバンドルファイルのサイズ

上の画像が webpack-bundle-analyzer を使って現状のバンドルファイルを分析した結果です。ボックスの大きさは各モジュールが占めるサイズを表しており、左側の node_modules は外部からインストールしたモジュール郡で、右側の src が Refeed の実装になります。どちらも大体同じくらいのサイズですね。

そもそものページ数・機能がまだ少ないためバンドルファイルのサイズはそこまで大きくないと思いますが、それでも bundle.js の Gzipped size は 440KiB ありました。webpack が推奨するパフォーマンスを考慮したバンドルファイルのサイズは 244KiB となっているため、改善の余地は十分ありそうです。(Google の推奨は 170KiB)

内訳を見ると、Refeed の実装以外では下記のモジュールサイズが大きいことが分かります。

  • firebase
  • material-ui
  • react-vis
  • react-dom
  • react-table
  • react-dnd

Firebase のモジュールサイズが大きいことは有名ですが、こうして比較してみるとその大きさがよくわかりますね(約100KiB )。今回の記事を書くにあたり Firebase のファイルサイズについて issue を漁っていたのですが、特定の機能に絞ってバンドルできるようにするためのモジュールや lite 版も開発中のようでした(しかしまだ alpha release 前)。

バンドルファイルの内訳を確認したところで、上記の状態からファイルサイズを削る作業を行っていきます。

モジュールを入れ替える or 自分で実装

バンドルファイルに含まれるモジュールサイズを削減する上で手取り早いのはモジュールを削除してしまうことです。しかし不要なモジュールをわざわざインストールしているわけもなく、削除したくてもほとんどのモジュールは削除できません(Firebase とかね)。

別の打ち手としては「同じ機能をもつ軽いモジュールに入れ替える」、「モジュールより軽い実装を自分で作る」といった方法があります。前者の対象となりそうなモジュールは見当たりませんでしたが、後者については下記のモジュールなら自前の実装で代用できたため削除することにしました。モジュールの部分的な機能しか利用していないなら、削減の余地があるかもしれません。

  • react-firebase-hooks
  • react-resize-detector

不要なモジュールを import していないか確認

開発を進める中で不要となったモジュールをそのままにしていたり、使用予定のないモジュールまでまとめて import していたりする場合はサイズ削減のチャンスです。よく聞くのは lodash で、

このように書いていると lodash の膨大なユーティリティ関数を意図せず全て import してしまいバンドルファイルを肥大化させます。次のように必要な関数のみ指定して import するだけで、場合によってはサイズを80%近く削減できるようです。(Refeed では lodash を使っていないため関係ありませんが)

上記の観点で実装のモジュール import を順に見ていきましたが、特に不要なモジュールの import は行われていませんでした。

Code Splitting と Dynamic Import で動作に必要なコードのみを動的ロード

今回のケースではこの Code Splitting と Dynamic Import が最も改善の見込みが大きそうでした。Code Splitting はその名のとおりバンドルファイルを複数のコード(Chunk)に分割し、ユーザーのアクセスに応じて必要な Chunk のみを非同期に読み込ませることを可能にします(Dynamic Import)。Refeed の First View ではログインとユーザー認証ができればよく、他ページのコンポーネントや機能実装には依存していないため、それらを切り離して Chunk サイズを絞ることが可能です。

ただしログインとユーザー認証機能のみ切り出して軽くすればよいかというとそうでもなく、認証済ユーザーの場合はすぐに Firestore へのアクセスが走ったり初期コンテンツの表示が始まったりするため、後続の動作に応じて Chunk の分割単位やロードの順番を考える必要があります。でないと First View の表示は早くなったのに後続の処理が遅延して総合的には遅くなった、という本末転倒な状況になり得ます。そのあたりに気を付けつつ、バンドルファイルをユースケースに応じて分割しユーザーのアクセスに応じて非同期にロードさせるよう手を加えていきました。

コード分割には webpack の Code Splitting と React の Suspense, lazy を使用しました。React では Route 単位でのコード分割を行うとよさそうだったため、それにならいました。やったことを簡単に書くと、ログインページと認証済ユーザーがリダイレクトされるコンテンツ(実験一覧ページ)は初回ロードされる Chunk としてまとめておき、他のコンテンツは基本的に全て非同期で遅延ロードさせるようにしました。

長くなるため詳細は省きますが、Code Splitting で Chunk サイズを絞るためのリファクタリングもそれなりに行う必要がありました。Chunk サイズを絞るにはその Chunk 内のユースケースに関係ないコードを可能な限り削ることが望ましいです。しかしそのためのリファクタリングは可読性や品質の向上とはまた違った観点でモジュール分割を行う必要があるため、分割の前にユーザーのユースケース見直しも行う必要があります。

今回のケースだと、ユーザー認証直後にリダイレクトされる実験一覧ページは初期表示に必須な機能とその後のアクションに応じて必要となる機能が混在していました。初回ロードを高速化するにはコンテンツの中で最初に表示されて欲しい機能のみを切り出す必要があったため、それ以外は遅延ロードできるようにリファクタリングを行いました。常日頃からコンポーネント同士、モジュール同士の関係を密にせず疎な設計を保てていればそんなに苦労しないかもしれません?

改善後のパフォーマンス

以上の取り組みを行ったところで、バンドルファイルのサイズをどれだけ削減できたか webpack bundle analyzer で見てみます。

main.6bf~ 以下にある茶色のボックスが First View のために初回ロードされる Chunk で、それ以外が Dynamic Import される Chunk に色分けされています。初回ロードされる Chunk の Gzipped Size は 440KiB → 234KiB まで減らすことができました。ひとまず webpack の推奨値(244KiB)は下回ることができたため、パフォーマンスもそれなりの改善が期待できそうです。この状態で再度 Lighthouse のスコアを見てみます。

大分改善しました!First View 表示にかかる時間は 5.0s → 3.5s となりました。しかしまだ改善の余地があるようです。Lighthouse の指摘事項が変化したため確認すると、何かしらのリソース読み込みによってレンダリングがブロックされているみたいでした。

詳細を掘り下げると Google の Material Design の利用に必要な font と icon リソースの読み込みがレンダリングブロックの原因になっていました。これらは現状の UI では未使用のリソースだったため、削除することにしました。削除可能な不要モジュールを求めて TypeScript のソースコードは全て見直していたのですが、ベースとなる HTML の link タグに見落としがありました。。

不要なリソースを削れたため、再度 Lighthouse でスコアを計測してみます。

First View 読み込み時のレンダリングブロックが解消され、パフォーマンスも90点オーバーのスコアを獲得することができました。

「まだ JavaScript のサイズを削れるよ」と Lighthouse 先生は主張していましたが、見る限りこれ以上 Chunk サイズを絞り込むのは骨の折れる & 可読性を損なうリファクタが必要になりそうだったため、今回のパフォーマンス改善はこれにて終了としました。改善前と比較して First View の表示速度は 5.0s → 2.9s と約1.7倍高速になりました。

おわりに

本記事では SPA における First View 表示を高速化した取り組みについて紹介しました。GiXo は「世界の考える総量を増やす」というミッションを掲げていますが、今回の取り組みによって社内機械学習ツールである Refeed のページ表示速度を約2.1秒速くしたため、約2.1x秒、社内の考える総量を増やすことに貢献できたといえるかもしれません(xには、アクセス回数、利用人数×利用頻度などが入ります)。小さい数字に思えますが、今後利用者が増えていくと、この積み上げ効果は大きなものとなります。

フロントエンドにおいてパフォーマンスは昨今よく取り上げられるトピックのひとつであるものの、これまでの開発ではあまり意識できていない領域でした。今回の取り組みを通して、普段の開発においてどのような部分が SPA のパフォーマンスに影響するのか改めて確認することができたため、今後の開発ではパフォーマンスにも目を向けていきたい所存です。パフォーマンスは継続した計測と改善が必要であるため、次なる一手としては Lighthouse を Github Actions の CI に組み込み、バンドルファイルのサイズとパフォーマンススコアをプルリクベースでウォッチできるようにしたいですね。

パフォーマンスに限らず、技術によってプロダクトや業務を改善することに興味がある方は MLOps Div. の紹介記事テックリード採用ページにもぜひ目を通してみてください。カジュアル面談からお気軽にご応募いただけます。
明日は Technology Div. の幸田より「Apache Superset の可視化例紹介」を公開予定です。


Go Horikoshi
MLOps Div. Lead / Kaggle Master
React・TypeScript を中心としたフロントエンド技術や、機械学習・データ分析を活用したサービス開発について発信していきます。

SERVICE