Web Speed Hackathon 2023 をやってみた
CyberAgent さんが実施されている、Web Speed Hackathon 2023 の問題を解きました。当日はみやぎハッカソンに出場していたので参加できなかったので、後日取り組みました。
この記事では、パフォーマンス改善で取り組んだ内容の備忘録です。
はじめに
今回はゆるっと取り組んだため、計測は LightHouse のローカル計測のみ、VRT や E2E テストは行っていないです。
Tl;dr 対策まとめ
はじめに、今回行った改善を分類してまとめます。
メディア自体の改善
画像
表示領域ごとに適切なサイズに変換
webp に変換
svg の容量を削減
動画
サムネイルを事前に生成
webm に変換
フォント
必要な文字のみを含んだサブセットフォントを適用
画像の表示の改善
レイアウト
img タグに width, height を追加(CLS)
width や height が画面幅に依存する場合は、aspect-ratio を指定
読み込み
FV(ファーストビュー)に含まれる画像は loading="eager" を指定
FV(ファーストビュー)に含まれない画像は loading="lazy" を指定
すべての画像に decoding="async" を設定
srcset を利用
バンドルサイズの削減
rollup-plugin-visualizer を導入しバンドルサイズを調査
ライブラリの削除
HTML への置き換え
canvaskit-wasm を削除し、直接 URL を img タグに渡すように変更
CSS への置き換え
AspectRatio を aspect-ratio に
WidthRestriction を max-width に
GetDeviceType をメディアクエリに
独自実装への置き換え
lodash: 独自実装に置き換え
formik: lodash-es への依存があったため独自実装に置き換え
zod: 正規表現に置き換え
他のライブラリに置き換え
recoil: 軽量ライブラリである valtio に置き換え
削除
date-time-format-timezone: polyfill は最新版 Chrome では不要なので削除
core-js: 最新版 Chrome では不要だと思われるため削除
その他
react-icons: 個別 import に変更
@js-temporal/polyfill: 個別 import に変更
zipcode-ja: サーバー側に JSON ファイルを設置し、必要に応じてフェッチするように変更
Lazy import
SignIn, SignUp モーダルを Lazy import に
各ページコンポーネントを Lazy import に
ビルド関連の改善
vite build 時に NODE_ENV=production に (バンドルサイズ)
vite.config.ts
minify を有効化
target (ビルドターゲット)を"chrome110" に
JS ファイルを Gzip 化(vite-plugin-compression を利用)
prefetch
react-router 提供の Link コンポーネントを利用し、prefetch を有効化
データフェッチ・GraphQL
FV に必要なデータのみを先行してフェッチ
Apollo の設定を改善
queryDeduplication を有効化
Production では connectToDevTools をオフに
fetchPolicy を cache-first / cache-and-network に(未検証)
GraphQL
不要なプロパティをレスポンスに含めない
必要なプロパティをレスポンスに含める
product に thumbnail フィールドを追加
データを利用するコンポーネントでフェッチを行う(Suspense の利用)
コンポーネントのリファクタリング
Suspense
Suspense を利用し、データフェッチ時に最小限のコンポーネントのみをサスペンドさせる
適切なフォールバックを設定する
不要な早期リターンを外し、UI を早いタイミングで表示できるように
実行パフォーマンス改善
処理が重い正規表現を改善
setimmediate を削除
サーバ
キャッシュの設定を追加(未検証)
その他
DOM アクセスが必要な部分では、useLayoutEffect を利用
最初にやったこと
ここからは、ある程度時系列に沿って、行った改善を紹介します。zennのスクラップとツイートとブランチにログが残っています。
まず利用技術と構成をざっくり確認しました。今回は API 通信が GraphQL、バックエンドが Koa、OR マッパが TypeORM、ビルドツールが Vite と流行りのフレームワークやライブラリが利用されている印象でした。
他にも、Recoil, zod, wasm などが利用されていました。
気になったのは、zipcode-ja と lodash です。lodash は毎度お馴染みで、Tree Shaking が効かずにバンドルサイズが大きくなりがちです。zipcode-ja も、まるまるフロントエンドのバンドルに含まれるとバンドルサイズが大きくなります。
最初の改善
開発者ツールのネットワークタブを見て画像が大量にロードされていそうだったので、そこから手をつけることにしました。
画像のサイズを見直し → アイキャッチのサイズまで落とす
画像を webp 化
1MB 超えの画像もあったが、数百 kB まで落とした
ffmpeg を利用し、ChatGPT に書いてもらったシェルスクリプトで一括変換しました
ProductHeroImage の改善
アイキャッチの画像にプレイスホルダーを追加
canvaskit-wasm を削除 → 通常の img タグとして表示するように変更
AspectRatio コンポーネントを CSS 化
FV に入る画像に、loading="eager" を、すべての画像に decoding="async" を設定
FV に入る画像は eager にすることで遅延せずにロードされる。FV から落ちる画像には lazy を指定して初期ロードから外し余計な通信を省く
decoding="async" は画像のデコードをバックグラウンドで非同期に処理できる
React.memo と _.isEqual の削除
検証したのち、削除しても動作に影響がなさそうだったので削除
props として渡している object をミュータブルな変更しない限り不要と判断
2 回目
ビルド時に、NODE_ENV=development になっていたため、production に変更しました。が、react-dom 以外はあまりバンドルサイズが落ちませんでした。Vite のビルドは(おそらく)開発用ビルドという概念がないため、元々最適化を行ったビルド結果が出ていたためだと考えています。NODE_ENV によって変化したのは react-dom のバンドルサイズで、ホットリロードやソースマップなどが関係していると考えています。
どちらにせよビルドサイズが大きかったため、次はバンドルサイズの改善から取り組みました。
rollup-plugin-visualizer を導入しバンドルサイズを調査
ライブラリの改善
zipcode-ja: イベントハンドラ内での非同期読み込みに変更
date-time-format-timezone: polyfill は最新版 Chrome では不要なので削除
react-icons: Treeshake を効かせるため、* での import を個別 import に変更
core-js: 最新版 Chrome では不要だと思われるため削除(最新の文法を利用して- いる可能性があるので、見つけ次第手動で修正)
lodash: 個別パッケージに分割(後に削除)
ビルド設定の見直し
vite.config.ts で minify を有効化(バンドルサイズの削減)
vite.config.ts で cssCodeSplit を有効化(バンドルサイズの削減)
vite.config.ts で cssTarget の設定を外す(最新版の Chrome のみをサポートすれば良いため。バンドルサイズの削減に有効だったかは不明)
vite.config.ts で target (ビルドターゲット)を"chrome110" に(バンドルサイズの削減)
ビルドサイズをさらに落とすため、ページごとにコードを分割しました。具体的には、共通レイアウトを抽出した上で、各ページコンポーネントを遅延ロードするようにしています。
ページごとにコードを分割(バンドルサイズを削減)
tsx
const Top = lazy(() =>
import('../../../pages/Top').then((res) => ({default: res.Top}))
);
react-router 提供の Link コンポーネントを利用し、prefetch を有効化
tsx
<Link className={styles.container()} to={href} {...rest}>
{children}
</Link>
その他、取りこぼしていた以下の対応を行いました
AspectRatio、WidthRestriction をすべて CSS 化
アバター画像を webp 化
動画を webm に変換
3 回目
画像を事前にクリップするように変更
動画のサムネイルを事前に生成
次に Order ページに取り掛かりました。
Order ページの認証による条件分岐を改善(CLS)
未認証状態で早期リターンしていた部分を、早い段階で UI を表示する形に変更
useTotalPrice で setImmediate をを利用していた実装を useMemo にリファクタ(INP?)
再度バンドルサイズに戻り、
Recoil を valtio に置換
zod を正規表現の置き換え
lodash-es が Tree shake されていなかったため、これに依存していた formik を削除
パスワードの正規表現の計算量が大きかたたため、ChatGPT に適切なものに変換してもらう
@js-temporal/polyfill を個別インポートするように変更(取り除きたかったが、広範囲で利用されていたので断念)
SignIn, SignUp モーダルを Lazy import に(バンドルサイズ削減)
Gzip を導入(FCP)
今回は vite-plugin-compression を利用しました
この辺りで大幅にバンドルサイズが減少し、パフォーマンスが改善してきました。
4 回目, 5 回目
FV の判定をより厳密に(FCP)
次に、後回しにしていた Suspense の導入を行いました。Suspense を利用することで、render-as-you-fetch パターンを利用できます。すなわち、各コンポーネントで独立してフェッチを行い、完了した部分から順次 UI を表示していきます。フェッチ完了前までは fallback としてプレイスホルダーを表示します。
Apollo の設定を改善
queryDeduplication を有効化
同一のクエリを複数回フェッチしないように
Production では connectToDevTools をオフに
fetchPolicy を cache-first / cache-and-network に(この辺りが挙動に影響を及ぼすかちゃんと検証できていませんでした)
データを利用するコンポーネントで直接データをフェッチするように変更
上記コンポーネントを呼び出す場所では Suspense を利用
フェッチを行うコンポーネントのプレイスホルダーを設定
フロントエンドでできることをやり切った感があったため、バックエンド周りに着手しました。GraphQL のリゾルバーは実装経験があまりなかったため、コードの挙動を調査しながらの改善でした。(とても勉強になりました)
まず、トップページで FV に不要なデータを多くフェッチしていたため、FV に必要なものだけ先行フェッチし、それ以外のデータを遅れてフェッチするようにしました
GraphQL クエリの改善
FV に入るデータを先行してフェッチするように変更
Recommendation resolver で、不要なプロパティを select 対象から外す(SQL 改善というよりは、フロントに返すデータを削減するため)
この辺りで大幅にスコアが改善しました。
6 回目
細かな変更を行いました。
setimmediate を削除
srcset を利用して、端末幅に適したサイズの画像を利用するように
useSlider 内のフックを useEffect -> useLayoutEffect に変更(CLS)
useLayoutEffect は DOM 構築後に同期的に処理されるため、レイアウトシフトを防ぐことができる(トレードオフとして画面の描画がブロックされる)
HTML ファイルにあった、謎の video ファイルの preload を削除
Koa の 'Cache-Control': 'no-store' の指定を削除
挙動に影響があるか不明
フロントエンド側で'Cache-Control'のヘッダを見てキャッシュ設定を変更するロジックがあるのかな?と思い、一応削除してみた
フォントを、利用されている文字のみを含むサブセットに変更
エラーが出ていたため react-helmet を react-helmet-async に変更
GetDeviceType コンポーネントを CSS メディアクエリで置き換え
また、GraphQL で、サムネイルを取得するためにすべての画像をフェッチしていたため、サムネイルを返却するフィールドを追加しました
product に thumbnail フィールドを追加
user_resolver で、isOrdered が false のオーダーのみ返すように変更
7 回目
最後まで残っていた zipcode の改善を行いました。いくつかの案を検討し、JSON ファイルを分割して配信する形にしました。
郵便番号の上 6 ケタをベースに XXXXXX.json というファイルに分割
ユーザーが 6 桁入力するごとに json ファイルをフェッチし、7 桁目の入力でサジェストを出す
JSON ファイルは 1kB 未満なので、妥協
6 桁で区切ったのは、ユーザーが 1 桁ずつ郵便番号を入力することを想定し、早めにフェッチを走らせるためです。(今考えると、E2E テストを行う場合(やブラウザの自動入力機能を利用する場合)は 7 文字一気に入力される気がするので、意味なかったかも)
JSON ファイルの分割は Node スクリプトを書いて行いました。処理が重く大変でした。
JSON ファイルは node_modules の中に配置しましたが、デプロイする場合は配置場所も考えないと行けなさそうです。
ボツ案はこんな感じです。
上記の XXXXXX.json または全 JSON ファイルを gzip 化して配信 → wasm でデコード
繰り返しが少なく gzip 化してもファイルサイズがあまり減らないため不採用
バックエンド側で zipcodeJa を読み出し、住所を返す
結局 JSON を返すことになるので、静的ファイルとして配信した方が早いだろうと予想
最終的に、各ページのスコアを 70 以上にすることができました。
SSR 化、preact への移行、キャッシュ設定の見直しなど、改善の余地はまだありそうです。
個人的には、非効率な正規表現、GraphQL の最適化、Suspense の利用周りが面白かったです。