あけましておめでとうございます!
19の翠(sappi_red)です。
いつもはSysAd班で部内サービスの開発・保守をしています。
Web Speed Hackathon 2021 miniに参加した(している)ので、今回はそれでほぼ満点を出した話を書きます。
このコンテストはWeb パフォーマンス改善を競うものです。
イベントの詳細は開催告知記事をご覧ください。
今回のminiは今年の2月に行われたWeb Speed Hackathon 2021をベースにしているようです。
そちらの方にも参加したのですが、そのときに参加記を書いていなかったので、振り返りつつ記録するのにちょうどいい機会なのといくつかできそうなことがあったので、参加してみることにしました。
年末には記事を出そうと思っていたのですが、文量が増えて結局終了1日前になってしまいました…。
ここからはLighthouseの各指標の略称として以下を利用します。
- FCP: First Contentful Paint
- SI: Speed Index
- LCP: Largest Contentful Paint
- TTI: Time to interactive
- TBT: Total Blocking Time
- CLS: Cumulative Layout Shift
前回での変更
振り返るという観点で行うためにまずは前回での変更を適用していくことをしました。この節では前回での変更とその効果を書いていきます。
(前回からいくつか変更を飛ばしたり順番を少し入れ替えたりしています。)
初回計測
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のほうは以下の設定をしました。
useBuiltIns
: 必要なpolyfillだけ挿入するようにwebpackのentrypointにcore-jsなどが含まれていたのを消して、false
から'usage'
に変えました。bugfixes
: サイズを減らせる可能性があるのでtrue
にしました。Babel公式の説明(英語)loose
: 少々危険なのですが、いけるかなって思ってtrue
にしちゃいました。今はassumptions
ができたみたいなので、多少は安全に有効にできそうです。
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
画像のアスペクト比を維持して拡大縮小するのにbuffer
とimage-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/cssとesbuildでの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
development
がtrue
になっていたので本番ビルド時はfalse
になるようにしました。ついでにuseSpread
をtrue
にしました。(今回確認したところ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にlimit
とoffset
が実装されているにもかかわらず、データを全件取得してフロントで絞り込んでいる箇所がいくつかあったので、それらを利用するように書き換えました。
これによりレスポンスサイズを減らせると同時にレスポンスのパースする量を減らせます。
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でなくてもよさそう)
- LCPだけでは画像最適化に不向き、新しい指標が必要な理由 - Zenn
- audit: Progressive JPEGs as part of opportunities: GoogleChrome/Lighthouse - GitHub
合計の画像のサイズが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: scroll
とscrollbar-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-router
をpreact-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の実行結果は次の通りです。
- 投稿一覧ページ (
/
)
- ユーザーページ (
/users/*
)
- 個別投稿(画像)ページ (
/posts/*
)
- 個別投稿(音楽)ページ (
/posts/*
)
- 個別投稿(動画)ページ (
/posts/*
)
- 規約ページ (
/terms
)
全部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ヘッダーに関しては思ったよりいろんなもので使えると感じました。
今回も楽しかったです!ありがとうございます。