feature image

2022年1月2日 | ブログ記事

Web Speed Hackathon 2021 miniでほぼ満点を出しました

あけましておめでとうございます!
19の翠(sappi_red)です。
いつもはSysAd班で部内サービスの開発・保守をしています。

Web Speed Hackathon 2021 miniに参加した(している)ので、今回はそれでほぼ満点を出した話を書きます。
このコンテストはWeb パフォーマンス改善を競うものです。
イベントの詳細は開催告知記事をご覧ください。
今回のminiは今年の2月に行われたWeb Speed Hackathon 2021をベースにしているようです。

そちらの方にも参加したのですが、そのときに参加記を書いていなかったので、振り返りつつ記録するのにちょうどいい機会なのといくつかできそうなことがあったので、参加してみることにしました。
年末には記事を出そうと思っていたのですが、文量が増えて結局終了1日前になってしまいました…。

前回のリポジトリ
今回のリポジトリ

ここからはLighthouseの各指標の略称として以下を利用します。

前回での変更

振り返るという観点で行うためにまずは前回での変更を適用していくことをしました。この節では前回での変更とその効果を書いていきます。
(前回からいくつか変更を飛ばしたり順番を少し入れ替えたりしています。)

初回計測

Score: 52.97
total JS size: 12222kB
total CSS size: 6341kB

コードを変更していない状態でHeroku (region: USA)にデプロイしたときのスコアです。

スクロールが重いのを修正

Score: 77.08
total JS size: 12221kB (-0.01%)
total CSS size: 6341kB
目的: なし
当該コミット: ae3af14 ecd072d

デバッグがつらいのでスクロールが重くなってる原因を取り除きました。
スクロールが重くなるのはpassiveじゃないイベントリスナーのせいだろうと思って、passive:でgrepしたらいくつか見つかったので、,preventDefault()してないことを確認してpassive: trueに変更しました。
多少の改善は見られたのですが、今度はスクロール後にCPUが跳ねる現象があったので、passiveにしたイベントリスナーのハンドラー内にやばい処理があると思って、やばい処理があったのでそこを修正しました。

webpackのmodeの設定

Score: 86.19
total JS size: 1196kB (-90.21%)
total CSS size: 4373kB (-31.19%)
目的: FCP/SI/LCP/TTI/TBT
当該コミット: 03474cf

webpackの設定でmodeがnoneに指定されていたので、ビルド時はproductionになるようにしました。前回のときは最初にNODE_ENVを指定する変更入れてたんですが、modeを指定するときにmodeがそれもやってくれることに気づいて消してます。

lodashを消す

Score: 81.53
total JS size: 1128kB (-5.69%)
total CSS size: 4373kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: aeb7ff5

ここからはJSのバンドルサイズを削っていきました。本当ならanalyzerなどを見ながら大きい順にやるべきだと思いますが、どうせ全部削ることになるだろうと思ってたので、目についた順にやっていきました。You might Not Need Lodashを参考にしつつ自前実装に書き換えました。

polyfill周りの変更

Score: 67.01
total JS size: 890kB (-21.10%)
total CSS size: 3992kB (-8.71%)
目的: FCP/SI/LCP/TTI/TBT
当該コミット: 4091770

レギュレーションで最新のChromeで動けばよいとあったので、browserslistをpackage.jsonに記述しました。これでbabelとpostcssが設定をトランスパイルを最小限にしてくれます。
babelのほうは以下の設定をしました。

momentを消す

Score: 73.17
total JS size: 601kB (-32.47%)
total CSS size: 3992kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: 9f194f0

何かを参考にした気もするのですが忘れちゃいました。
実はWebSpeedHackathon2021の前のWebSpeedHackahon2020にも参加していたんですが、そのときにもmomentを消すのは行っていました。そのときは「何分前」みたいな現在時刻からの差分の表示があってmomentのソースコードとにらめっこしながら書いたのですが、今回は使用箇所も多くなくかなり短いコードになりました。

jQueryを消す

Score: 110.39
total JS size: 513kB (-14.64%)
total CSS size: 3992kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: c105b30

