🙆

web-speed-hackathon2024 の改善メモ

2024/05/23に公開

web-speed-hackathon2024に参加しました

https://github.com/CyberAgentHack/web-speed-hackathon-2024
こちらのweb-speed-hackathonに参加していました。
結果はというと、惨敗も惨敗で、スコア測定まで持って行けませんでした... 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の数を最低限の数に間引く

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/main/workspaces/app/src/pages/TopPage/index.tsx#L59-L60
https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/main/workspaces/app/src/features/ranking/components/RankingCard.tsx#L44-L45
他のページでも同様ですが今回のアプリは、ページのルートでfetchを行いさらにその子コンポーネントで再度fetchを行う構成になっていました。

https://github.com/imamiya-masaki/web-speed-hackathon-2024-private/blob/main/workspaces/next-app-router/src/app/page.tsx#L47
https://github.com/imamiya-masaki/web-speed-hackathon-2024-private/blob/main/workspaces/next-app-router/src/_components/src/features/feature/components/FeatureCardList.tsx#L9

そのため、ページのルートでfetchを行いそれをそのまま、子コンポーネントにobjectを渡す形にしました。

server側の画像処理をcache

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/main/workspaces/server/src/routes/image/index.ts#L108-L130

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/main/workspaces/app/src/features/feature/components/FeatureCard.tsx#L53

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/main/workspaces/app/src/lib/image/getImageUrl.ts#L8

今回のアプリは、APIサーバーを経由してブラウザに画像が表示されます。
その際に、指定したwidthとheightとformatをもとにサーバー側で加工を行います。
これのせいで、画像をfetchするだけで、2000ms ~ 3000msほど時間がかかりました。

これを解決するためには、予想できるwidth,height,formatに関して加工後のデータを保存することで解決しました。
(このコードで一度加工した画像を保存します。)
https://github.com/imamiya-masaki/web-speed-hackathon-2024-api/blob/main/workspaces/server/src/routes/image/index.ts#L221-L223
ページで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画像の軽量化

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/main/workspaces/app/src/pages/TopPage/internal/HeroImage.tsx#L38-83

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/1337939986070d89da3e28da82c441dcbe2a7625/workspaces/app/src/foundation/components/Footer.tsx#L115

これらの画像を保存/変換などをし、publicディレクトリで参照可能にしました

不要な外部ライブラリを自前のものなどに変換する

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/1337939986070d89da3e28da82c441dcbe2a7625/workspaces/app/src/features/icons/components/SvgIcon.tsx#L10

ページ全体で、@mui/icons-materialを利用しており大きなボトルネックでしたが、そもそも利用されているIconは一部(なのにtree shakingが効かないimportの仕方)で、また特にこのライブラリを利用する理由もないので、
https://github.com/imamiya-masaki/web-speed-hackathon-2024-private/blob/main/workspaces/next-app-router/src/_components/internal/CoverSection.tsx#L57
同じsvgiconを https://fonts.google.com/icons?icon.set=Material+Icons から取得しコンポーネントを作成していきました。

また、momentやlodashなども不必要(標準ライブラリでカバー可能)でしたので、同様に削除しました
https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/1337939986070d89da3e28da82c441dcbe2a7625/workspaces/app/src/pages/TopPage/index.tsx#L21
https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/1337939986070d89da3e28da82c441dcbe2a7625/workspaces/app/src/pages/TopPage/index.tsx#L1

CSSで表現可能なビジュアル表現をCSSに移譲する

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/1337939986070d89da3e28da82c441dcbe2a7625/workspaces/app/src/foundation/components/Separator.tsx#L16

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/1337939986070d89da3e28da82c441dcbe2a7625/workspaces/app/src/foundation/hooks/useImage.ts#L24

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/1337939986070d89da3e28da82c441dcbe2a7625/workspaces/app/src/pages/TopPage/internal/HeroImage.tsx#L94-L107

今回のアプリではCSSで表現可能なことがちらほら散りばめられています。
それらの処理を削除し、CSSで表現しました。

適切なSuspense/dynamic

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/1337939986070d89da3e28da82c441dcbe2a7625/workspaces/app/src/pages/TopPage/index.tsx#L88-L90

今回は初期コードからSuspenseが使われていますが、ルートコンポーネントをSuspenseで挟むなどの使われ方しかされておらず、意味がありません。

そのようなコードを削除し、適切な場所でsuspense/dynamicを行うようにし、CLSの懸念があるところ(例えばfetchなどでfooterがチラ見えする)に関しては、heightを差し込むようにしました。

Footerの巨大文章の分離

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/1337939986070d89da3e28da82c441dcbe2a7625/workspaces/app/src/foundation/components/Footer.tsx#L27

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/1337939986070d89da3e28da82c441dcbe2a7625/workspaces/app/src/foundation/components/Footer.tsx#L6

Footerが大量の文章をimportで参照しており、
それをボタンが押下された際に、その内容が追加されたdomをdialogコンポーネントに追記します。

