開発ブログ

BLOG

LP の表示を速くした続き:全ページで読み込むものを減らす

前回に続き、LP(トップページ)の表示を、演出を変えずに速くした。表示を待たせていた CSS の 100KB は、すべてのページで読み込んでいた日本語フォントの 370 個の定義だった。すべてのページで読み込まれていた zod も外し、スクロールの演出(ScrollTrigger)が位置を測り直す回数を減らした。見本のカードの JS を後回しにする案は、カードが描き直されてしまうのでやめた。

公開 2026年10月10日

前回の記事では、演出を変えずに LP(トップページ)の PageSpeed Insights(モバイル)を 31 点から 61〜69 点まで上げました。それでもモバイルの LCP(最初の画面でいちばん大きな要素が描かれるまでの時間)は 4.4〜5.9 秒で、Google の「良好」の基準(2.5 秒以下)には届いていません。

今回は、LP だけでなくサイト全体で読み込んでいたものを見直しました。PageSpeed Insights の中で動いている Lighthouse を手元で動かして測ると、中央値で日本語の LP(/ja)は 62 点から 69 点、英語の LP(/en)は 56 点から 69 点になりました。今回も、スクロールに合わせて動く演出の見た目と動きは変えていません。

Lighthouse の LCP は、実際の LCP より前に始まった読み込みから見積もる

前回わかったとおり、LP で LCP の対象になる要素は、ヘッダーの「アプリに移動」のボタンです。手元で回線と CPU を絞って測ると、このボタンは最初の描画(FCP)と同時に描かれていました。それなのに、Lighthouse の LCP は FCP より 3 秒以上遅い値でした。

Lighthouse は、回線を絞らずにページを読み込み、その記録をもとに遅い回線での時刻を計算します(シミュレートした回線)。LCP は、回線を絞らない読み込みで LCP までに始まった読み込みをすべて拾い、それらを遅い回線で読み込んだらどれだけかかるかを計算して見積もります。直す前の LP は、回線を絞らない読み込みでは 0.15 秒ほどで LCP に届き、それまでに 42 個のファイル、合わせて約 960KB の読み込みが始まっていました。最初の画面に要らない JS や画像も、すべて LCP の見積もりに入ります。

直す前の LP の Lighthouse の指標。番号 1 は Largest Contentful Paint の 5.7 秒、番号 2 は Total Blocking Time の 550 ミリ秒
直す前の /ja(手元の Lighthouse・モバイル)
  1. LCP は 5.7 秒。FCP(2.3 秒)より 3 秒以上遅い
  2. TBT(長いタスクのせいで操作を受け付けられなかった時間の合計)は 550 ミリ秒

そこで、最初の画面を描くまでに読み込むものを減らすことにしました。減らしたのは、描画を止める CSS、最初に読み込む JS、最初の画面では見えない画像の 3 つです。そのあと、読み込みのあとに残っていた長いタスクにも手を入れました。

描画を止める CSS の 100KB は、フォントの定義だった

Lighthouse の「Render-blocking requests」(描画を止めているリクエストの一覧)には、LP の描画を止めている CSS が 4 本並んでいました。そのうち 1 本だけが 100KB と大きく、ほかは 20KB 以下です。

Lighthouse の Render-blocking requests を開いたところ。CSS が 4 本並び、番号 1 は 100.9KiB の CSS の行
直す前に描画を止めていた CSS
  1. 100KB の CSS。中身は IBM Plex Sans JP の @font-face が 370 個

この CSS の中身は、@font-face が 370 個で、圧縮前で約 280KB あります。すべてのページに共通する外枠(ルートのレイアウト)で、next/font/google の IBM Plex Sans JP を読み込んでいたためです。

Google Fonts の IBM Plex Sans JP は、文字の範囲(unicode-range)ごとに 120 ほどのファイルに分かれていて、太さ 3 つ分で @font-face も 360 ほどになります。next/font の subsets に latin だけを書いても、日本語の範囲の定義は CSS に入ります。next/font はこの CSS を、フォントを読み込んだレイアウトやページの CSS にまとめます。ルートのレイアウトで読み込んでいたので、どのページでも描画を止める CSS に入っていました。

前回、LP の日本語は、LP で使う文字だけを含む小さなフォント(サブセット)で描くようにしました。これで読み込むフォントのファイルは減りましたが、アプリなどで使う IBM Plex Sans JP 全体の 370 個の定義は残っていました。LP には、この定義は要りません。

そこで、IBM Plex Sans JP を読み込むのは LP 以外のレイアウト(アプリ・ドキュメント・ブログ)だけにしました。フォントを指す CSS の変数は、モーダルなど <body> の直下に出る要素でも使います。レイアウトの要素に定義すると、その外にある要素には届かないので、:root に定義します。

import { ibmPlexSansJp } from "@/app/_styles/mfm/sans-jp-font";

export const SansJpFontVariable = () => (
  <style>{`:root{--font-ibm-plex-sans-jp:${ibmPlexSansJp.style.fontFamily};}`}</style>
);