使ってる箇所はデータの取得だけだったのでfetchに書き換えました。

CoverImageの書き換え

Score: 97.53
total JS size: 466kB (-9.16%)
total CSS size: 3992kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: 5438523

画像のアスペクト比を維持して拡大縮小するのにbufferimage-sizeパッケージを利用してJSで行っていたので、CSSのobject-fitを利用するようにして、それらを消しました。

tailwind cssのpurge

Score: 133.1
total JS size: 466kB
total CSS size: 459kB (-88.50%)
目的: FCP/SI/LCP
当該コミット: 9deb233

ここで一旦CSS周りに手を入れました。やっぱりpurgeしないとtailwindはめちゃでかいですね。

CSSのminify

Score: 135.65
total JS size: 466kB
total CSS size: 381kB (-16.99%)
目的: FCP/SI/LCP
当該コミット: 2de3981

css-minimizer-webpack-pluginでminifyをするようにしました。設定はデフォルトのままです。

前回は細かいところは調べなかったんですが、今回は@parcel/cssesbuildでのminifyも試してみました。結果としてはデフォルトのcssnanoのほうが小さくなりました。
@parcel/cssやesbuildはブラウザターゲットの指定によって、新しいショットハンド記法であるinsetへの変換などをサポートしている(esbuild@parcel/css)ので、新しいブラウザのみに対応する場合はcssnanoなどよりも小さくなる場合があります。

使っていないwebfontのcssの削除

Score: 142.9
total JS size: 466kB
total CSS size: 160kB (-58.01%)
目的: FCP/SI/LCP
当該コミット: 1e7e681

font-でgrepするとfont-bold以外はヒットせず、font-weightは400と700しか使われていないことがわかるので、それ以外のものを削除しました。(実際は<b><strong>が使われていないことのチェックも必要ですね)
結構サイズが小さくなっているように見えますが、フォントのCSSは繰り返し部分が多くbrotli圧縮された状態のファイルサイズで比較すると大きくは削れていないです。

Before After Diff
無圧縮 381kB 160kB -58.01%
gzip 140kB 58.8kB -58.00%
brotli 18.8kB 18.1kB -3.72%

gzip圧縮だとあまり圧縮できていなく、スライディングウィンドウが小さいせいかなと思ったのですが、実際に大きさがかなり違うみたいなので、それが原因としてありえそうです。

Gzip uses a fixed size, 32KB window, and Brotli can use any window size from 1KB to 16MB, in powers of 2 (minus 16 bytes). This means that the Brotli window can be up to 512 times larger window than the deflate window.
Results of experimenting with Brotli for dynamic web content - The Cloudflare Blog

AudioContextのponyfillの削除

Score: 168.17
total JS size: 356kB (-23.61%)
total CSS size: 160kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: 64b7b46

AudioContextのponyfillであるstandardized-audio-contextが含まれていますが、最新のChromeでは利用している機能がすべて実装されているので削除できます。

inertのpolyfillの削除

Score: 165.26
total JS size: 348kB (-2.25%)
total CSS size: 160kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: f107e65

このアプリケーションではinert属性のpolyfillであるwicg-inertが使われていました。inert属性は付与した要素をインタラクトできないように指定するものです。
ここで、wicg-inertのソースコードを見るとその要素にuser-select: noneなどをつける以外に、その要素の子孫要素にtabindexがついている要素やhref属性のついているaタグなどにtabindex=-1をつけることでフォーカスがそこに行くことを防ぐようにしています。
これをCSSに置き換えました。これを行うとモーダル内の最初のフォーカス可能要素をフォーカス後、Shift+Tabを行うとモーダル外の要素を選択できるようになってしまいますが、レギュレーションの「著しい機能落ち」には該当しないと考えて取り除いてしまいました。そのときは特に何をチェックされるかを明言されてはいなかったのですが、今回の機能落ちチェックリストにも記述がないので、おそらく該当しない機能なのでしょう。
(わざわざinertが入っているということはアクセシビリティにも気を配ってほしいという意図の可能性も考えましたが、それならariaをつけたりしているだろうからそうではないだろうという判断をしました。)

