私たちが忘れていたフロントエンドパフォーマンスの技術
モダンなフロントエンドを遅くする最も確実な方法は、フレームワークがパフォーマンスを自動的に処理してくれると思い込むことだ。10年前に高速なウェブサイトを実現していた低レベルの技術——明示的な画像サイズ指定、サードパーティスクリプトの遅延読み込み、フォント表示のヒント、手動でのpreconnect設定——は今でも重要だ。しかし、フレームワークがプラットフォームとの間に入ることで、Lighthouseの監査が容赦なく指摘するような形で抽象化が漏れ出している。2019年にButterCMSは「ブラウザはまもなくネイティブの遅延読み込みをサポートするようになる」と書いた。その未来は到来し、標準機能となり、そして私たちが確認することをやめた機能へと静かに変わっていった。本記事では、私たちが委任してしまったフロントエンドパフォーマンスの基本事項と、その委任が破綻したときに本番環境で発生する障害パターンを解説する。
重要なポイント
- ネイティブの
loading="lazy"はSafari 15.4(2022年3月)以降、すべての主要ブラウザでサポートされている。それ以前に書かれたIntersection Observerのラッパーはすべて、バンドルサイズを増やすだけのデッドコードだ。 - Google Fontsは
font-display: swapを適用した状態でフォントを配信できるが、自身のCSSに書いたカスタムの@font-faceブロックはその挙動を継承しない——低速な接続環境では、それぞれが不可視テキストのフラッシュ(FOIT)を引き起こす可能性がある。 setTimeout(fn, 0)は次のタスクで実行され、ユーザーのインタラクション中に割り込む可能性がある。一方、requestIdleCallbackはブラウザが本当にアイドル状態になるまで待機するため、緊急性の低い処理に適した正しいプリミティブだ。- DOMを操作するスクリプトには
deferを使い、asyncは完全に独立したスクリプトにのみ使用すること。asyncスクリプトはドキュメントの順序ではなく、ネットワークの到着順に実行されるためだ。 - 2024年3月にINPがFIDに代わってCore Web Vitalになったことで、メインスレッドをブロックするスロットリングされていないscrollおよびresizeハンドラーは、単なる滑らかさの問題ではなく、検索ランキングのシグナルになった。
画像への明示的な width/height 指定は今もレイアウトシフトを防ぐ
APIレスポンスから実行時に画像サイズを取得するReactアプリケーションでは、ブラウザが事前に領域を確保できないため、初回描画後に読み込まれるすべての画像がレイアウトシフトの原因になりうる——フレームワークの画像コンポーネントが静的アセットを正しく処理しているかどうかに関わらずだ。Cumulative Layout Shift(CLS)はGoogleのweb.dev CLSドキュメントで定義されているCore Web Vitalsの一つであり、その障害モードは具体的だ。画像が読み込まれるとページがジャンプし、ユーザーのタップが意図しないボタンに当たってしまう。
ここで漏れが生じる抽象化はフレームワークの画像コンポーネントだ。Next.jsの <Image> は width と height を渡せばスペースを確保するが、MDXコンテンツやCMSがレンダリングしたHTML、あるいはコンポーネントが関与しないマークアップ内の生の <img> タグには何もしない。モダンなブラウザは width と height 属性から暗黙的な aspect-ratio を計算する(MDNがこの挙動を文書化している)ため、CSSがレンダリングサイズを上書きしても、サイズ指定によってスペースが確保される。
// Before: サイズが実行時に到着するため、何もスペースを確保しない
<img src={product.imageUrl} alt={product.name} />
// After: 画像が読み込まれる前にアスペクト比ボックスを属性で設定する
<img
src={product.imageUrl}
alt={product.name}
width={product.width}
height={product.height}
style={{ width: '100%', height: 'auto' }}
/>
APIがサイズ情報を返さない場合は、代わりにコンテナに aspect-ratio を設定する。いずれにせよ、バイトが届く前にスペースを確保しておくことが重要だ。
Discover how at OpenReplay.com.
カスタムフォントへの font-display: swap とpreconnectの適用
font-display: swap のないカスタム @font-face ブロックは、低速な接続環境では潜在的なFOIT(Flash of Invisible Text:不可視テキストのフラッシュ)を引き起こす。フォントのフェッチが完了するまで段落全体が空白のままになってしまうのだ。font-display ディスクリプタはこれを直接制御する。swap を指定すると、フォールバックテキストを即座にレンダリングし、カスタムフォントが読み込まれた時点でスワップする。これにより、MDNの font-display リファレンスに記載されているように、FOITではなくFOUT(Flash of Unstyled Text:スタイルなしテキストのフラッシュ)が発生するようになる。
問題は委任による漏れだ。Google FontsはスタイルシートのURLに適切な display パラメータが含まれていれば、配信するCSSに font-display: swap を注入できる。そのため、ホスト型スタイルシートを使用しているチームはこれを意識することがなく、その後ブランドフォント用に独自の @font-face ブロックを書いたとき、その挙動が継承されないことに気づかない。ディスクリプタのないセルフホスト型フォントは、コールドキャッシュのすべての訪問者にFOITを届けることになる。
セルフホスティングでは、Google Fontsのスタイルシートが暗黙的に促していたpreconnectも失われる。web.devの早期ネットワーク接続に関するガイダンスでは、CSSでフォントURLが検出される前にDNS、TCP、TLSのハンドシェイクを完了させるために、フォントのオリジンにpreconnectすることを推奨している。
@font-face {
font-family: "BrandSans";
src: url("/fonts/brand-sans.woff2") format("woff2");
font-display: swap; /* フォールバックテキストを即座に表示し、FOITを防ぐ */
}
<link rel="preconnect" href="https://fonts.cdn.example.com" crossorigin />
手書きのすべての @font-face ブロックを監査すること。ホスト型CSSの習慣が、修正が必要なブロックを見えなくしている。
サードパーティオリジンへの preconnect と dns-prefetch の適用
バンドラーやフレームワークは自身のCDNオリジンへのpreconnectを処理するが、サードパーティの分析エンドポイント、画像CDN、A/Bテストサービスはビルドステップから見えない——手動で <link rel="preconnect"> を追加しない限り、それらのDNSルックアップはリクエスト時に発生する。ButterCMSは2019年にこのメカニズムを正確に説明した。preconnectは「スクリプトタグが検出されたときではなく、できるだけ早く」DNSルックアップ、初期接続、TLSネゴシエーションを完了するようブラウザに指示するものだ。
DNSとTLSのハンドシェイクコストはなくなっていない。フレームワークがそれを意識させなくなっただけだ。Segmentのエンドポイント、Cloudinaryのオリジン、サードパーティのタグマネージャーはそれぞれ、その背後にあるリソースをブロックする新たな接続セットアップを必要とする。確実に早期にアクセスするオリジンには preconnect を使い、アクセスする可能性があるオリジンには軽量なヒントとして dns-prefetch を使うこと。preconnect は実際に使用されるかどうかに関わらずコストが発生する完全な接続を開くためだ。web.devは2つのヒントのトレードオフについて解説している。
<!-- 重要なサードパーティオリジン: 今すぐ完全な接続を開く -->
<link rel="preconnect" href="https://cdn.imagecdn.example" crossorigin />
<!-- 可能性はあるが確実ではないオリジン: DNSの解決だけを行う -->
<link rel="dns-prefetch" href="https://analytics.example.com" />
これらは <head> の上部、リクエストをトリガーするスクリプトやスタイルシートより前に配置すること。
ネイティブの loading="lazy" がIntersection Observerのラッパーを不要にした
ネイティブの loading="lazy" は2022年3月のSafari 15.4のリリース以降、すべての主要ブラウザでサポートされている——それ以前に書かれたIntersection Observerのラッパーはすべて、バンドルサイズとメンテナンスコストを増やすだけのデッドコードだ。Chromeはバージョン77(2019年8月)、Firefoxはバージョン75(2020年4月)でサポートを開始した。これはMDNの img 要素リファレンスのブラウザ互換性テーブルに記載されている。
ここでの漏れはフレームワーク固有のものではなく、歴史的なものだ。コードベースは、この属性が標準化される以前の数年間に useLazyImage フックや <LazyImage> コンポーネントを蓄積してきた。そしてそれらのコンポーネントは今も動き続けている——ブラウザが今やネイティブかつメインスレッド外で行うことを、画像ごとにオブザーバーを実行し、refを保持し、インターセクション時に再レンダリングしながら実現しているのだ。同じ属性はiframeにも機能するため、フォールドより下に埋め込まれたマップや動画プレーヤーにとっても重要だ。
// Before: プラットフォームによって不要になった手書きのオブザーバー
function LazyImage({ src, alt }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) setVisible(true);
});
io.observe(ref.current);
return () => io.disconnect();
}, []);
return <img ref={ref} src={visible ? src : undefined} alt={alt} />;
}
// After: ブラウザがメインスレッド外で処理する
<img src={src} alt={alt} loading="lazy" width={800} height={600} />;
明示的なサイズ指定は維持すること——遅延読み込みされる画像も、即時読み込みの画像と同様にレイアウトシフトを引き起こす。
サードパーティスクリプトへの defer と async の使い分け
実践的な判断基準はこうだ。DOMを読み書きするスクリプトには defer を使い、async は本当に独立したスクリプトにのみ使用すること。async スクリプトはドキュメントの順序ではなくネットワークの到着順に実行されるため、依存関係のある2つの async スクリプトは競合状態に陥る。HTML Living Standardのscript要素の定義によると、defer スクリプトはパースが完了した後にドキュメント順で実行され、async スクリプトはフェッチが完了次第実行される。
この問題は技術的なものではなく、社会的なものだ。誰かがベンダーのコピー&ペースト手順に従って、属性なしのままベンダーの分析スニペットを <head> に貼り付ける。そして属性のない <script> は、ダウンロードと実行が完了するまでパースをブロックする。その結果として現れる障害はインタラクションの遅延だ。サードパーティスクリプトがインタラクションをブロックすると、セッションリプレイには同じコントロールへの連打が記録される——典型的なレイジクリックのパターンだ。
| 属性 | 実行タイミング | 順序の保証 | 用途 |
|---|---|---|---|
| なし | パーサーを即座にブロック | ドキュメント順 | ほぼ使用しない |
async | フェッチ完了次第 | ネットワーク到着順 | 独立した分析スクリプト |
defer | パース完了後 | ドキュメント順 | DOMに触れるすべてのもの |
<!-- Before: パーサーをブロックし、初回描画とインタラクションを遅延させる -->
<script src="https://vendor.example/analytics.js"></script>
<!-- After: 独立したスクリプト、パーサーを一切ブロックしない -->
<script src="https://vendor.example/analytics.js" async></script>
setTimeout(fn, 0) の代わりに requestIdleCallback を使う
setTimeout(fn, 0) は次のタスクキューのスロットで処理をスケジュールするため、ユーザーのインタラクションの最中に割り込む可能性がある。一方、requestIdleCallback はブラウザが本当にアイドル状態になるまで待機するため、分析の初期化、プリフェッチのハイドレーション、テレメトリのバッチ処理に適した正しいプリミティブだ。この違いはMDNの requestIdleCallback リファレンスに記載されている。コールバックはブラウザのアイドル期間中に発火し、さらに処理を続ける前に確認できるデッドラインを受け取る。
これはほとんどのチームが採用しなかったプリミティブだ——setTimeout(fn, 0) が「後で実行する」という反射的なイディオムになってしまい、実際にはユーザーに制御を返していない。2024年3月にINPがFIDに代わってCore Web Vitalになった(web.devのINPアナウンス参照)ことで、インタラクション中に発生するメインスレッドの処理は、単なる滑らかさの問題ではなく、検索ランキングのシグナルになった。requestIdleCallback はChromeとFirefoxでサポートされているがSafariではサポートされていないため、機能検出を行ってフォールバックを用意すること。
function whenIdle(fn) {
if ("requestIdleCallback" in window) {
requestIdleCallback(fn, { timeout: 2000 });
} else {
setTimeout(fn, 0); // Safariのフォールバック
}
}
// 緊急性の低い処理をインタラクションパスから外す
whenIdle(() => initAnalytics());
timeout オプションにより、ブラウザがアイドル状態にならない場合でも処理が最終的に実行されることが保証される。
scroll、resize、inputへのデバウンスとスロットリングの適用
スロットリングされていないscroll、resize、inputハンドラーがメインスレッドをブロックすることは、今や単なる滑らかさの問題ではなく、検索ランキングのシグナルだ——それらが遅延させるすべてのフレームが、INP違反の可能性を持つ。このパターンが問題になるのは、useEffect がレート制限なしで生のリスナーを簡単にアタッチできるからだ。3行のコードで、すべてのスクロールフレームで発火するハンドラーが完成してしまう。
デバウンスはアクティビティが停止した後に関数を実行する——検索入力やリサイズ終了時の処理に適している。スロットリングは頻度を制限する——ジェスチャー中に更新が必要なスクロール位置のトラッキングに適している。MDNのscrollイベントリファレンスでは、scrollイベントが高頻度で発火する可能性があることを指摘し、コストの高いハンドラーにはスロットリングを推奨している。
useEffect(() => {
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
updateScrollPosition(window.scrollY);
ticking = false;
});
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
requestAnimationFrame によるゲートで更新を1フレームに1回に制限し、{ passive: true } によってハンドラーが preventDefault を呼び出さないことをブラウザに伝え、JavaScriptの完了を待たずにスクロールできるようにする。
複合的なパターン
本記事で取り上げたすべての技術は、フレームワークのデフォルトに委任し、検証をやめてしまったプラットフォームの知識だ。どれも新しいものではない——それがまさに要点だ。font-display の欠落や遅延されていないタグ一つひとつはミリ秒単位のコストだが、それらが重なると、モダンなツールを使っているにもかかわらず重く感じるアプリと、速く感じるアプリの差になる。次の具体的なアクションは、DevToolsを開き、手書きの @font-face ブロック、サードパーティの <script> タグ、useEffect のリスナーを上記のルールと照らし合わせて監査し、ブラウザが不要にしたIntersection Observerのラッパーを削除することだ。
よくある質問
アクティビティが停止した後の最終状態だけを気にする場合はデバウンスを使う。例えば、ユーザーの入力が止まった後に検索リクエストを送信する場合や、リサイズ終了後にレイアウトを再計算する場合だ。連続したジェスチャー中に制限された頻度で更新が必要な場合はスロットリングを使う。例えば、スクロール位置のトラッキングがそれにあたる。デバウンスは一時停止を待ち、スロットリングはイベントが発火し続ける間の頻度を制限する。
はい。loading属性はimgとiframe要素の両方に適用されるため、フォールドより下に埋め込まれたマップ、動画プレーヤー、サードパーティウィジェットは、Intersection Observerのラッパーなしにネイティブで読み込みを遅延できる。ブラウザサポートは画像のロールアウトと密接に連動しており、Chrome、Firefox、Safariで同時期に標準化された。遅延読み込みされる要素も即時読み込みの要素と同様にレイアウトシフトを引き起こすため、レイアウトシフトを防ぐために明示的なwidthとheightを維持すること。
競合状態が発生し、誤った順序で実行される可能性がある。asyncスクリプトはそれぞれのダウンロードが完了次第実行されるため、ドキュメント順ではなくネットワーク到着順になる。そのため、別のasyncスクリプトに依存するスクリプトが先に実行されて失敗する可能性がある。解決策は両方のスクリプトにdeferを使うことだ。これによりパース完了後にドキュメント順で実行されることが保証される。あるいは、依存スクリプトを被依存スクリプトより前に単一のバンドルとして読み込む方法もある。
setTimeout(fn, 0)は次のタスクキューのスロットで処理をスケジュールするため、ブラウザはユーザーのインタラクション中を含め、即座に実行する可能性がある。つまり、実際にはユーザーに制御を返していない。requestIdleCallbackはブラウザが本当にアイドル状態になるまで待機し、処理を続ける前に確認できるデッドラインを渡す。2024年3月にINPがCore Web Vitalになったことで、インタラクション中に発生する処理は検索ランキングのシグナルになったため、この違いは重要だ。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.