LP で描画を止める CSS は、4 本の合計で 125KB から 25KB になりました。LP のサブセットにない文字は、これまで IBM Plex Sans JP 全体のフォントで描いていましたが、これからは Hiragino Sans など端末のフォントで描きます。LP の文言を変えたときはサブセットを作り直すので、サブセットにない文字は通常は出てきません。

直した後の Render-blocking requests。CSS は 3 本で、合わせて 24.5KiB
直した後に描画を止めている CSS

zod が、すべてのページに付いていた

次に、LP が最初に読み込む JS を、ソースマップを使って元のファイルごとの大きさに分けて数えました(測り方は前回の記事と同じです)。いちばん大きいかたまりは zod で、圧縮前で約 240KB ありました。zod はデータの形が正しいかを調べるライブラリですが、LP には zod を使う処理はありません。

import のつながりをたどると、instrumentation-client.ts で zod を読み込んでいました。Next.js が、ブラウザでアプリのコードより先に動かすファイルです。

import { z } from "zod";

// CSP で eval を許可していないので、zod の JIT を切る
z.config({ jitless: true });

zod v4 は、スキーマを定義するときに Function("") を試して、eval が使えるかを調べます。使えれば、eval で速い検証のコードを作ります(JIT)。このサイトは CSP(ページで実行してよいものを決めるセキュリティの設定)で eval を許可していないので、ブラウザでのこの確認が、CSP の違反の報告として届き続けていました。JIT を切って、この確認をやめるための設定です。設定は zod の中の変数に書くので、zod を読み込まずに書く方法はありません。そのため、どのページにも zod 全体が付いていました。zod/v4/core から config だけを読み込む形も試しましたが、core だけで約 200KB あり、ほとんど減りませんでした。

そこで、zod は lib/zod.ts から読み込むことにし、JIT を切る設定もそこに移しました。zod を使うページでは、スキーマを定義するより先に設定が効きます。zod を使わないページでは、zod を読み込みません。違反の報告が届くのはブラウザなので、JIT を切るのもブラウザだけです。

import { z } from "zod";

if (typeof window !== "undefined") {
  z.config({ jitless: true });
}

export { z };

"zod" を直接 import すると、この設定を通らずにスキーマを定義できてしまいます。そこで ESLint の no-restricted-imports で、lib/zod.ts 以外のファイルが "zod" を直接 import すると、エラーになるようにしています。

zod はもう 1 か所からも届いていました。メトロノームの設定を JSON のファイルに書き出す処理は、すべてのページで読み込みます。この処理が、設定ファイルの版の番号と値の範囲を、zod のスキーマと同じファイルから import していました。版の番号と値の範囲は、zod を使わない別のファイルに移しています。

zod を外すなどして、LP が最初に読み込む JS は、圧縮前で約 1,700KB から約 1,440KB になりました。

最初の画面では見えない画像を、後から読み込む

LP には、アプリの画面録画を見せる節があります。その動画のポスター画像(再生する前に出す画像。約 75KB)に loading="lazy" を付けました。この画像は最初の画面より下にあるのに、ページを開いた直後に読み込んでいました。大きさは枠で決まっているので、読み込む前後で位置は変わりません。

見本の JS を後回しにする案は、やめた

LP の中ほどには、アプリの機能を紹介するカードを横に流して見せる帯があります。カードは 7 枚で、それぞれにアプリと同じ部品で作った見本が入っています。どれも最初の画面より下にあるので、見本の JS(約 400KB)を、ページの読み込みが終わったあとに取りに行く形も試しました。

見本の HTML はサーバーで今までどおり描き、ブラウザでは React.lazy と Suspense で JS が届くまでハイドレーションを待たせる形です。手元の Lighthouse では LCP がさらに 0.7 秒ほど縮みましたが、この形は取りやめました。

回線を絞って確かめると、ハイドレーションがページの読み込みより先に終わったとき、見本がいったん消えて、作り直されていました。React は、まだハイドレーションしていない Suspense の中に更新が届くと、サーバーが描いた HTML を捨てて作り直します。見本の JS が届くまでのあいだに、見本を包むコンポーネントのどこかで値が変わっていたと見ています。どの値かまでは、突き止めていません。

見本は画面の外にあることが多いので、消えても気づかない人がほとんどです。それでも、演出の見た目を変えないという条件からは外れるので、今回は入れませんでした。気づいたのは、見本の START を押して音を確かめる E2E(ブラウザを動かす自動テスト)が、押したボタンが DOM から外れたというエラーで失敗したときです。

スクロールの演出の準備を、セクションごとに分ける

ハイドレーションの直後には、0.3 秒ほどの長いタスクも残っていました。プロファイルを見ると、LP のセクションが ScrollTrigger のピン留め(スクロールしてもセクションを画面に止めておく演出)を作る処理です。ScrollTrigger は作るたびにレイアウトを測るので、ハイドレーションの後に呼ばれる React の effect の中で、セクションの数だけ測っていました。