react-helmetの削除

Score: 183.67
total JS size: 331kB (-4.89%)
total CSS size: 160kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: f200eb9

document.titleしか触っていなかったので書き換えました。

pakoの削除

Score: 181.07
total JS size: 289kB (-12.69%)
total CSS size: 160kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: e7dc407

POSTリクエストでリクエストボディをgzip圧縮してたのをやめてpakoを削除しました。
lighthouseだとそもそもロード後のユーザーの操作は点数に含まれないので何も考えずに消しました。ただ、もし現実問題としてgzip圧縮をする必要があるなら画面がロードしきった後や投稿モーダルを開いたときにpakoを読み込むようにするのがよさそうです。

production用のJSX変換

Score: 202.84
total JS size: 263kB (-9.00%)
total CSS size: 160kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: 82382ac

developmenttrueになっていたので本番ビルド時はfalseになるようにしました。ついでにuseSpreadtrueにしました。(今回確認したところJSXで...を使ってる箇所はなかったので影響ないですね)

キャッシュ周り

Score: 189.61
total JS size: 263kB
total CSS size: 160kB
目的: FCP
当該コミット: 9c3c4b4

ファイル名にコンテントハッシュを含めて静的ファイルにCache-Control: public, max-age=604800, immutableをつけました。ついでに動的な部分でもCache-Controlからno-transformを取り除きました。それと、Connection: closeを取り除きました。
ベンチマーク時に前回のベンチマークのキャッシュは利用されないことと今はCDNとか入っていないのでCache-Controlに関しては効果がないです。
ただ、HtmlWebpackPluginのinjectを利用するようになり、scriptタグにdeferがつくようになったので、JSのダウンロード中もHTMLのパースが進むようになりました。

ログイン状態のチェック前から画面を表示する

Score: 176.24
total JS size: 263kB
total CSS size: 160kB
目的: SI/CLS
当該コミット: 29466c8

ログイン状態のチェックができていなくても表示したほうがCLSが減らせるので、チェック前から表示されるようにしました。

データ取得でのlimit/offsetの利用

Score: 151.33
total JS size: 263kB
total CSS size: 160kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: d3e7eca

APIにlimitoffsetが実装されているにもかかわらず、データを全件取得してフロントで絞り込んでいる箇所がいくつかあったので、それらを利用するように書き換えました。
これによりレスポンスサイズを減らせると同時にレスポンスのパースする量を減らせます。

CSSのaspect-ratioを利用する

Score: 165.34
total JS size: 263kB
total CSS size: 160kB
目的: TTI/TBT
当該コミット: 2cd670a

特定のアスペクト比の要素をつくる機構がJSで実装されていたので、CSSのaspect-ratioを利用するようにしました。

CloudFlareの利用

Score: 203.98
total JS size: 263kB
total CSS size: 160kB
目的: FCP/SI/LCP
当該コミット: なし

載せると配信が近いところになるだけでなく、brotli圧縮も勝手にかかるので便利です。

GIFをWebMに変換

Score: 279.62
total JS size: 185kB (-29.66%)
total CSS size: 160kB
目的: FCP/SI/LCP/TTI/TBT
当該コミット: 7cbbfbf

GIFはファイルサイズが動画に比べ大きいこととGIFで一時停止を実現するためにライブラリを利用していたので変換しました。
ffmpegではコーデックを指定せずにWebMを指定するとVP9になるみたいなので、コーデックはVP9です。

合計の動画のサイズが183,698kBから47,769kBになりました (-74.00%)。

JPEGをWebPに変換&リサイズ

Score: 354.07
total JS size: 185kB
total CSS size: 160kB
目的: SI/LCP
当該コミット: 1b9b848

横幅の最大値が存在していたので、各画像の横幅の最大値も決まるので、リサイズしました。また、WebPを利用するようにもしました。そのときはAVIFを知らなかったんですが、AVIFにするともっとよさそうです。