Footerコンポーネントは全てのコンポーネントに利用されており、
可能な限りバンドルサイズを減らしたい。
そこで、nextのapiRoutesにapiに内容を記載し、fetchを行うようにしました。
https://github.com/imamiya-masaki/web-speed-hackathon-2024-private/blob/11aa83f289aae8e3730874fd4d3e68347e5952a6/workspaces/next-app-router/src/_components/src/foundation/components/FooterContent.tsx#L47

(実は、最初の改善ではfooterdialogの内容でページを作成し、それをiframeで切り分けるという処理にしていましたが、今回のアプリのE2Eテストと相性が悪そうなのでやめました)

CDNをcloudflareで頑張る


例年通り、originに関しては自由なのでcloudflareで配信することにしました。
とりあえず、h3配信になるし、キャッシュの自由度なども広がるし...と考え、最初に設定しました

/books/[bookId] 作品詳細ページ, authors/[authorId] 作者詳細ページ



このページもホームページと同様に

  • fetchの数を最低限の数に間引く
  • server側の画像処理をcache
  • CSSで表現可能なビジュアル表現をCSSに移譲する
  • 適切なSuspense/dynamic

が上手く効き、両方とも100になったので特に変わったことはやってません。

/books/[bookId]/episodes/[episodeId] ページビュワー

などは同様にうまく作用しました。
ただ結果としてこのページはスコアが他と比べて良くなく、チューニングしきれてないのを感じます
(TBTがある閾値を超えるまではずっと0になってしまうので、スコアとして結果が出るのが遅い...)
それに加えて下記のチューニングを行いました。

ページビュワー部分以外をlayoutとして抜き取る

https://github.com/imamiya-masaki/web-speed-hackathon-2024-private/blob/11aa83f289aae8e3730874fd4d3e68347e5952a6/workspaces/next-app-router/src/app/books/[bookId]/episodes/layout.tsx

episodesは、episodeIdが必要な箇所はビュワーの場所のみで、
episodeListなどは使いまわせます。そのためNextが不必要に再レンダリングなどが発生しないようにlayoutとして抜き取りました。

マウスイベントの発火を間引く

元々、
https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/main/workspaces/app/src/features/viewer/components/ComicViewerCore.tsx#L147
マウスの動きに合わせて最終的な変化量を計算し続け、
スクロールが終わると、計算結果を反映する方式になってましたが、
https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/main/workspaces/app/src/features/viewer/components/ComicViewerCore.tsx#L199

スクロールエンドの際に計算を行うように変更しました。
https://github.com/imamiya-masaki/web-speed-hackathon-2024-private/blob/main/workspaces/next-app-router/src/_components/src/features/viewer/components/ComicViewerCore.tsx#L164

また、元々、passiveがfalseだったものをtrueにすることや、
https://github.com/imamiya-masaki/web-speed-hackathon-2024-private/blob/main/workspaces/next-app-router/src/_components/src/features/viewer/components/ComicViewerCore.tsx#L186-L190

どうせスクロールエンドに計算するので、世界の変化の観測()を減らしました。
https://github.com/imamiya-masaki/web-speed-hackathon-2024-private/blob/8b5e06e52c9942ce22362010a9c7e3cbd8d29ae3/workspaces/next-app-router/src/_components/src/features/viewer/components/ComicViewerCore.tsx#L37

/search 検索ページ


searchはadminページと同様に、競技終了後は計測できないですが、
明らかなボトルネックは判明しているためついでに、改善しました。

フロントでフィルタリング処理を行わないようにする

https://github.com/CyberAgentHack/web-speed-hackathon-2024/blob/main/workspaces/app/src/pages/SearchPage/internal/SearchResult.tsx#L21-L22

検索ページでは、本の名前か本のルビにヒットするものを、全ての本をフィルタリングして表示します。
ただ全ての本を取得し、それをフロントでフィルタリングするのは、通信のデータ量も極端に増え、またフロント側のCPUに頼ったボトルネックを生んでしまいます。

こういう場合、ぱっと思いつくのがバックエンド側によるSQLによるフィルタリングですが、
簡単にはいかないのがこのisContainsで行なっている内容です。
isContainの内容は、カタカナの「フ」とひらがなの「ふ」を同一視するなど、曖昧検索を可能にしています。

このアプリのデータベースは、SQLiteで実装されており、素直にフロントから受け取ったkeywordを、バックエンドへそしてSQLへ送ったとしても、ふとフは別の言葉として処理されてしまいます。
SQLiteではなく、MySQLなどであれば、COLLATE演算子の自由度が高く、SQL内で完結してここら辺の処理を記述できるのですが、SQLiteでは出来ません。

この関数の説明通りであれば、ひらがな、カタカナ、半角、全角のみのパターンを網羅すれば良いらしいので、
全てのパターンを網羅するようにし、WHERE LIKEで、部分一致でフィルタリングを行えるようにしました。

https://github.com/imamiya-masaki/web-speed-hackathon-2024-api/blob/d06ab36f45d43b7c367421bd308c505ee66adc26/workspaces/server/src/routes/api/books/getBookList.ts#L81-L86
https://github.com/imamiya-masaki/web-speed-hackathon-2024-api/blob/d06ab36f45d43b7c367421bd308c505ee66adc26/workspaces/server/src/repositories/book.ts#L113-L116

Discussion