最初の画面にある、ページを開いたときの演出(オープニング)と最初のセクション(ヒーロー)は、今までどおりすぐに作ります。それより下のセクションは、1 つずつ別のタスクで作ることにしました。

ピン留めは、止めておくあいだのスクロールの分だけページに余白を足します。下のセクションを先に作ると、あとから上に足された余白を知らないまま位置を決めてしまい、ずれます。そのため、上のセクションから順に作ります。React は兄弟の effect を並びの順に呼ぶので、その順にキューへ入れ、setTimeout で 1 つずつ実行します。

export const useGSAPInOwnTask = (
  callback: () => void | (() => void),
  config: Parameters<typeof useGSAP>[1],
) =>
  useGSAP(
    (context: gsap.Context) =>
      runInOwnTask(() => {
        context.add(callback);
      }),
    config,
  );

作ったアニメーションは、useGSAP の context に入れておきます。セクションが画面から取り除かれたとき(アンマウント)に、元に戻すためです。各セクションでは useGSAP( を useGSAPInOwnTask( に替えるだけで済みます。

前回の記事で課題として残していた、オープニングが終わったときの ScrollTrigger の測り直し(ScrollTrigger.refresh())にも手を入れました。オープニングのあいだはスクロールを止めていて、そのあいだはスクロールバーが消えます。スクロールできるように戻すと、スクロールバーが戻って幅が変わるので、全体を測り直していました。スマホでは、スクロールバーは中身に重なるだけで場所を取らないので、幅は変わりません。そこで、幅が変わったときだけ測り直すことにしました。

const widthWhileLocked = document.documentElement.clientWidth;
applyToPage(false);
state.lenis?.resize();
if (document.documentElement.clientWidth !== widthWhileLocked) {
  ScrollTrigger.refresh();
}

ここで 1 つつまずきました。最初は、幅が変わらないときに、なめらかスクロール(Lenis)の測り直し(lenis.resize())も飛ばしていて、ホイールでスクロールできなくなっていました。下のセクションのピン留めを後から作るようにしたので、Lenis を作った後でページが長くなります。Lenis は <html> の大きさの変化を見て測り直します。このサイトでは、ページの中身は高さを画面に合わせた枠の中に並んでいて、<html> の高さは画面の高さのままです。そのため、ページが長くなっても Lenis は気づきません。オープニングの終わりの lenis.resize() が、それを補っていました。

見た目を確かめるために画面を撮って比べていましたが(次の段落)、それではこれに気づけませんでした。スクロールに window.scrollTo を使っていて、Lenis を通っていなかったからです。気づいたのは、LP の E2E のうち、ホイールでスクロールするテストが落ちたときでした。今は、Lenis はいつも測り直し、ScrollTrigger だけを幅が変わったときに測り直しています。

演出の見た目が変わっていないことは、前回と同じく画面を撮って確かめました。スクロールの位置を 17 か所決め、直す前と後の LP をスマホと PC の幅で撮って、画素で比べています。スクロールできる長さはぴったり同じでした。違いが出たのは、ヒーローの振り子の角度と、再生位置の線だけです。どちらも時間で動き続けていて、撮った瞬間がずれていました。

結果

手元で本番と同じようにビルドしたものを、Lighthouse(モバイル)で直す前と後に 5 回ずつ測った中央値です。同じマシンで交互に測りましたが、Lighthouse の値は 1 回ごとにぶれます。

ページ
点数
LCP
TBT
/ja
62 → 69
5.7 秒 → 4.8 秒
557ms → 404ms
/en
56 → 69
6.4 秒 → 5.3 秒
623ms → 394ms
直した後の LP の Lighthouse の指標。番号 1 は Largest Contentful Paint の 4.8 秒、番号 2 は Total Blocking Time の 400 ミリ秒
直した後の /ja(手元の Lighthouse・モバイル)
  1. LCP は 4.8 秒
  2. TBT は 400 ミリ秒

次の表は Lighthouse の見積もりではなく、前回と同じく、ブラウザの CPU を 4 倍遅く、回線を 1.6Mbps に実際に絞って測った値です(3 回の平均。TBT は Long Tasks API から出した概算)。最初の節に書いたとおり、この測り方ではボタンが最初の描画と同時に描かれるので、FCP と LCP を 1 行にまとめています。

項目
直す前
直した後
FCP・LCP
約 2.9 秒
約 0.9 秒
TBT
約 0.53 秒
約 0.41 秒
いちばん長いタスク
約 0.34 秒
約 0.27 秒
最初に読み込む JS(圧縮前)
約 1,700KB
約 1,440KB
描画を止める CSS(転送)
約 125KB
約 25KB

LCP の見積もりは「良好」の 2.5 秒にまだ届いていません。残りは、Next.js と React(圧縮前で約 490KB)、オープニングの演出に使う GSAP(約 120KB)、LP のサブセットのフォント(約 110KB)、それに見本の JS を、遅い回線で読み込む時間です。前の 3 つは演出を残すなら外せません。見本の JS は、作り直しの原因を突き止めてから、もう一度後回しを試します。

この記事を共有

X でポストはてなブックマーク