ただ、ここではlighthouseのことだけを考えているので、場合によってはWebPやAVIFよりもJPEGを利用した方がいいこともあるので(あるいはまだブラウザの実装がリリースされていないJPEG XL)、実世界では一概には言えないです。(サムネ程度なら軽いのでJPEGでなくてもよさそう)

合計の画像のサイズが91,099kBから1,469kBになりました (-98.39%)。

アイコンのバンドル

Score: 404.46
total JS size: 191kB (+3.24%)
total CSS size: 160kB
目的: SI/LCP
当該コミット: e199d01

でかいSVGを読み込んで切り出す方式になっていたので必要なものだけバンドルするように変更しました。
@fortawesome/react-fontawesomeを使ってもいいのですが、特に複雑なこともしないので、@fortawesome/free-*-svg-iconsからデータを読み込んで自前でsvgを組み立てることにしました。
jsのsizeは大きくなってますが、合計で1,205kBのSVGを読み込まなくなったので、最終的に読み込まれるサイズは小さくなっています。

横幅を半分にリサイズした画像も用意するように

Score: 406.56
total JS size: 191kB
total CSS size: 160kB
目的: SI/LCP
当該コミット: 2d88f47

画像が2枚以上の投稿では画像の表示の横幅が半分になるので、半分の横幅の画像を生成して、2枚以上だったときはそっちのURLを参照するようにしました。
半分の画像は、平均で元の画像のファイルサイズの3分の1程度でした。

normalize.cssの削除

Score: 383.86
total JS size: 191kB
total CSS size: 158kB
目的: FCP/SI
当該コミット: 915973d

tailwind cssはmodern-normalize含んでいるため、normalize.cssを読み込む必要はないので取り除きました。

波形画像のサーバーでの生成

Score: 504.21
total JS size: 190kB (-0.52%)
total CSS size: 158kB
目的: SI/LCP/TTI/TBT/CLS
当該コミット: e0f6050

フロントで波形の生成をしていて、かつメインスレッドで行っていて重かったので、サーバーで事前に生成するようにしました。
ここで前回つまづいたのはmp3のままだとデコードがうまくできないことと、見つけたWebAudioのライブラリの二つのうちの一方が何でか上手く動かなかったことです。もうそんなに覚えていないのですが、結構苦労した記憶があります。
前回はサーバーに生成を移すよりも前に、配列の生成回数を減らしたり、TypedArrayに書き換えたりをしたのですが、結局それでも時間がかかっていたので、サーバーに移しました。

画像の遅延ロード

Score: 476.34
total JS size: 190kB
total CSS size: 158kB
目的: SI/LCP
当該コミット: 87e1285

<img>loading="lazy"をつけました。本当はLCPの画像はつけないとかの分岐をしたほうがよいですが、ここではそこまではやらなかったです。

一方のデータが取得できたタイミングでできるだけ表示する

Score: 497.95
total JS size: 190kB
total CSS size: 158kB
目的: SI/CLS
当該コミット: 9200bff

複数のfetchの結果を利用しているページで片方でも取得できたら描画できる部分まで描画するようにしました。

スクロールバーによるレイアウトシフトへの対応

Score: 462.1
total JS size: 190kB
total CSS size: 158kB
目的: CLS
当該コミット: 5e15434

前回のときは対応していなかったのでoverflow-y: scrollでやりましたが、より適切なのはscrollbar-gutter: stableなので今回はこっちにしようとしたんですが、VRTが通らなかったこととスクロールバーが非表示のときの背景色の問題があって結局前回と同じ方法にしました。これをするとスクロールバーが表示されるようになることでのレイアウトシフトを防げます。

overflow-y: scrollscrollbar-gutter: stableのどちらでもスクロールが不要な場合にもスクロールバー分のスペースを確保できますが、scrollbar-gutter: stableではスクロールバーが表示されないという違いがあります。そのため、scrollbar-gutter: stableではスクロールバーが非表示の際にそこが何色で描画されるかという点がscrollbar-gutter: stableを指定せずoverflow-y: autoを指定した場合と異なります。

なんか微妙に挙動がバグってるんですが、「overflow scrollにする」を押してから切り替えるとうまく動きました。
scrollbar-gutter: stableのありなしで、右端のグレーの部分の範囲が変わってることがわかると思います。

