Web Speed Hackathon 2023 で3位になり損ねた話
こんにちは、monicaです。
今回、CyberAgentさん主催のWebSpeedHackathon2023というイベントに参加してきました。
WebSpeedHackathon2023(以下WSH)とは、Webアプリケーションのパフォーマンスチューニングを競うイベントです。
ISUCONやUTE-1といったWebアプリケーションのパフォーマンスチューニングコンテストと似ていますが、チューニング対象になるのはフロントエンド、おもにCore Web Vitalsの改善です。
参加時のリポジトリを置いておきます
これから書く解説もコミットの順番とは一致していないのでご注意ください。
なぜ参加したか
フロントエンド(主にEdge)が好きなので、今まで培ってきた知識がどれだけ役に立つかを試したいなと思い参加しました。
参加詳細
今回は学友であるAsa@a01sa01toと一緒にぞれぞれ個人で参加しました。
事前準備
元々UTE-1に参加し、そのあと1週間後WSHに参加するというスケジュールだったので、それほどWSHに向けた準備はできませんでした。
また、春休み期間中ですが昼間はインターンをしているため、終わった後から毎晩9時~3時間の過去問解きをしていました。
参加当日
当日は10時からのオープニングセッションから始まり、10時30分から大会開始です。
参加者が大会用リポジトリを一斉にForkして、開発を開始しました。
昨年まではHerokuの無料枠へのデプロイが可能でしたが、咋年末でHerokuの無料枠の提供が終了したため、今回はFly.ioへのデプロイがサポートされていました。
初期スコア
Fly.ioはビルドしたdocker imageをFly.ioのレジストリへpushすることでデプロイできます。
開始早々、どんどん参加者がデプロイをして初期スコア計測を終えている中、自分が初期デプロイを終えた時刻はなんと開始から1時間半後の12時でした...
(もっと早くデプロイできてれば...という結果だったので悔やまれます)
初期スコアは80.00でした。
エントリーポイントのバンドルサイズ削減
Bundle Analyzerの導入
Bundle Analyzer導入 #1fbd3e
今回はVite + Reactの構成だったのでViteの内部実装であるRollupのPlugin rollup-plugin-visualizerを導入しました。
これにより、ビルド後のバンドルサイズを可視化できます。
明らかにバンドルサイズが大きいとわかります。全体で12.85MBもありました。
ここからまずはエントリーポイントのファイルサイズを減らすことを目標にしました。
初期ロードのjsファイルサイズを減らすことができれば、その分FCPが上がるはずです。
Production Build化
Build Commandが
{
"scripts": {
"build:vite": "cross-env NODE_ENV=development vite build",
}
}
となっていたため
{
"scripts": {
"build:vite": "cross-env NODE_ENV=production vite build",
}
}
としました。これでbundle sizeは11.75MBになりました(それほど変わらなかった...)
チャンク分割
まずはこのひとつのファイルを、必要になったときだけ呼ぶように分たいため、Chunk Splittingを行うことにしました。
一番大きいのがdate-time-format-timezoneなんですが、これは後ほど削除できるかなと思ったので先に次に大きかったzipcode-jaを分割します。
特にこのzipcode-jaは購入画面の住所入力部分でしか使われてなかったので、全てのページで最初に読み込まれるのは無駄そうです。
そこで、購入画面のみで読み込まれるようにしました。
const OrderForm = lazy(() => import('../../components/order/OrderForm'));
これでDynamic Importした分がSplit Chunkされ、Entry Pointのファイルサイズは8.22MBになりました。
また、ひとつのURlでひとつのPageが表示されるのにも関わらず、Entry Pointで全てのコンポーネントを読み込んでいました。
そのため、各pageをLazy Loadingすることで、必要なときだけ読み込むようにしました。
Polyfillの削除、ESTargetの変更
今回はレギュレーションに「最新版のChromeで動くことが条件」だと書いてあったため、IE11などの古いブラウザに対応する必要はないと思い、Polyfillを削除しました。
また、ESBuildのTargetもガン上げしました。
esnextに #62339b
code-jsやめる #d80b1e
date-time-format-timezoneやめる #c6d431
polyfill全消し(多分) #f0ac35
Tree Shakingの有効化
Default Importにより、全てのコンポーネントや関数をライブラリから読み込んでいたので、Tree Shakingが有効になっておらず、これらをNamed Importに変更しました。
必要なもののみを読み込むことで、バンドルサイズを減らすことができました。
react-iconをNI #89d9d4
lodash ES, lodashをNI #2e0ae2
ViteOptionの変更
ViteのOptionを変更しました。
export default defineConfig(() => {
return {
build: {
assetsInlineLimit: 20480,
- cssCodeSplit: false,
+ cssCodeSplit: true,
cssTarget: 'es6',
- minify: false,
+ minify: true,
...
}
}
});
まあMinifyとかSplitとかしてないのは???て感じなので。
よくよく考えればViteはそもそも最適化がデフォルトの設定で十分されているので、これらの設定ごと消すのが正解だったかもしれません。
この時点でだいたい1.52MBくらいまで減りました。
その他いらないライブラリをどんどん抹消、独自実装で置き換え
React Helmet 抹消 #70e86d
Canvas Kit 抹消 #ffa2b9
lodash 抹消 #ce233e
ここまでで一旦エントリーポイントのバンドルサイズ削減を終えました。
静的ファイルの最適化
ECサイトというのもあり、トップページやら詳細ページやらで画像と動画がとても多いのでここのアセットをまず最適化しました。
画像の最適化
画像はSquooshで最適化しました。WebP60%くらいで、レンダリングサイズ用に何枚かバリエーションを作りました。
webp化 #c59486
SVGがなぜか10MBくらいあったので、Figmaに貼ってpngでexportしてwebpにするとかいうすごい遠回りなことをしました。
svgデカすぎ #2ff63a
動画の最適化
動画はFFmpegで最適化しました。FFmpegを使ってとりあえずWebMに変換して、さらにサムネイル表示をフロント側で動画から生成していたので、webpでサムネイルを作って、それを表示するようにしました。
動画をWebMに変換 #031089
動画をWebPに変換 #ccd8e0
フォントの最適化
クソデカNotoSerifJPフォントをところどころで呼び出して使っていたのですが、これらの文字はフロントだけで完結する定型分の表示にのみ使われていました。
なので、woff2に変換して必要な文字のみを含むフォントにサブセット化しました。
また、Font読み込み後の切り替わりの際にデザインが崩れていたので、font-display: swap
を追加しました。
実際に6MBあったフォントが10KBくらいになりました。
フロントエンドロジックの最適化
SuspenseQueryをやめる
今回のWSHではGraphQLでバックエンドと通信していたのですが、@apollo/client
のuseSuspenseQuery_experimental
を使っていました。
このuseSuspenseQuery_experimental
は、SSRのフロントエンドにおいてData Fetchingを行う間useQuery
を使うと完成前のHTMLがStreaming SSR
としてクライアントに送られてしまう問題を解消し、主にDataFetchingが終わるまでレンダリングを待機させ、HTMLが完成してから初めてクライアントに送るという実装で使います。
今回はSSRを行っておらず、Suspense
されてしまうとData Fetchingが終わるまで何も表示されない状態になるので、useQuery
を使うように変更しました。
useSuspenseQuery_experimentalをuseQueryに変更 #280054
これによってFCPが大幅に改善されました。
詳しくは参考にさせていただいたこちらの記事をご覧ください。
同期XHR接続でのGraphQL通信をやめる
utils/apollo_client.ts
にsyncXhr
という関数を発見しましたが、これはApollo Clientに渡すHTTP Linkのfetch
オプションに渡している関数です。
実は同期XHR接続でGraphQL通信をするようになっていました。
これによって全てのGraphQL通信がwaterfallになっており、Data Fetchingの待機時間が無駄に長くなっていたので、非同期な並列通信を実現するためにsyncXhrをやめました。
apollo clientの通信を非同期にしてみる #22f186
これがサクセスパスとなり、FCPがさらに改善され、全体的にスコアも2倍になりました。
比較的早くこの問題に気づけたのでこの時点で総合2位になりました。
フォーム入力が遅すぎるのを改善
入力毎にzipcode-jaのクソデカObjectをDeep Copyするという破天荒ロジックを発見したので、それをやめて一度取得したObjectを使い回すようにしました。
さらにその後、フォーム画面の表示の遅さに結局zipcode-jaの読み込みが影響していることに気付いたため、外部API呼び出しによって不必要なデータを完全に取得しないようにしました。
RecoilをReact Contextに置き換える
グローバルな状態共有ロジックがとても少ないユースケースであるため、Recoilを使う必要がないと判断し、React Contextに置き換えました。(多分モーダルだけだったはず)
RecoilをReact Contextに置き換える #a26ad4
Form実装を全部自作ロジックで解決する
Formの実装にはzodとFormikを使っていたのですが、どちらも自力で置き換えられそうだったので、自作ロジックで解決するようにしました。
(綺麗に書きたいなーとかいう適当なモチベーションでReducer使ったのでとても時間がかかったのは内緒です)
Zodを抹消 #1a71d9
Formikを抹消 #5783da
SPA遷移にする
aタグを使ったhref遷移によるページ間移動をしていたのですが、これではSPAとしての性能が出ないので、ReactのLinkコンポーネントを使ってSPA遷移にしました。
SPA遷移を使わないと毎回エントリーポイントからJSの読み込みをやり直してしまいます。
(余談ですがこの変更でdata-test-idを機能不全にしてしまい、採点が落ちてめっちゃ沼りました。)
バックエンドロジックの最適化
N+1問題の解消
GraphQLは触ったことがなかったのですが、自分がPrismaというORMが大好きで、その内部実装としてバッチローダーによるGraphQL向けのN+1問題の解消が組み込まれているということを知っていました。
そのため同等の処理がスピード改善に繋がるのではないかと思い、Facebookが作っているDataLoaderを使ってみました。
また同時に、使っていないGraphQLのフィールド(description)を削除しました。
(このフィールドを削除するだけで総テキスト転送量が1/2になります)
DataLoaderを使ってN+1問題を解消 #9c13d0
import DataLoader from 'dataloader';
import { Product } from '../../model/product';
import { dataSource } from '../data_source';
import type { FeatureItem } from './../../model/feature_item';
import type { GraphQLModelResolver } from './model_resolver';
export const featureItemResolver: GraphQLModelResolver<FeatureItem> = {
product: async (parent) => await ProductLoader.load(parent.id),
};
const ProductLoader = new DataLoader(async (ids: readonly number[]) => {
const products = await dataSource
.createQueryBuilder(Product, 'product')
.whereInIds(ids)
.select(['product.id', 'product.name', 'product.price', 'product.description'])
.getMany();
return ids.map((id) => products.find((product) => product.id === id)) as Product[];
});
これにより通信に大体1s弱かかっていたのが、0.5sくらいに、約50%の改善が見られました。
静的アセット配信時の処理
とりあえず静的アセットは全部gzip圧縮して配信し、さらにCache-Controlを設定しました。
Gzipで配信 #0f64c3
Cache-Controlを設定 #6108ec
サーバー分割
WSHではお馴染みですがレギュレーションに毎年「無料の範囲内であればデプロイするサーバー等を変えてもよい」というルールが存在します。
個人的に推してるゆーすけべーさんの以前のWSH参加記にCloduflareへの移行が話題に出ており、自分も参加する前から移行は絶対やりたいと思っていました。
なので今回はフロントエンドや静的アセットなどCDNにおけるものをCloudflare Pagesから配信するようにしました。
また、Cloudflare Pagesで配信される静的アセットはデフォルトでbrotli圧縮がかけられます。これはgzipよりも効率が良い圧縮方式です。
フロントエンドをCloudflare Pagesに移行
Cloudflare PagesはGithubリポジトリとの連携がとても柔軟ですが、今回リポジトリにpushできなかったため、連携を使わずにwranglerを使ってデプロイしました。
(Forkしているので簡単にprivateにできないという理由です)
Cloudflare Pagesを設定 #80065b
フロント分離 #8520db
ただ、ここでいくつか問題が出てきます。単純にPagesにデプロイするだけでは上手くいきませんでした。
弊害1: 認証が通らなくなる
バックエンドとフロントエンドが完全に別オリジンであるため、クロスオリジンでのcookie認証に対応させなければなりません。
まずcredentialsをincludeにする必要があります。
そしてクロスオリジン間でのAPIリクエストなためSameSiteをNoneにする必要がありましたが、secure属性を付与する必要があります。
さらにsecure属性を付与するためにはHTTPSである必要があります。
その場合fly.ioの内部通信はhttpなためproxy通信であることを明示させないといけません。
(これらでめっちゃつまりました)
credentials明示 #846d0a
secure貼る #b6879e
proxy(http通信内)でもsecure #9cffad
弊害2: initialize apiが動かなくなる
WSHの採点方法はISSUEのコメントに貼ったフロントエンドが存在するURL先へ採点しに行くというものでした。
そのため、フロントとバックが異なるサーバーにある場合、[フロントのURL]/initialize
にPOSTされた時、[バックのURL]/initialize
にリクエストを引き継ぐ必要があります。
そこで、Cloudflare Pagesのリダイレクト機能を使って、[フロンのURL]/initialize
にPOSTされると、それをリダイレクトさせるようにしました。
ただ、どうもこれが上手くいかず、採点前にinitializeできていないというエラーが出てしまいました。
おそらくリダイレクトのステータスコードが302であるためかなと考えます。
そのため、Cloudflare PagesのFunctionsを使って、POSTされた時にバックへリクエストを投げ、そのレスポンスをそのまま返すようにしてみました。
export const onRequestPost = async () => {
const url = new URL('https://sor4chi-web-speed-hackathon-2023.fly.dev/initialize');
const response = await fetch(url, {
method: 'POST',
});
return new Response(response.body, {
status: response.status,
});
};
Functionsを使ってinitializeを引き継ぐ #b8e8e8
これが上手くいき、無事フロントエンドを完全にバックエンドから引き剥がした状態で採点を受けることができるようになりました。
最終調整
ここまでやってあと2時間くらい、特に今からめちゃくちゃ動いても点数があまり変わらないだろうという確信があったので、最終調整をしました。
ここまでやって最高点347点が出ました。
この時点で残り15分です。
ここで焦ったのか私monica、CSSの最適化をし始めます..。
画面幅を変える -> classを切り替える
という操作をJSでやっていたので、これをCSSのmedia queryでやるようにしました。
はい。なんとここで痛恨のミスをしてしまいます。
maxとminを逆にしてしまいました。
このミスによってFooterのNavigationの並びがPCとSPで縦横逆になってしまいました。
試したけど断念したこと・やりたかったこと
react-routerのwouter置き換え
wouterはreact-routerの軽量版のようなものです。
Zero dependencyで、gziped sizeでreact-routerよりも10KBほど小さい1.36KBで実装されています。
また、Preactにも対応しているため、もしPreactに置き換えるタイミングがくればということで先にRouterだけ置き換えようと思いました。
react-routerをwouterに置き換える #fae46d
ただこの置き換えのPRでローカルでは動いているのにどうしても採点チェックが通らなくなってしまい、上手くいかなかったため断念しました...。
Preactに置き換え
言わずもがな、Preactに置き換えることでReactから簡単にマイグレーションできかつ軽量化を図ることができるので、やりたかったなぁという悔しさがあります...。
SSR
バックエンドがKoaなのでReactをServer側でHydrateしてServer側でDOMを生成してから返すのがとてもやりやすい環境でした。
SSRは必要なもののみをレスポンスでき、さらにはクライアント側でのDOM生成を省略できるのでとても効率的です。
もし余裕があればこれを真っ先にやってましたね...。多分Next.jsにリプレイスするよりよっぽど楽。
Cloudflare Workers
Cloudflare WorkersはCloudflareのCDNの中で動くJavaScriptです。
CloudflareのCDNを使うことで、世界中のユーザーに近い場所からコンテンツを配信できます。
要件をよく確認していなかったのですが、もし数分の遅延が許容されるのならCloudflare Workersを使って重いAPIをKVにキャッシュしておくことで相当Topが高速化できたかなと。
Static CSS化
スタイルは全てemotionのCSS in JSで書枯れていました。
これを全て静的なCSSファイルに切り出すことができればCSSがJSに含まれなくなり、HTTP/2通信とも相まってパフォーマンスが向上すると思いました。
結果・感想
最後に行った変更は「全てのページにおいて著しい表示差分がないこと」というレギュレーションに反していたため、最終的にはレギュレーション違反となり点数が無効となりました。
...(クソデカため息)
もし最後のスタイル調整をためらっていれば3位だったことを考えるととても悔しいです...
次回参加する際は、最初にplaywriteで軽くVisual Regression Testを書いてから開発します。
GraphQLやCloudflareなど、大会中に初めて触るものが多かったのですが、実際に大会の時間制限内でドキュメントを読んで問題解決するということができたのでとてもいい経験になりました。
また、大会後の解説などで自分のさらに知らなかったReDOS等の知識についても学ぶことができたので、今後の開発に役立てていきたいです。
また違反落ちにはなってしまったものの、スコアをみると自分の努力や成果が反映されている気がして自信につながりました。
来年こそは優勝したいです!!!
結果や、得点推移などはこちらのURLからご確認いただけるので是非みてみてください。
最後に応援していただいた皆様、WSHの運営スタッフの皆様、貴重な経験をありがとうございました。
おまけ
自分の得点推移です。
最終スコア分布です。
ログインとレビューがほぼ0なのはおそらくReDOSの対策ができていなかったためです。
Discussion