本記事は CyberAgent Developers Advent Calendar 2022 21 日目の記事です。
2023 年度入社予定の加藤 零(@cut0_) です。現在は株式会社 WinTicket で内定者アルバイトをしています。Web Speed Hackathon 2022 Public では 499.1 点を記録しました。
お疲れさまでした!
届かなかった残り 0.9 点を噛み締めて社会人エンジニアを迎えようと思います。
CDN 使わずに Heroku 単体でもここまで戦えます!!https://t.co/ikVmhml6iq #WebSpeedHackathon— レイ (@cut0_) November 27, 2022
本記事では、Web Speed Hackthon 2022 Public で取り組んだことに加え、これから Web Speed Hacktahon に参加する方に向けた戦略を紹介させていただきます。
Web Speed Hackathon 2022 Public に参加していない方でも楽しんでもらえるような記事に仕上げたので、最後までお付き合いいただけるとうれしいです。
TL;DR
- Web Speed Hackathon の戦略を紹介させていただきます。今後 Web Speed Hackathon に参加する方はぜひ参考にしてください。
- Web Speed Hackathon 2022 Public で実際に取り組んだことや、取り組めなかったことを順番にいくつか説明します。
Web Speed Hackathon とは
“Web Speed Hackathon” は、非常に重たい Web アプリをチューニングして、いかに高速にするかを競う競技です。今まで学生向けや社内向け、一般向けに様々なテーマで開催されてきました。
今回のテーマは架空のベッティングサービスとなっております。
詳しくはこちらの記事を御覧ください。
事前準備
Web Speed Hackathon に取り組む前の事前準備を説明させていただきます。
Lighthouse のスコア測定について知る
Web Speed Hackathon は Lighthouse v8 Performance Scoring に基づいてスコアが計算されており、スコアは下記の項目によって構成されます。それぞれの指標に関する説明はここでは割愛させていただきますが、参考になる有用な記事を掲載します。
- FCP (First Contentful Paint)
- SI (Speed Index)
Speed index (スピードインデックス) – MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN
- LCP (Largest Contentful Paint)
Largest Contentful Paint (LCP)
- TTI (Time to Interactive)
- TBT (Total Blocking Time)
- CLS (Cumlative Layout Shift)
Leaderboard の仕組みを知る
Web Speed Hackathon のスコアは web-speed-hackathon-2022-leaderboard のようなリーダーボードリポジトリによって測定されます。具体的な流れは次のようになります。
- 計測対象の URL を issue に登録する
- issue に
/retry
とコメントするとスコアの再計測が走る - VRT が実行され計測対象のページに差異が発生していないことを確認 (ここで差異があった場合、スコアは測定されますがランキングには反映されません)
- スコアの測定。スコアは Lighthouse v8 Performance Scoring と同様に次のような式で計算される
FCP × 10 + SI × 10 + LCP × 25 + TTI × 10 + TBT × 30 + CLS × 15 = ページのスコア
5. ランキングに反映
スコアの大小に関わらず、測定した最新のスコアがランキングに反映されるため、順位が下ることもあります。
スコアは計測毎に上下することがあるので、ランキングを下げずにスコアを測定したい場合は、web-speed-hackathon-2022-leaderboard を fork して計測するのが良いでしょう。
また、中身のコードを覗いてみると、このような 記載があることが確認できます。fork したリポジトリで WSH_SCORING_DEBUG
を true
にして環境変数を渡すことで各ページの詳細なスコアがわかります。Web Speed Hackathon 終盤になると、ローカルの PC で計測されるスコアとリーダーボードが計測するスコアに差異が発生することがあるため、有用になってきます。
Heroku を知る
Heroku は HTTP2 以降を採用しておらず、マシンパワーも高くないためパフォーマンスのボトルネックになります。しかし、もともと Web Speed Hackathon 2022 Public は Heroku の無料プラン終了の告知を受けて開催されました。そのため、今回は Heroku を積極的に利用して CDN を利用しない方針にしました。
Heroku はリージョンを選択してデプロイすることができます。また、GitHub Actions のIP はこちらから主に US で実行されていることが確認できます。そのため、Heroku のリージョンは計測位置と地理的距離が近い US を選択するのが良いと思います。CDN を利用していないため計測毎のスコアのブレは抑制できませんでしたが、結果的に比較的高いスコアを記録することができました。
作業開始
それでは、Web Speed Hackathon に取り組んでみましょう。対象のリポジトリはこちらになります。また、開催当時の状況を再現した激重サイトをこちらに用意させていただきました。記事を読みながら参考にしていただけると幸いです。
実際にこれから手を動かして今から取り組まれる方は、こちらの部分を修正するか、yarn init:seeds
コマンドに引数を渡して開催当時のデータを再現すると良いでしょう。
コードを書く前にサイトを眺める
まずは、サイトを眺めてみましょう。当然ですが、読み込み速度が明らかに遅いことがわかります。Lighthouse で計測してみたスコアは以下のとおりです。芸術的ですね。
スコアを確認してみると、CLS の値が悪くなっているため、単なる高速化以外にも改善点があることがわかります。CLS は各ページのスコアの 15 点を締めており、500点満点中 75 点にあたるため絶対に改善したいですね。
- 画像最適化
まず、画像の読み込み速度が明らかに遅いことがわかります。なので、画像を圧縮してフォーマットを変換しましょう。squoosh cli を利用して、圧縮と avif への変換を行いました。
今回はリポジトリ内に画像ファイルが同梱されておりましたが、外部の画像ファイルを参照している場合は Cloudflare Images 等の画像 CDN を利用するのが良いでしょう。
また、画像最適化とはいえ限度があるので、VRT が通る程度に留めて次の作業に取り組みましょう。
- Webpack Bundle Analyzer
次に、Webpack Bundle Analyzer を利用します。Webpack Bundle Analyzer とは Webpack が出力するファイルサイズをツリーマップで視覚化するツールです。
Web Speed Hackathon の序盤は明らかに大きいパッケージを削除したり、置き換えたりすることでスコアが格段に伸びます。実際に確認してみると zengin-code、 @fortawesome/fontawesome-free 等が大きいことがわかります。
コードをいじる
次に実際にコードを触ってみます。参加者のほとんどの方が Web Frontend を生業としていると思うのでクライアントサイドから改善しましょう。
クライアントサイド
Web Speed Hackathon は基本的に、これから説明する順番で改善するのが良いと思います。
まずは一般的な改善手法を紹介するので、今回 Web Speed Hackathon に参加していない方も参考にしていただけるとうれしいです。
ビルド設定
Webpack のビルド設定周りは Web Speed Hackathon では頻出です。最適化を有効化するため devtool オプションを削除し、 mode
に "production"
を設定しました。これにより、ソースマップの出力を無効にし minify が有効化されます。
また、@babel/preset-env の設定を変更し CommonJS に変換されないようにすることで、適切な Tree Shaking が行われるようにします。
この設定を加えるだけでもかなりのサイズを削ることができます。
パッケージの削除
次に削除したパッケージを紹介させていただきます。序盤はパッケージを削除・置換することでスコアが劇的に向上するため、積極的に取り組みましょう。特に axios や lodash、moment.js は Web Speed Hackathon でよく利用されているパッケージなので、置換方法も事前に覚えておくと良いでしょう。
コードに関する細かい説明をすると、きりがなくなってしまうため、ここでは割愛させていただきます。
- Polyfills
Polyfill に関するコードはこちらに記載されています。今回は Chrome の最新バージョンで動けばレギュレーションに違反しないため、すべて削除しました。
- axios
Fetch API に置き換えました。Fetch API はネットワークエラー等の通信路に関するものでないと Error を throw しないため注意が必要です。
上記のことに気づけないとこちらの部分の挙動が変わり、チェックリストの
残高が不足した状態で、拳券の購入ダイアログの購入ボタンをクリックした場合、残高が不足のエラーが表示されること
に違反してしまいます。
- moment.js
今回は利用箇所が多くなかったためすべて素の Date オブジェクトを利用しました、date-fns や dayjs に置換するのも有効です。
- lodash
lodash はすべて素の JavaScript に置き換えました。今回はそこまで利用箇所が多くなかったため lodash のドキュメントを読めばすんなり置き換えられます。
- framer-motion
framer-motion によるアニメーションは CSS で置き換えて、styled-components に記載しました。
- zengin-code
zengin-code はチャージダイアログを開いたときに、銀行の支店を選択するために利用される統一金融機関コードデータセットです。対策としては 2つの手法が考えられます。どちらの方法もそこまで工数は変わらないと思いますが、今回は後者の方法で対応しました。
-
-
- ダイアログが開かれた際に lazy import する。Webpack はデフォルトで動的 import するパッケージをチャンクし、利用されるまで読み込まれないようにします。
/zengin-code
のようなエンドポイントを生やしてバックエンドから配信する。
-
- fortawesome/fontawesome-free
こちらは素の svg アイコンに置き換えました。i タグを検索して対応するのがかんたんで良いと思います。うまく置き換えられていなくてもおそらく VRT でチェックしてくれます。
- react-router
wouter という Zero dependency なライブラリに置き換えました。今回はページ量もそこまで多くないため比較的容易に置き換えられました。また、wouter は Preact 版も提供しているため採用しました。
- react、react-dom
Preact に置き換えました。preact/compat も削除しようと思いましたが時間の都合上対応しませんでした。Preact 自体は Webpack の alias を設定することで利用できます。
スタイルの修正
次に styled-components を利用している部分を中心に改善します。
CSS の書き換え
framer-motion の置き換えでも述べさせていただきましたが、可能な限り CSS に置き換えましょう。
特に注意が必要なのはチェックリストの
銀行コードを入力し、銀行名が fade-in アニメーションで表示されること
です。
animation-delay
を利用することで順に表示されるようにしました。
const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`;
const ItemWrapper = styled.li`
animation: ${fadeIn} 1s cubic-bezier(0.2, 0.6, 0.35, 1);
animation-delay: ${({ $index }) => $index * 100}ms;
animation-duration: 500ms;
animation-fill-mode: forwards;
background: ${Color.mono[0]};
border-radius: ${Radius.MEDIUM};
opacity: 0;
padding: ${Space * 3}px;
`;
今回のハッカソンでは、出題者がわざと JS を利用してスタイリングを実現しているところが何箇所かあります。それらは CSS に書き換えることでパフォーマンスを向上できます。
Web Speed Hackathon では「可能な限り CSS でデザインを実現する」という心持ちで臨んでしまって良いと思います。
styled-components の設定
disableCSSOMInjection
は今回不要であるため削除しましょう。
該当箇所はこちらです。
styled-components の修正
props を利用して動的にスタイリングしている部分はレンダリング時に値を評価してクラスを生成します。そのため、無数に props のパターンがある場合にはパフォーマンスが悪化してしまいます。
具体的にはこちらの部分です。今回は付け焼き刃ではありますが、style タグに直接スタイルを書くことで改善しました。
webpack の設定
babel-plugin-styled-components を利用しました。こちらを利用することで minify や SSR の設定を利用することができます。
フォント最適化
今回はオッズページで Senobi-Gothic というフォントが利用されていました。数字とカンマのみを利用していたため、subset-font を利用しサブセット化し形式を woff2 に変換しました。
CLS改善
前述のとおり、CLS はスコアにおいて大きな割合を占めているため絶対に改善した方が良いです。満点を目指しましょう。Web Speed Hacathon では、div タグを利用してスケルトンを作成しコンテンツが表示されるまで変わりに表示するだけで良いと思います。
前述の改善例がある程度落ち着いた段階で一旦気分を切り替えたいなというときに対応するのが良いと思います。
ここからは、今回のハッカソン特有の具体的な改善例をいくつか紹介させていただきます。Web Speed Hackathon では出題者が意図的にパフォーマンスを悪化させる処理を記載しているため、それらを感知できる嗅覚が必要になります。今回紹介した例をもとに、雰囲気をなんとなく掴んでいただけると幸いです。
画像コンポーネントの修正
画像の表示に TrimmedImage コンポーネントが利用されています。canvas 要素を利用して表示していますが、画像配置の計算が TBT を悪化させているため、object-fit
やobject-position
プロパティを利用してCSS に置き換えてしまいましょう。
置き換えに不安があるかたは VRT を利用して確認しましょう。
また、レースページ等では TrimmedImage は最大コンテンツの画像以外にも選手やレースの画像などで利用されているため、img タグにloading 属性を設定し props で eager や lazy を渡せるようにすると良いと思います。
Hero 画像の配信
API からトップページで利用されるヒーロー画像の url が配信されています。今回はヒーロー画像は複数枚あるわけでもないので、他の画像と同様にクライアントから img タグの src に URL を設定して問題ありません。
レース開催時刻の取得
投票ステータス(締め切り XX 分前)がリアルタイムに更新されていること
を実現するために setInterval
に 0 ミリ秒を指定してステートを更新していました。レースの開催時刻は一分単位で表現されるため、1000 ミリ秒あたりを設定しておけば問題ありません。
該当箇所はこちらです。
レース一覧の取得
トップページでレースの一覧情報を表示するためにこちらで、全レースの情報を取得しています。
トップページでは当日分の情報があれば良いため、URL にクエリを付与して取得するデータ量を減らしましょう。クエリを利用したリクエストは既に API に設定されています。
サーバーサイド
ここまですべて試すとメインの JavaScript のファイルは Gzipped で 30kb 程度になります。
フロントエンドの改善がある程度進んだら次は配信等のバックエンドを少し改善しましょう。
圧縮
fastify-compress を利用することで API のレスポンス時に圧縮をかけることができます。
import compression from "fastify-compress";
...
省略
...
server.register(compression, { encodings: ["gzip", "deflate"] });
また、静的ファイルは事前に圧縮すると良いでしょう。Webpack の pulugin に compression-webpack-plugin を設定し、fastify-static の preCompressed を true にすることで有効化されます。
const CompressionPlugin = require("compression-webpack-plugin");
...
省略
...
new CompressionPlugin({
algorithm(input, compressionOptions, callback) {
return zopfli.gzip(input, compressionOptions, callback);
},
compressionOptions: { level: 9 },
test: /\\.js$/,
}),
エンドポイントの分割
レース情報を取得するエンドポイントを確認すると、レスポンスにオッズの情報が常に付随してくることが確認できます。レースカードページやレース結果ページではオッズの情報は不要であるため、エンドポイントを分割しました。
最終的には次のような実装になりました。また、relation をなるべく利用しないほうがよいため、OddsItem から直接参照しています。(raceId カラムは自力で生やす必要があります。)
fastify.get("/races/:raceId/entries", async (req, res) => {
const repo = getRepository(Race);
const race = await repo.findOne(req.params.raceId, {
relations: ["entries", "entries.player"],
});
if (race === undefined) {
throw fastify.httpErrors.notFound();
}
res.send(race);
});
fastify.get("/races/:raceId/trifectaOdds", async (req, res) => {
const repo = getRepository(OddsItem);
const odds = await repo.find({
raceId: req.params.raceId,
});
if (odds === undefined) {
throw fastify.httpErrors.notFound();
}
res.send(odds);
});
やや高度なテクニック
ここまではそこまで工数を要さない改善例で,すべて達成していれば400半ば〜後半のスコアを取ることができると思います.Web Speed Hackathon は 2〜3日の程度の短期間で開催されることもあるので、ここまでを一区切りとしてパフォーマンス・チューニングに臨むと良いでしょう。
ここからは、短期間では実装が少し難しいアドバンスドな改善項目を紹介します.
チャンクの導入
上記のライブラリ削除・置換を行うことで、この時点で JavaScript のファイルサイズは 30kb 前半でした。とはいえ、TBT を改善できていなかったため、チャンクを導入することで改善を試みました。実際に試した手法は下記の 2 つです。
vendor chunk
指定したパッケージを共通のチャンクに吐き出すように設定する手法です。React や React Router 等のアプリケーションを構成するパッケージを共通チャンクに設定することが多く,Webpack の optimization.splitChunks を設定することで利用できます。
実際に社内でも利用されている取り組みです。
Web版WINTICKETのパフォーマンスを改善してきた | CyberAgent Developers Blog
ただし、今回は TBT の改善につながらなかったため利用しませんでした。
route chunk
ページのルーティングごとに JavaScript をチャンクする手法です。今回は私が慣れていることもあり @loadable/component を利用しました。React の lazy と Suspence を利用してもよかったと思います。
import loadable from "@loadable/component";
const LoadableTop = loadable(
() => import("./pages/Top"),
{ fallback: undefined, ssr: true },
);
このようにすることで,ページコンポーネントをチャンク対象にしました。これにより、ある程度 TBT を改善することができました。
SSR
ここまですべて試した上で FCP を満点取れなかったため SSR を導入しました.
hydration
Hydration とは、サーバーサイドでレンダリングされた結果をクライアントサイドで利用するために導入された SSR の手法です。CSR のみで構成されたアプリケーションに SSR に移行して機能させる際には、特に次の点に注意する必要があります。
- サーバーサイドでレンダリングされた DOM 構造とクライアントサイドでレンダリングされた DOM 構造が一致していること
- サーバーサイドレンダリングのフェーズで、window オブジェクトへのアクセス等のブラウザ特有の機能を利用していないこと
今回は styled-components と loadable を利用した一般的な方法を採用しています。以下が Hydration しているプログラムの例となります。
import { ChunkExtractor } from "@loadable/server";
import { renderToStaticMarkup, renderToString } from "react-dom/server";
import { ServerStyleSheet, StyleSheetManager } from "styled-components";
import { App } from "../../client/foundation/App.jsx";
import { Html } from "../templates/index.jsx";
const renderHtml = (path, headElement, serverSideProps) => {
const extractor = new ChunkExtractor({ statsFile });
const jsx = extractor.collectChunks(
//App コンポーネントに path を指定することで対象のページを設定
<App path={path} serverSideProps={serverSideProps} />,
);
let content = null;
let styleElements = null;
const sheet = new ServerStyleSheet();
try {
content = renderToString(
<StyleSheetManager sheet={sheet.instance}>{jsx}</StyleSheetManager>,
);
styleElements = sheet.getStyleElement();
} catch (error) {
console.error(error);
} finally {
sheet.seal();
}
const html = renderToStaticMarkup(
<Html
content={content}
headElement={
<>
{headElement}
{styleElements}
{extractor.getLinkElements()}
</>
}
scriptElementList={extractor.getScriptElements({ async: false })}
/>,
);
return html;
};
上記の例で利用している HTML のコンポーネントは以下のようになります。
import React from "react";
export const Html = ({ content, headElement, scriptElementList }) => {
return (
<html lang="ja">
<head>
<base href="/" />
<meta charSet="UTF-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>CyberTicket</title>
{headElement}
</head>
<body>
<div dangerouslySetInnerHTML={{ __html: content }} id="root"></div>
{scriptElementList}
</body>
</html>
);
};
データの prefetch
今回はどのページでも初回レンダリング時にユーザー特有の情報を利用していないため、配信する HTML ファイルにあらかじめデータを注入しておくことで、クライアントサイドレンダリング時の API リクエストを減らすことができます。
具体的にはレースの詳細情報をサーバーサイドレンダリング時に HTML に注入して、クライアントサイドでは注入されたデータをもとにレンダリングすることで Hydration による不一致を解消しています。
しかし、とにかくサーバーサイドでデータを注入すれば良いという訳ではなく、HTML に注入するデータ量が増えると HTML ファイルのサイズが膨張し、パフォーマンスが悪化する恐れがあります。そのため、今回はレースの情報のみ注入しました。
サーバーサイドの実装は以下のようになります。
fastify.get("/races/:raceId/race-card", async (req, reply) => {
reply.type("text/html");
const path = `/races/${req.params.raceId}/race-card`;
const repo = getRepository(Race);
const data = await repo.findOne(req.params.raceId, {
relations: ["entries", "entries.player"],
});
if (data === undefined) {
throw fastify.httpErrors.notFound();
}
const html = renderHtml(
path,
<>
<link
as="image"
href={data.image.replace(".jpg", ".avif")}
rel="preload"
/>
<script
defer
data-injected={JSON.stringify({
item: data,
path,
})}
id="injected-props"
type="text/plain"
/>
</>,
{ item: data },
);
return reply.send(`<!DOCTYPE html>${html}`);
});
クライアントサイドの実装は以下のようになります。既存の useFetch を改良しています。
//fallback 引数を追加
export function useFetch(apiPath, fetcher, fallback) {
const [result, setResult] = useState({
data: fallback ? fallback : null,
error: null,
loading: true,
});
useEffect(() => {
//fallback が設定されているときは HTTP リクエストせず、fallback を利用
if (fallback != null) {
return;
}
setResult((pre) => ({
...pre,
error: null,
loading: true,
}));
const promise = fetcher(apiPath);
promise.then((data) => {
setResult((cur) => ({
...cur,
data,
loading: false,
}));
});
promise.catch((error) => {
setResult((cur) => ({
...cur,
error,
loading: false,
}));
});
}, [apiPath, fetcher, fallback]);
return result
サーバーサイドでは Props で渡されたデータを、クライアントサイドでは script タグのデータを利用する必要があるため、データの取得を振り分ける関数を実装しました。これにより Hydration の不一致を防いでおります。
// SSR 時には serverSideProps の値を利用し、CSR 時には script タグの値を利用することで Hydration error を防ぐ
export const generateInitialProps = (serverSideProps) => {
// サーバー内かどうか判定
if (typeof window === "undefined") {
if (serverSideProps) {
return serverSideProps.item;
}
return undefined;
}
const injectedPropsElement = document.getElementById("injected-props");
if (injectedPropsElement == null) {
return undefined;
}
const injectedProps = JSON.parse(
injectedPropsElement.getAttribute("data-injected"),
);
// クライアントサイドで別ページに遷移をした際には script タグの値を利用しない
if (location.pathname !== injectedProps.path) {
return undefined;
}
return injectedProps.item;
};
ページ側では以下のように、useFetch Hook を利用します。
const { data } = useFetch(
`/api/races/${raceId}/entries`,
jsonFetcher,
generateInitialProps(serverSideProps),
);
間に合わなかったこと
どうしても TBT が満点取れなかったので、最終日にゼロから Qwik を利用して全ページを作り直しました。
Qwik とは、builder.io によって開発された JavaScript のフレームワークです。デフォルトで初回読み込み以外のイベントハンドラ等の処理を遅延読み込みし、HTML 内にシリアライズした状態を注入することで、Hydration によるオーバーヘッドを解消しています。
VRT が通るレベルまでページを作成し計測したところ TBT は改善したのですが、何故か FCP が落ちてしまい 0.2 点足りず、その時点でリプレイスを断念してしまいました。
とはいえ、Resumable は Hydration のコストを解消できる新しいアプローチということを実感できたので良い経験になりました。
Qwik にリプレイスする段階で、styled-components はすべて生の CSS に置き換えていたので、普通に styled-components を剥がした状態でスコアを計測してみればよかったと少し後悔しています。
終わりに
最後まで読んでくださり、ありがとうございます。Web Speed Hacathon 2022 に参加した方には振り返りの記事として、これから参加する方には今後の戦略として参考にしていただければ幸いです。
また、Web Speed Hackathon 2022 は他の参加者の方々が素晴らしい記事を書いていらっしゃるので、週末に予定のない方は是非 Web Speed Hackathon 2022 に取り組んでみてください!!
私自身、(当記事執筆時点ではまだ学生ですが)学生時代に Web Speed Hackathon に参加して、シンプルに技術力が足りなかったり、レギュレーション違反をしたりと苦い思いを経験したことがたくさんあります。それでも、参加するたびに自分の成長を実感することができ、Web Speed Hackathon を機に様々な方とつながることができました。
特に学生エンジニアの方にとっては、Web のパフォーマンスチューニングに取り組める珍しい機会でもあり、自身の成長にもつながると思うので参加して絶対に損はないです。
Web のパフォーマンスチューニングやサイバーエージェントに少しでも興味がある方は、今後参加していただけるとうれしいです。