font-display: swapの利用

Score: 598.85
total JS size: 190kB
total CSS size: 158kB
目的: SI/CLS
当該コミット: 93997ee

フォントのダウンロードが終わるまで文字が表示されないよりは表示されていたほうがレイアウトシフトを防げるので、swapに変更しました。

規約ページの内容の部分表示化

Score: 636.12
total JS size: 192kB (+1.05%)
total CSS size: 158kB
目的: SI/CLS
当該コミット: fd3ebba

文字が多いことでフォントの読み込みが大量に発生していたので、読み込み時には前半分だけを表示して、スクロールして初めて後ろ半分を表示するようにしました。これにより初回ロードでは、最初に表示されている文字で使われているフォントファイルだけがダウンロードされるようになりました。

code splitting

Score: 646.5
total JS size: 212kB (+10.42%)
total CSS size: 158kB
目的: FCP/TTI/TBT
当該コミット: 324fd78

各ページで読み込まれるJSの量を削減するためにrouteごとにdynamic importするようにしました。preloadなどでページごとのchunkを読み込むようにしないと表示されるまでに1RTT増えるので一長一短です。

モバイルでは半分のサイズの画像を利用

Score: 618.11 / 648.08
total JS size: 212kB
total CSS size: 158kB
目的: SI/LCP
当該コミット: f27d455

横幅が小さい端末では半分のサイズの画像で十分なので、そっちを利用するようにしました。

疑似SSG

Score: ---
total JS size: 212kB
total CSS size: 158kB
目的: FCP/SI/LCP/TTI/TBT/CLS
当該コミット: なし

これは前回は行ったのですが、今回はVRTが通らず行いませんでした。(軽く調査したんですが、やるとナビゲーションの高さが少し変わる。CSSもHTML構造も一緒に見えるのでHTMLにおけるスペースか、HTMLを描画する場合とJSでHTMLを組み立てる場合での挙動の違いが怪しいと思ってます)

規約ページは静的なページなので事前に生成することができます。本来ならReactDOMServer.renderToStringするのがいいと思いますが、そのときは規約ページをブラウザで開いて、描画されたHTMLをコピーしました。(なので疑似って書いてます)

今回での変更

本当はちゃんと減らすべき指標を確認しながら削ったほうがいいのですが、このコンテストでは得点の配分は表示されないので、Performance Timeline APIとかElement Timing APIとかで情報をあつめて、サーバーに送信する必要があって面倒になっちゃってやりませんでした…
一応手元からlighthouseを実行してもいいのですが、RTTや通信速度やマシンスペックが異なるので、高得点帯(lighthouseで90点台以上)だとそこそこ点数が違うイメージです。

それと宗教上の理由でSSRはしてないです。なので、ほかの方とは違った手段を使ってるかもしれません。ただ、高得点を出すならSSRはしたほうがいいとは思います。

htmlのキャッシュ

Score: 668.26
total JS size: 212kB
total CSS size: 158kB
目的: FCP
当該コミット: 4409eba

今回はSSRしていないのでhtmlも静的なのでサーバーではなくCDNから返すと最初のRTTを短くできます。
知らなかったんですが、デフォルトだとCache-Controlを指定していてもcloudflareはhtmlをキャッシュしないんですね。

htmlで同じキャッシュがヒットするように

Score: 639.79
total JS size: 212kB
total CSS size: 158kB
目的: FCP
当該コミット: なし

//termsのように別のパスでも同じhtmlを返しているので同じキャッシュが使われたほうがキャッシュヒットが高くなります。
そこでCloudflareのURL Rewrite Rulesを使って、/に書き換えるようにしました。
これでどのパスにアクセスしても同じキャッシュが利用されます。

ユーザーと投稿のキャッシュ

Score: 643.08
total JS size: 212kB
total CSS size: 158kB
目的: SI/LCP
当該コミット: ed0ecee

