この度、株式会社サイバーエージェントさん 主催の Web Speed Hackathon 2023 に参加させていただきました!参加記です。
参加までの経緯
正直なところ、これまで私は Web Speed Hackathon について聞いたことがありませんでした。
Twitter で、相互フォローしている方が参加するというツイートを見て、私も参加してみようと思ったのがきっかけです。
あくまでいろいろ経験をして勉強になればと思って参加することにしました。
正直、パフォーマンスガチ勢には勝てないので...。
当日まで
UTE-1 の記事 にも記述のある通り、「Web 研」を立ち上げたので、そのチームにも参加意思を聞いてみます。
はい、 sorachi も参加するみたいです。sorachi は存在をすでに知っていたみたいですが、申し込みしてなかったみたい。
当日まで、sorachi と練習をします。
といっても、 UTE-1 の翌週に開催のため、1 週間も練習はできませんでした。
過去問 が公開されているので、とりあえず 2022 年版を Fork して、練習します。
これまでの Web 開発経験が活かせた気がします。
まあ本番では 1 人でやるのですが。。
また、今回は fly.io を用いるようです。
触ったことがなかったので、少し触ってみました。
無料枠が広いらしく、うれしい。
本番
いざ本番です。
とはいっても、4 日・5 日の 2 日間であったため、気楽に参加することにします。
競技連絡用 Discord でも、
競技中は自由に休憩をとってください
と書いてあるのでね。
一応、早めに Zoom に参加しておきます。
オープニング
なんか、1 番乗りでした。
課題はショッピングサイトのようです。
レギュレーションは前年度と同様っぽい。
採点は Chrome の最新版なので、Polyfill はいらないなーと戦略を練る。
お願い
・ 適度に休憩、食事を取る
まあ、食事を取らない人も出てきそうなのでね~
私は食事をしっかりとります。
競技
開始したので、Fork して、デプロイします。
開発環境整備
デプロイに時間がかかるので、とりあえずコードを読みます。
デプロイでエラーが出ました。修正を先にします。
foundation/Icon
がないというエラーが出ていました。
.gitignore
で無視するようになっていたので、これはミスかなーと思い、修正を待ちます。
(この後 Discord でも質問があり、修正された)
Vite を使っているようです。読みが当たった。
先に開いておいた Vite の Bundle Analyzer の導入手順の記事を見て、導入してみます。
また、設定を見てみると minify:false
になっていたので、true
にします。
Target オプションも、Chrome 最新版(chrome110)に変えておきます。
InlineLimit も引き上げられていたので、デフォルト値にしておきます。
とりあえず、Build の設定はこれでいいかなーと思います。
デプロイしておきます。
スコア計算をさせておきます。
80.00 でした。ほかにビルドしている人が少ないので、暫定 3 位。
次に、データベースを初期化します。が、エラー。
Windows 特有の、 /
が \
になってる問題でした。
直して初期化。
Polyfill 消し
さて、ようやく開発に取り組みます。
まずは polyfill を消すことにします。
polyfill
ディレクトリにいろいろ入っていたのですが、調べながらやってみると、全部消してはいけないみたいです。
core-js
と date-time-format-timezone
を消します。練習が活きてます。
ほかにもあったけど、後で消すことにします。
ベンチさせます。
font-awesome 軽量化
さて、analyze した結果から、font-awesome を軽量化します。
といっても、やることは一括インポートをやめて Tree Shaking させるだけ。
(ほんとは SVG で置き換えもしたいけど、スタイルの適用とかめんどくさいし、それほど軽量化も見込めないので、優先度は低い。)
1MB 超が 5KB、だいぶ減りました。
lodash 消し
さて、先ほどのベンチが終わっていないので、終了までの間に Lodash を消す作業に入ります。
isEqual が多用されていました。 The Vanilla JS Toolkit のコードをコピーさせていただきます。
これで 500KB ほど減りました。
lodash 消す作業が終わったところで、先ほどのベンチが終わりました。
104.62 点、暫定 4 位です。
font-awesome 軽量化と lodash 削除したコードをデプロイし、ベンチを回します。
zipcode を何とかする
この間に、zipcode-ja の遅延読み込みを実装します。
このライブラリが一番重いので。
ほかの郵便番号ライブラリもありましたが、高速化させるには API を作ったほうがよいと考え、 server 側に API を作ります。
Copilot の活躍もあり、すぐに実装できました。
ビルドしてみると、JS のサイズが 1MB を切りました!
さて、さっきのベンチが終わったので見てみます。
119.52 点、暫定 3 位です。
すかさず zipcode の API をデプロイしてみます。
ファイル分割
一連のベンチ回し作業が終わったところで、疑問が生じます。
「ビルド、1 つの JS しか生成されてないな...ファイル分割されてないっぽいわね。。」
ということで、分割してみます。
調べたところ、 manualChunks でやるのが手っ取り早いようです。
とりあえず、以下の分け方をしてみます。重いものをとにかく別ファイルに置く戦法です。
manualChunks(id) { if (id.includes('react')) return 'react'; if (id.includes('apollo')) return 'apollo'; if (id.includes('polyfill')) return 'polyfill'; if (id.includes('recoil')) return 'recoil'; if (id.includes('graphql')) return 'graphql'; if (id.includes('canvaskit')) return 'canvaskit'; if (id.includes('node_modules')) return 'vendor'; return 'index'; },
これでチャンクのサイズ警告がなくなりました。
見直しは必要ですが...。
zipcode を何とかする v2
先ほどのデプロイでなんかエラーが起き、ロールバックされました。process が kill されてるっぽいです。
分割も実装したところで、もう一回デプロイしてみます。
が、やっぱりメモリ不足でだめらしいです。別の方法を考えてみます。
とりあえず、 もぐもぐタイム に入ります。
戻ってきました。
郵便番号検索でエラーが出てると考えられるので、ほかの API を使ってみます。
ZIPCODA というのがあったので、これを使うことにします。
クライアント側では CORS が出てしまうので、サーバー側で API として fetch するようにしました。
これで問題なくデプロイできるようになったので、ベンチを回します。
ファイル分割 v2
なんかクライアント側でエラーが起きました。
vender-xxx.js
内で isElement
がないと言われてしまいました。
めんどくさくなったので、とりあえずチャンク分割をやめます。
画像の最適化
次にやりたいのは、画像の圧縮と最適化ですね。
なんかロゴの SVG が 14MB 超とめちゃくちゃ重いので、 SVG Optimizer で圧縮してみます。6.7KB になりました。
ほかの画像も圧縮します。まずは、PowerToys の ImageResizer でリサイズします。
商品画像は最大で横 1024px があれば足りるみたいです。
プロフィール画像は、52px x 52px でいいみたいです。一応、オプションとしては 100x100 に収まるように設定しました。
ここでベンチを回します。
というのも、Visual Regression Test が落ちたまま開発してたら怖いので。(VRT がないことに気づくのはまた後の話。)
と思ったのに、なんか Actions のタイムアウトで落ちてしまいました。すかさず再実行します。
130.07 点でした。
canvaskit を消す
デベロッパーツールのネットワークタブで、一番重くなっている読み込みを見てみます。
canvaskit
の WASM が一番重いようです。6.4MB もありました。
実装部分を見てみると、どうやら画像を canvas に書いて、そこからさらに Base64 に変換して、それを img タグに入れているようです。なんて非効率な...。
ということで、直接 img に src を入れるようにします。
これで canvaskit
パッケージもいらなくなったので、削除すると 200KB ほど減りました。
ベンチを回します。
"Web Speed Hackathon 2023" に挑戦中です! スコア 169.80 / 600.00 で、現在 3 位です github.com/CyberAgentHack… #WebSpeedHackathon
なんと、169.80 点で総合 3 位に浮上しました!
GraphQL を何とかする
ほかのファイルも見てみます。/graphql
からの応答も遅いようです。
これを非同期にしてみることにします。
ファイルを見てみると、 useSuspenseQuery
が使われているようです。
これを useQuery
にして、読み込み中なら Loading... と表示するようにします。
これでスコアはだいぶ上がりそうです。
(しかし CLS が増えたのでスコアが下がることに気づくのはまた後の話。)
キャッシュさせる
アクセスログを見てみます。
どうやら同じファイルに何度もアクセスしているようです。
静的ファイルに max-age
を設定して、キャッシュさせてみますか。
と koa のドキュメントを見てみると、 immutable オプションがあったので、それを設定しました。
コードの上部に cache-control: no-store
があったことに気づかず、えーなんでー??を繰り返してました (かなしい)
さて、ベンチを回してみましょう。
なんか計測できてないので、後で実行することにします。
ライブラリを減らす
ネットワークタブを見てみます。とりあえず重いファイルは graphql と bundle.js だけなので、bundle.js を軽量化することにしましょう。
先ほどコードを分割してみたらエラーになったので、まずは分割しない方向で減らしてみます。
@js-temporal/polyfill
を消したいよね~ということで、ネイティブの Date
と dayjs
に置き換えます。
動作確認をして、ベンチを回します。
続いて、recoil
を削除します。これは、useContext
で代用します。
こちらの Qiita の記事を参考にしました: 【React + Typescript】useContext の値を子コンポーネントから更新
めちゃくちゃ大変だった...
これで、bundle サイズが 562KB になりました。あとちょっと...!
zod を消します。
とりあえず一括インポートしているので、一部に直すことでどれだけ減るか試してみます。が、あまり減りませんでした。
正規表現で置き換えられる内容だったので、丸々消しちゃいます。
bundle サイズは 515.63KB...。ここからの道のりは長いなぁ...
ファイル分割 v3
置き換えられそうなものがない (というか、時間がかかりそう) ので、エラーが起きない範囲で分割してみます。
React を分けてしまうとエラーを吐かれたので、 ページ周りと apollo
と その他で分けて分割するようにした。
なんか採点システムがおかしかったらしいので、ベンチは休止中。
ということで夜のもぐもぐタイムにします。
さて、戻ってきました。スコアの算出方法が変更されています。
これまでのスコアも変わってます。まあ、この後スコアを爆上げする (はず) なので、気にしないことにします。
色々見てみる
やっぱり、 /graphql
が重いのでどうにかしたいよなぁ
とりあえず、CLS を減らすため、最初から高さを指定してあげます。
そのためにまずは features の section がどれだけあるかを取得する必要があります。
そこで GraphQL のクエリとして features_length
を追加しました。
そのうえで実装してみました。が、そこじゃない気がします。
クエリのリクエストを送らないと読み込みが始まらない or その他の処理に時間がかかってる気がする。
生成された HTML を見てみます。
<link rel="preload" as="video" fetchPriority="high" href="\videos\001.mp4" />
があるんだけど???
Vite.config.ts に動画が何とか書いてあったのはこのためだったのか...
消します。最初に動画を読む意味なんてないのでね。
ついでに、 wasm プラグインが入っているのも見つけましたが、これは canvas-kit で wasm を消したので、取り除いても問題ないはずです。
ファイル分割 v4
ほかに初期化にかかわってそうなものを探します。
やっぱり React を分割するとエラー吐くのが気になるよなぁ...
でたどり着いた React 公式のコード分割ページ 。
とりあえずコードごとに分けて、manualChunks の設定をなくしてみると、なんかめちゃくちゃ分割されてる。
なんか、Loading...と表示されるのが爆速になりました。
え、最初からこれでよかったのでは??
GraphQL を何とかする v2
さてさて、GraphQL に戻ってきます。
レスポンスを見てみると、うーん、無駄。
商品の情報は初期化とは別にクエリを投げてやればいいんじゃないかなぁ...と思い、実装。
(自分のサイトでも「GraphQL では、...必要なデータのみを取得することができます。この結果、データ通信量削減などの効果が期待できます。」って書いてあるやん。忘れてた。)
うまく実装できずに日付が変わりました。とりあえずこれだけは完成させたい...!
結局 useQuery などを使わず直接 FetchAPI を使うことにして完成させました。
1 時半です。眠い。寝ます。
404 ページを高速化
6 時半に目が覚めました。2 日目スタートです。
まず、一番手っ取り早そうな、 NotFound ページを直すことにします。
どうやらフォントが異なっているようです。Noto Serif Japanese を使っています。
サイト全体で使っているわけではないので、GoogleFonts から使う文字だけリクエストして使うことにします。
ついでに、Lighthouse で指摘された、ロゴの SVG に height, width が指定されてない問題も修正しておきます。
一応ローカルの (デバイスをデスクトップに設定した) Lighthouse では 97 点出ました。デプロイしてベンチを回しておきます。
161.90 点、暫定 10 位 (学生内 5 位)でした。本命の 404 ページのスコアが上がっていたのでよかったです。
商品詳細ページの高速化
次、(フローがないので) 商品詳細ページです。
どうやら映像があると、先に読み込んでしまって遅くなるっぽいです。
また、動画のサムネイル画像も Base64 で生成してしまっているみたいです。
あらかじめ画像を用意しておくことにします。
もちろん、画像のサイズも大きすぎるので、リサイズしておきます。
明らかに早くなりました。一応、別のサムネイル画像もリサイズしておきます。
さらに、画像を WebP に変換をしておきます。
映像も WebM に...と思ったんですが、なんか逆にファイルサイズが大きくなったので、リサイズするだけにしました。
ファイルのパスを変更する際に見つけた loading=eager
の属性を loading=lazy
に変更することもしました。
デプロイしてベンチを回し、朝のもぐもぐタイムにします。
戻ってきました。下がってた。まあ誤差の範囲内かなぁ
ログインフローの見直し
先ほどの結果を見てみると、ログインするフローがとてつもなくスコアが低いです。
DB をチューニングするかぁということで改善してみます。
とりあえず index を貼ったら高速化しそうなところにいろいろ貼ってみます。
また、気になったので、郵便番号 API は 7 桁のときのみ叩きに行くことにします。
なんか、ローカルだと速くなってる。けど、これは性能差なのか...?わからん。
ということでデプロイしてチェックしてみます。
うん、めちゃくちゃ速いわ。チューニングの勝利。
ベンチを回してみます。「ログインする」「初めてのユーザーが商品を買う」が上がっていればいいけど...。
うん????????思ったより上がらない...。
あー、user.email に index を貼ってなかったわ。
デプロイしなおして再挑戦。
うーん、あまり上がらず。むしろ下がってる。。
CSS-in-JS をやめる
次にやりたいと思っていたのは、CSS-in-JS をやめること。
CSS ファイルを新たに作り、そこに全てのスタイルを書いていきます。
とにかく地味で過酷な作業...全ファイルを一括変換!とかは(逆に実装が大変なので)できないので、手作業でやります。
と思ったけど、正規表現でファイルごとにはできるじゃん。
export const (.+) = \(\) => css`\n((.+\n)+)`; ↓ $1 { $2 }
styles\.(.+)\(\) ↓ styles.$1
1 時間半をかけて、全ファイルを変換しました。疲れた。。。
一応、@emotion/css
も消しておいて、完了...!
これで JS の実行時間はかなり減るはず。
だいぶ上がりました。暫定 10 位です。とりあえず 200 点を超えたい。
問題を見直す
客観的に Lightscore を見るため、GTmetrix でベンチを回してみます。
まずは 404 ページ。
やっぱり JS の実行時間が長い。とりあえず apollo
と lodash
は別ファイルにしておく。
ロゴで CLS が発生してるみたい。loading=eager
にしてみる。
残り時間が少なくなってきたので、とりあえずパフォーマンス改善はあきらめ、CLS あたりを改善します。
まずトップページ、ちょっとガタガタしてたので高さを微調整します。
次に、react-lazy-load-image-component
を使って、画像の遅延読み込みを実装します。
あとホーム画面向けに画像を小さくしておきます。時間がないので WebP はあきらめ。
a タグ → Link タグ
ほかに何かないかなーと調べてみると、あることに気づきます。
「あれ、リンク踏んだら一からリロードされてね?」
自分が作った (この React(Next.js) 製) サイトでリンクを踏むと、ヘッダーなどは保存されていますよね。
でもなぜか、コンテンツが一から読み込まれているのです。
どおりで購入フローが遅いわけだ。
ということで <a>
を <Link>
に置き換え、期待する動作になるようにします。
apollo → urql
うーん。。。
もう上がりそうにないので、JS の最適化をすることにします。
分析すると、 @apollo/client
が重いようなので、調べたら出てきた urql
に置き換えます。
調べながらの実装なので、ちょっと時間がかかりましたが何とか完成しました。
デプロイしてベンチをします。
え、かなしい。。。
でもめちゃくちゃスコアが伸びてます。
再試行します。
formik
なんだかんだで気づいたら 16 時。残り時間は 1 時間半。
昼のもぐもぐタイムは取っていません (空腹感仕事しろ!)
ベンチを再試行する間に、次は formik ですかね...
これはフォームを管理してるみたいです。
普通に useState とかでできそう?
実装しました。デプロイします。
スケーリングと微調整
さっきのベンチ、やっぱり落ちてます。
ローカルで試してみると、なんかエラーになりました。
凡ミスでした。urql
に渡す引数を変え忘れていました。
再度デプロイして、ベンチを回します。
これで「採点できませんでした」になったらキレます
コラ~!
サーバー側のログを見てみると、メモリ不足みたいです。こまった。
あと 30 分。デプロイするサービスを切り替えている時間はないので、一時的に拡張をしてみます。
また、少しでも早くするためサーバーのログ機能を消しておきます。
299.15 点!全体 7 位 (学生内 5 位) です!
300 点行きたすぎるので、最後にコード分割を試してみてみます。
来ました!300 点!全体 6 位 (学生内 4 位)!
もうちょい上がらないかな~と思い、最後に滑り込みでベンチを回します。
下がった。賭けに負けました。かなしい 🥺🥺🥺
解説と結果発表
解説が始まります。
かなりわかりやすく勉強になりました。
CLS の重みが高まっているのは知りませんでした。今後注意して情報を入れていきたいと思います。
「言われればそうじゃん!」になることも多く、特に ReDoS は気づきたかった。。
だからあんなにログインのフローが点数低いのかぁ
なんか重いな~とか思ってたけど、全然気づかなかったので今後は気をつけます。
それと、最後まで development でビルドしてたみたいで、なんで気づかなかったの?という気持ちに。
さて、いよいよ結果発表です。
あの。。。。。。。。。。かなしい
原因は
存在しないユーザーでログインしたとき、エラーが表示されずにモーダルが閉じてしまいます
えー。確かに違和感あったのに気づかなかった。
最後に formik を消したときに、なんかモーダル閉じるけどそういう仕様かなぁになってたのでマジで悔しい。
チェックリストを見なさい。気づけていれば総合 3 位になっていたかもしれないのに。。。
終了後 30 分くらいずっとかなしいしか言ってませんでした。。。
「「違和感仕事しろ!!!!」」
まとめ
こういう時に限って違和感が仕事しなかったな~~ レギュレーション違反になっちゃったのめちゃくちゃ悔しいけど初参加でここまで来れたの楽しかったです!ありがとうございました! #WebSpeedHackathon
楽しかったです!来年も参加します!
かなり手が痛いです。。2 日間もぶっ続けてキーボード打つことあまりないのでね。。
腱鞘炎になってそう。 (後日追記: まじでなりました...)
休憩は取りましょう。
競技でやったことをまとめたリポジトリです: a01sa01to/web-speed-hackathon-2023
さすがに競技中にアップはしませんでしたが...。
Tag で競技中どの段階でデプロイしたかが見れます。Diff も見れます。ぜひ。