Lighthouse の Performance スコアを52から94に上げた。
Before。

After。

施策として具体的に何を行ったのか、書いていく。
経緯
以前、Shape Painter という SPA を作った。
構成はシンプルで、エントリポイントはひとつだけ。そしてそこで、ライブラリのコードをバンドルしたvendors.contenthash.jsと、アプリケーションのコードをバンドルしたindex.contenthash.jsを読み込んでいる。サーバとの通信はページ読み込み時のみで、あとはフロントエンドで完結して動作する。
React や Redux の習作という意味合いが強く、公開後は放っておいたのだが、なんとなく Tree ページを Lighthouse でスコアを計測してみたところ、Performance 項目がまさかの52だった。
パフォーマンスを意識せずに作っていたのは確かだが(useCallbackを使った最適化などは行っていた)、広告も入れてないしソーシャルウィジェットも入れていないのだから、もう少しマシだろうと思っていた。画像もほとんど使っていないし。
自分以外の利用者がほぼいないのが現状なので放っておいてもよかったのだが、ここ最近学んでいたパフォーマンス改善の実践として丁度よさそうなので、スコア改善に取り組むことにした。
テキストリソースの圧縮
Lighthouse は診断結果に応じたレポートを作成してくれるので、その内容を見ていく。
まず目を引いたのは、Enable text compression。
テキストリソースを圧縮せずに配信しており、これを改善するだけでもファイルサイズを大幅に削減できそうである。

Shape Painter のリソースは S3 に置いてあり、CloudFront で配信している。
この場合、CloudFront でリソースの圧縮を設定できる。
Edit Behavior で Compress Objects AutomaticallyをYesにすると、コンテンツを自動的に圧縮して配信してくれる。

Create Invalidationでキャッシュをクリアしたあとに確認したところ、無事にcontent-encodingとvaryが設定されていた。

vendors.contenthash.jsに対する効果が特に大きく、551kBから152kBにまでサイズを削減できた。
再度計測したところ、大幅にスコアが改善し、Enable text compressionの警告も消えた。

テキストリソースの圧縮についての詳細は、以前書いた。
script 要素に defer 属性を設定する
Lighthouse のレポートに書かれていたわけではないのだが、script要素にdefer属性をつけていないことに気付き、修正した。
具体的には、html-webpack-pluginの設定を変えた。
defer属性にどのような効果があるのかは、以下を参照。
スコアは横ばいだったが、これ自体がやるべきことだったので、よしとする。
First Contentful PaintとSpeed Indexも目に見えて改善した。

ブラウザにキャッシュさせるようにする
Lighthouse のレポートにServe static assets with an efficient cache policyという警告が出ているので、次はそれに取り組む。

Cache-Controlを使って効率的にキャッシュしましょうとのこと。
確かに現状ではCache-Controlを全く設定していない。
既に述べたように、ライブラリのバンドルファイルと、アプリケーションのバンドルファイルを読み込んでいる。
そしてどちらも、ファイル名にハッシュ値を使うことで、ファイルの中身が変わればファイル名も変わるようにしている。
これはキャッシュバスティングという手法で、リソースの内容が変わればリソースの URL も変わるため、古い内容のリソースを参照し続けることを回避できる。
そのため、キャッシュを長く設定しても問題ないように思える。ただ、先程の圧縮のケースのように、同じ URL でも配信内容が変わることもあるので、極端に長い時間は設定しないほうがいいかもしれない。
取り敢えず今回は、90 日間キャッシュするようにした。
また、いくらキャッシュバスティングを行っていても、HTML ファイルが古いままでは意味がない。
そのため、HTML ファイルだけは、キャッシュしないようにした。
Shape Painter は、masterブランチにコミットされた際に GitHub Actions でデプロイを行っている。
そのため、.github/workflows/deploy.ymlを編集して、S3 にリソースを設置する際にCache-Controlフィールドが付与されるようにした。
レスポンスヘッダにCache-Controlが付与され、2 回目以降のアクセスではキャッシュを使うようになっている(from memory cacheとなっている)。


スコアが上がり、Serve static assets with an efficient cache policyの警告も消えた。

Cache-Controlそのものの説明は、以下を参照。
webpack のコード分割
最後に、残っている警告であるRemove unused JavaScriptに取り組む。
未使用の JavaScript があるとのことだが、心当たりがある。
既に少し触れたが、ライブラリのコードは全てvendors.contenthash.jsにバンドルしている。だが意図があってそうしているわけではなく、webpack のドキュメントにあるサンプルの内容をただコピペしただけである。
cacheGroups: { commons: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all', }, },
こうして作られたバンドルファイルをページロード時に読み込んでいるのだが、明らかに、不要なコードも含まれているはず。
これを改善できれば、ページの読み込みや表示を高速化できると思われる。
まず、いい機会なので webpack をv5に上げた。
次に、webpack-bundle-analyzerで現状を確認する。

html2canvasが目を引く。
これは、その名の通り任意の HTML 要素を Canvas 要素に変換してくれるライブラリ。
Shape Painter は、描画した図形をこのライブラリで Canvas 要素に変換し、そこからさらにtoBlobメソッドで PNG ファイルに変換することで、図形を画像ファイルとしてダウンロードできるようにしている。
つまり、このライブラリは画像のダウンロード時に必要になるもので、ウェブアプリの初期表示時には不要。このライブラリを Dynamic Import で読み込むようにすれば、ページロード時に読み込まれるファイルのサイズを削減できるはず。
それ以外にも、React Router のルーティング単位でのコード分割を導入したり、webpack.config.jsの設定を見直したりした。
その後も、初期表示時に不要なファイル(モーダルなど)を Dynamic Import で読み込むようにして、コード分割を進めた。
最終的に、以下のような形になった。

その結果、冒頭で述べたようにスコアは94まで改善された。
webpack によるコード分割については、以下の記事に詳しく書いた。
感想
パフォーマンス改善というとテクニカルな手法を駆使するイメージがあったが、当たり前のことを当たり前にやるだけでもスコアが改善されることが分かった。
gzipによる圧縮やレスポンスヘッダの設定などはバックエンドっぽいというか、あまりフロントエンドエンジニアが行うイメージがなかったのだが、今回のような構成ではフロントエンドエンジニアが行うことが多いと思う。
「これはフロントエンド領域、これはバックエンド領域」のように決め付けず、幅広く学んでおかないと、いざという時に対応できないように感じた。というより、CDN による配信やキャッシュの設定も、「フロントエンド」に含まれていると認識すべきなのかもしれない。CDN Edge の利用も、History APIのフォールバックのために必要だったりするし。