今回はユーザー(/api/v1/users/:username)と投稿(/api/v1/posts/:postId)のエンドポイントにCache-Controlをつけて、CDNから返るようにしました。
投稿のほうは変更するエンドポイントがないので問題ないですが、ユーザーのほうは変更するエンドポイントが存在し、キャッシュをすると変更の反映が遅れるため、レギュレーションによっては怪しいです。
ただ、今回はブラウザでの機能落ちがないというレギュレーションなので、エンドポイントは存在していてもブラウザで情報を変更できない以上問題ないと判断しました。

WOFFをWOFF2に

Score: 669.64
total JS size: 212kB
total CSS size: 158kB
目的: SI
当該コミット: 86b5f20

前回は気づいていなかったんですが、public/fonts/genei-m-gothic以下にはwoffだけじゃなくwoff2も入っていたので、ただwoffから変換することなくcssを書き換えるだけで切り替えられます。

合計のフォント(実際には読み込まれないものもあるので参考値ですが)のサイズが、18,824kBから15,604kBになりました (-17.11%)。

WebMのリサイズとWebPをAVIFに変換

Score: 633.6 / 634.84
total JS size: 212kB
total CSS size: 158kB
目的: SI/LCP
当該コミット: 41d4f9d f07aaf6

WebMも画像と同じようにリサイズしました。
また、WebPではなくAVIFを利用するようにしました。

合計の動画のサイズが47,769kBから7,121kBになりました (-85.09%)。
また、合計の画像のサイズが1,924kBから962kBになりました (-50.00%)。

fontの遅延読み込み && loadイベントを使わないように

Score: 702.19
total JS size: 212kB
total CSS size: 159kB (+0.63%)
目的: SI/LCP
当該コミット: c4a3177

loadイベントでJSが実行されていたのでそれをやめた(deferで読み込んでいるのでDOMContentLoadedのタイミングで実行される)のとフォントのCSSをloadイベントで読み込むようにしました。

Azureに載せる

Score: 658.8 / 667.14
total JS size: 212kB
total CSS size: 159kB
目的: FCP/SI/LCP
当該コミット: d89731f d15f254

前述の通り、手元とベンチマークでは、通信速度やRTTやスペックの影響で点数が異なります。
そこで通信速度とRTTの影響を無視できるようにするために、ベンチマークが実行されているGitHub Actionsが実行されていたAzureのEast USにサーバーを置くことにしました。また、CDNよりも近いはずなので、Cloudflareは外しました。Cloudflareに頼っていたbrotliはかけたファイルを用意するようにしました。
AzureのApp ServiceのDocker Composeを使えるものがあったのでそれを利用しました。構成としては元のnode.jsサーバーとその前段にcaddyというリバースプロキシを用意しました。caddyを使ってるのはtraPで使ってて慣れているというのもありますが、設定が個人的には楽だと感じるからです。
GitHub Actionsの実行されている場所はアクセス元IPから特定しました(East USもEast US 2も位置が近くなので、もしかしたらEast US 2かもしれない)。
これによりマシンスペック以外の点では、GitHub Actionsからサーバーへのリクエストと、手元のブラウザから手元で建てたサーバーが近似できるようになりました。

WebM(VP9)をWebM(AV1)にする

Score: 647.82 / 655.06 / 628.8 / 690.1
total JS size: 212kB
total CSS size: 159kB
目的: SI
当該コミット: 3171426

Scoreの後ろ二つの結果は、fontの変更のアナウンス後に計測したものです。

WebMのコーデックを指定していないことに気づいたのでVP9からAV1にコーデックを変更しました。
エンコードにすごい時間かかるのでユーザー数が多くないと現実的ではないですが、今回は問題にならないので使うことにしました。ちなみに15個の5秒の動画なのに数時間以上かかりました。

合計の動画のサイズが7,121kBから5,141kBになりました (-27.81%)。

code splittingを部分的にやめた

Score: 668.46 / 673.15
total JS size: 196kB (-7.55kB)
total CSS size: 159kB
目的: SI/LCP
当該コミット: 0b69455

code splittingされたチャンクのpreloadを行っていないので、RTT分描画が遅延していました。チャンクの大きさがそんなに大きくないので、モーダルの部分以外はdynamic importするのをやめました。

