web-speed-hackathon2024 の改善メモ
web-speed-hackathon2024に参加しました
結果はというと、惨敗も惨敗で、スコア測定まで持って行けませんでした... orz
理由としては、明白で配られたコードを見て、
- コンポーネント単位で行なっているfetch
- layoutコンポーネントなるもの
- ユーザー操作が必要なのは、searchと閲覧ページ
- 慣れないpureなReact
これらの点を踏まえて、初手からAppRouter(Next.js)への移植を決意し、2日間臨んでましたが結果は本番までにはテストが通るような成果物まで持っていけず、未提出の結果になりました
敗因、反省点
結果的には、
未提出の結果になりましたが、今回のアプリのAppRouter移行は2日間という制約がなければそこまで筋が悪いものではなかったと思います。(後述する延長チューニングで機能しています)
ですが、限られた時間の中で最適なチューニングを行うことが今回のミソで、その上で今回の戦略は間違いでした。
今回の大きな反省点は以下の3つです。
-
ボトルネックを多く抱えた状態のAppRouterのビルドは大変時間がかかる
基本的にAppRouterのビルドは各ページの表示スピードに依存する。そのため最初から全てのページの移行を始めると、ビルドを行うのにとてつもない時間がかかる。今回だと初期状態なら30分~1時間ぐらいかかっていた。 -
AppRouterに完全移行しなければ、スコア計測できない状態で作業していた
完全移行or移行しないの状態で移行を進めていたが、これを行うと他の作業に移りたくなっても移れなくなる。今回はCloudflareから配信していたので、パスベースで接続先のwebアプリを切り替えるなどして段階的に移行をするべきだった -
フレームワークと関係しない大きなボトルネックを見つけるために、きちんと計測から入る
フレームワークの力で既存のボトルネックが解決されることを望んで、あまり念入りに計測などを最初に行わなかった。ただ例えば今回の(1番か2番を競う)大きなボトルネックであるN+1問題のようなfetchは、フレームワークに関係しないのでより高い優先順位で修正するべきだった
延長チューニング
流石に、本番で何もできなかったので延長してチューニングを行いました。
本番以外ではスコア計測が出来ないため、フローのスコアのみで計算される/adminページを抜いた全てのページのチューニングを行いました
next移行関連のimgタグやLinkタグの変更などを除いたチューニング箇所について、追記します。
参考値として、初期状態のラボデータを共有します
/ ホームページ
fetchの数を最低限の数に間引く
他のページでも同様ですが今回のアプリは、ページのルートでfetchを行いさらにその子コンポーネントで再度fetchを行う構成になっていました。
そのため、ページのルートでfetchを行いそれをそのまま、子コンポーネントにobjectを渡す形にしました。
server側の画像処理をcache
今回のアプリは、APIサーバーを経由してブラウザに画像が表示されます。
その際に、指定したwidthとheightとformatをもとにサーバー側で加工を行います。
これのせいで、画像をfetchするだけで、2000ms ~ 3000msほど時間がかかりました。
これを解決するためには、予想できるwidth,height,formatに関して加工後のデータを保存することで解決しました。
(このコードで一度加工した画像を保存します。)
ページでgetImageUrlが利用されているformat,width,heightと今回利用されている画像id全ての組み合わせを実行する関数を作成し、画像の加工を全てcacheさせるようにします。
const createImages = async() => {
/**
* @type {string[]}
*/
let files = [];
try {
// ディレクトリ内のファイル名を同期的に読み込む
files = fs.readdirSync(directoryPath);
console.log(files);
} catch (err) {
console.log('Error reading the directory:', err);
}
const formats = ['avif', 'webp','jpg']
let i = 0;
const sizes = [{width: 32, height: 32}, {width: 64, height: 64}, {width: 96, height: 96}, {width: 128, height: 128}, {width: 192, height: 256}, {default: true}]
for (const file of files) {
i++
console.log('process', `${i} / ${files.length}`, `percent: ${Math.floor((i/files.length)*100) }%`)
const filename = file.split('.')[0]
const originFormat = file.split('.')[1]
for (const format of formats) {
for (const size of sizes) {
const input = {}
if (format !== "") {
input.format = format
}
if (!size.default) {
input.height = size.height
input.width = size.width
}
input.imageId = filename
const url = getImageUrl(input)
console.log('url', url)
const fetched = await fetch(url)
await sleep(100)
// console.log('fetched', fetched)
}
}
}
}
ページで動的に作成されている画像をcacheする / svg画像の軽量化
これらの画像を保存/変換などをし、publicディレクトリで参照可能にしました
不要な外部ライブラリを自前のものなどに変換する
ページ全体で、@mui/icons-materialを利用しており大きなボトルネックでしたが、そもそも利用されているIconは一部(なのにtree shakingが効かないimportの仕方)で、また特にこのライブラリを利用する理由もないので、 同じsvgiconを https://fonts.google.com/icons?icon.set=Material+Icons から取得しコンポーネントを作成していきました。
また、momentやlodashなども不必要(標準ライブラリでカバー可能)でしたので、同様に削除しました
CSSで表現可能なビジュアル表現をCSSに移譲する
今回のアプリではCSSで表現可能なことがちらほら散りばめられています。
それらの処理を削除し、CSSで表現しました。
適切なSuspense/dynamic
今回は初期コードからSuspenseが使われていますが、ルートコンポーネントをSuspenseで挟むなどの使われ方しかされておらず、意味がありません。
そのようなコードを削除し、適切な場所でsuspense/dynamicを行うようにし、CLSの懸念があるところ(例えばfetchなどでfooterがチラ見えする)に関しては、heightを差し込むようにしました。
Footerの巨大文章の分離
Footerが大量の文章をimportで参照しており、
それをボタンが押下された際に、その内容が追加されたdomをdialogコンポーネントに追記します。
Footerコンポーネントは全てのコンポーネントに利用されており、
可能な限りバンドルサイズを減らしたい。
そこで、nextのapiRoutesにapiに内容を記載し、fetchを行うようにしました。
(実は、最初の改善ではfooterdialogの内容でページを作成し、それをiframeで切り分けるという処理にしていましたが、今回のアプリのE2Eテストと相性が悪そうなのでやめました)
CDNをcloudflareで頑張る
例年通り、originに関しては自由なのでcloudflareで配信することにしました。
とりあえず、h3配信になるし、キャッシュの自由度なども広がるし...と考え、最初に設定しました
/books/[bookId] 作品詳細ページ, authors/[authorId] 作者詳細ページ
このページもホームページと同様に
- fetchの数を最低限の数に間引く
- server側の画像処理をcache
- CSSで表現可能なビジュアル表現をCSSに移譲する
- 適切なSuspense/dynamic
が上手く効き、両方とも100になったので特に変わったことはやってません。
/books/[bookId]/episodes/[episodeId] ページビュワー
- server側の画像処理をcache
- CSSで表現可能なビジュアル表現をCSSに移譲する
- 適切なSuspense/dynamic
などは同様にうまく作用しました。
ただ結果としてこのページはスコアが他と比べて良くなく、チューニングしきれてないのを感じます
(TBTがある閾値を超えるまではずっと0になってしまうので、スコアとして結果が出るのが遅い...)
それに加えて下記のチューニングを行いました。
ページビュワー部分以外をlayoutとして抜き取る
episodesは、episodeIdが必要な箇所はビュワーの場所のみで、
episodeListなどは使いまわせます。そのためNextが不必要に再レンダリングなどが発生しないようにlayoutとして抜き取りました。
マウスイベントの発火を間引く
元々、
スクロールが終わると、計算結果を反映する方式になってましたが、
スクロールエンドの際に計算を行うように変更しました。
また、元々、passiveがfalseだったものをtrueにすることや、
どうせスクロールエンドに計算するので、世界の変化の観測()を減らしました。
/search 検索ページ
searchはadminページと同様に、競技終了後は計測できないですが、
明らかなボトルネックは判明しているためついでに、改善しました。
フロントでフィルタリング処理を行わないようにする
検索ページでは、本の名前か本のルビにヒットするものを、全ての本をフィルタリングして表示します。
ただ全ての本を取得し、それをフロントでフィルタリングするのは、通信のデータ量も極端に増え、またフロント側のCPUに頼ったボトルネックを生んでしまいます。
こういう場合、ぱっと思いつくのがバックエンド側によるSQLによるフィルタリングですが、
簡単にはいかないのがこのisContainsで行なっている内容です。
isContainの内容は、カタカナの「フ」とひらがなの「ふ」を同一視するなど、曖昧検索を可能にしています。
このアプリのデータベースは、SQLiteで実装されており、素直にフロントから受け取ったkeywordを、バックエンドへそしてSQLへ送ったとしても、ふとフは別の言葉として処理されてしまいます。
SQLiteではなく、MySQLなどであれば、COLLATE
演算子の自由度が高く、SQL内で完結してここら辺の処理を記述できるのですが、SQLiteでは出来ません。
この関数の説明通りであれば、ひらがな、カタカナ、半角、全角のみのパターンを網羅すれば良いらしいので、
全てのパターンを網羅するようにし、WHERE LIKEで、部分一致でフィルタリングを行えるようにしました。
Discussion