fetchのpreload

Score: 702.04 / 654.03 / 679.03
total JS size: 196kB
total CSS size: 159kB
目的: SI/LCP
当該コミット: 1169de7

Linkヘッダーでfetchの内容をpreloadするようにしました。as=fetchで行えます。ヘッダーを利用するとHTMLを静的なままにできます(ただ、これが静的なのかは怪しいですが)。
実はJSとCSSをサーバープッシュするようにもしてるんですが、Azure App Serviceでは未対応で動かなかったです。ファイル名の取得のためにsedで最悪みたいなことしてるんですが、本来ならrecords.jsonを使ったりしたほうがよいです。

Azure VM

Score: 681.51 / 703.21 / 682.64 / 676.84
total JS size: 196kB
total CSS size: 159kB
目的: FCP/SI/LCP
当該コミット: なし

前述の通り、サーバープッシュが未対応だったので、Azure VMで動かすことにしました。これでサーバープッシュが利用でき、JSとCSSが読み込まれるのが早くなります。
現実ではユーザーが毎回キャッシュを消してるわけではないので無駄な転送量になって一長一短ですが、今回はベンチマーカーだけで、ベンチマーカーはキャッシュを利用しないのでプラスですね。HTMLファイルにそのままインライン化するのとどっちがいいかはわかっていません。

最初の画像の場合に遅延ロードをしないように

Score: 692.51 / 699.65 / 712.77
total JS size: 196kB
total CSS size: 159kB
目的: LCP
当該コミット: 448fb94

一律でloading="lazy"をつけていたのですが、個別投稿ページ(/posts/*)や投稿一覧ページ(/posts/*)の一つ目の画像ではつけないようにしました。

規約ページでfontのCSSをpreloadするように

Score: 667.12 / 717.55 / 688.8
total JS size: 196kB
total CSS size: 159kB
目的: SI/LCP
当該コミット: c5f5955

規約ページ(/terms)では画像などがなく、文字のコンテンツしかないので、ウェブフォントをLinkヘッダーでpreloadするようにしました。

fontのCSSの遅延読み込みの改善

Score: 686.29 / 691.05 / 711.65
total JS size: 196kB
total CSS size: 159kB
目的: SI/LCP
当該コミット: 4207407

loadイベントで読み込むようにしていたのをrequestIdleCallbackで行うようにしました。これでJSの処理がなくなったタイミングで読み込まれるようになりました。フォントよりも画像の読み込みを優先したいのが理由です。

画像のクリッピングと圧縮と画質の調整

Score: 697.17 / 692.35 / 698.25
total JS size: 196kB
total CSS size: 159kB
目的: SI/LCP
当該コミット: e2bd4a0

圧縮率を最高にして画質を50から40に変更した上で、使われない上下の部分を切り取るようにしました。

合計の画像のサイズが962kBから426kBになりました (-55.72%)。

遅延レンダリング

Score: 703.01 / 718.73 / 708.05
total JS size: 197kB (+0.51%)
total CSS size: 159kB
目的: TTI/TBT
当該コミット: b5c423c

実際に表示される箇所の近くが表示されるまで、画像のロードだけでなくJSでのレンダリングの一部も行わないようにしました。
content-visibilityの存在を忘れていたのですが、もしかしたらcontent-visibilityが効果あるかもしれないですね。

波形画像の値の調整

Score: 718.61 / 716.43 / 694.57
total JS size: 197kB
total CSS size: 159kB
目的: SI/LCP
当該コミット: 383a4d4

波形画像のsvgの数値が小数点以下16桁あったので小数点以下3桁に四捨五入しました。

合計の波形画像のbrotli圧縮されている状態でのサイズが27kBが10kBになりました (-62.96%)。

一覧取得の際に10件ずつ取得していたのを3件ずつに

Score: 719.73 / 715.18 / 717.49
total JS size: 197kB
total CSS size: 159kB
目的: SI/LCP
当該コミット: 0493719

ちょっとずるい気もしますが、10件も画面内に表示されることは少ないので思い切って3件に変更しました。すべてが画面内でも続きが勝手に読み込まれるので、画面内が埋まらなかったときには表示が遅くなりますが最終的に読み込まれるためレギュレーション的に問題はありません。これによってJSでのレンダリング処理が減ります。
現実的なラインとしては5件にして、スクロールしきるより前の場所に来たら読み込むようにするというのがよさそうではあります。

preloadしていたfetchをサーバープッシュするように && ナビゲーションバーのフォントを早めに読み込むように

Score: 718.7 / 716.43 / 718.73
total JS size: 197kB
total CSS size: 159kB
目的: SI
当該コミット: e3de2fd

Linkヘッダーでfetchの内容をpreloadするようにしていましたが、nopushを指定してサーバープッシュはしていませんでした。ここでnopushを外してサーバープッシュを有効にしました。
ナビゲーションバーの太字の文字のフォントはどのページでも使われるので、遅延してpreloadするようにしました。これはフォントのCSSの読み込みと同じようにrequestIdleCallbackで行っていて、linkタグを追加することで行いました。

Preactに移行

Score: 719.91 / 717.73 / 693.46
total JS size: 91.1kB (-53.76%)
total CSS size: 159kB
目的: FCP/TTI/TBT
当該コミット: 6befb27

reactをpreactに変えました。ただ、preact/compatを利用したのでそんなに書き換えていません。JSの転送量を減らすというよりは、パースされるコード量を減らしてパースにかかる時間を削減することが目的で行いました。

preact-routerに移行

Score: 716.36 / 717.43 / 715.13
total JS size: 87kB (-4.50%)
total CSS size: 159kB
目的: FCP/TTI/TBT
当該コミット: 97d56e2

preactに変えたのと同様に、react-routerpreact-routerにしました。

preact/compactの除去

Score: 699.41 / 707.81 / 703.27 / 718.8 / 697.16 / 719.91
total JS size: 80.3kB (-7.70%)
total CSS size: 159kB
目的: FCP/TTI/TBT
当該コミット: aa14d99

そんなにpreact/compatが必要な箇所がなかったので削りました。

最終的な結果

クライアント側でのJSの処理がそこそこ残したままなので、GitHub Actionsの個体差で点数がブレてるのですが、最高点としては、最終的には719.91点を獲得できました。
満点が720点なので満点まで0.09点ですね。

各ページでのlighthouseの実行結果は次の通りです。

全部100点なので各指標での1s未満とかで戦ってる状態ですね。

この画像の生成にはlowlighter/metricsを利用しました。GitHub Actionsでのpuppeteerによるlighthouseの実行結果ではなくPageSpeed Insights APIの結果なのでベンチマーカーとは実行環境が異なります。

各ファイルサイズの変化は次の通りでした。

Before After Diff
JS 12,222kB 80.3kB 約152分の1
CSS 6,341kB 159kB 約40分の1
動画 183,698kB 5,141kB 約36分の1
画像 91,099kB 276kB 約330分の1

まとめ

サーバープッシュもLinkヘッダーも使ったことがなかったので勉強になりました。Linkヘッダーに関しては思ったよりいろんなもので使えると感じました。
今回も楽しかったです!ありがとうございます。

sappi_red icon
この記事を書いた人
sappi_red

19B/22M。SysAd班。 JavaScript書いたりTypeScript書いたりGo書いたりRust書いたり…

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

ERC20トークンを用いた宝探しゲーム(真)の提案【アドベントカレンダー2018 10日目】 feature image
2018年11月3日
ERC20トークンを用いた宝探しゲーム(真)の提案【アドベントカレンダー2018 10日目】
Azon icon Azon
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2023年4月27日
Vulkanのデバイスドライバを自作してみた
kegra icon kegra
2024年4月14日
Spotifyのクライアントを自作しよう
d_etteiu8383 icon d_etteiu8383
2023年8月21日
名取さなになりたくてOBSと連携する配信画面を作った
d_etteiu8383 icon d_etteiu8383
2023年3月30日
みやぎハッカソン2023に参加しました(ずんだ食べ食べ委員会)
mehm8128 icon mehm8128
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記