レンダリング

SPA (Single Page Application)

単一ページアプリケーション (SPA, Single-page application) はウェブアプリの実装方法の一種で、単一のウェブ文書のみを読み込み、別な内容を表する際には XMLHttpRequest やフェッチなどの JavaScript API を通じて単一文書の本文の内容を更新するものです。

MDN Web Docs より

単一ページアプリケーション (SPA, Single-page application) はウェブアプリの実装方法の一種で、単一のウェブ文書のみを読み込み、別な内容を表する際には XMLHttpRequestフェッチなどの JavaScript API を通じて単一文書の本文の内容を更新するものです。

これにより、ユーザーはサーバーから新しいページ全体を読み込まずにウェブサイトを使うことができ、性能の向上やより動的な利用方法が得られるのと引き換えに、 SEO などで不利になったり、状態の保守、操作の改善、意味のある性能の監視のためにより手間がかかったりします。

先に押さえる指標: FCP / LCP / TTI

この後のスイムレーン図には FCP / LCP / TTI のマイルストーンが登場する。それぞれが「ユーザー体験のどの瞬間」を測っているのかを先に整理しておくと、図の中で『なぜここが重要なのか』が腹落ちしやすい。

FCP First Contentful Paint — 最初の視覚コンテンツ描画
テキスト・画像・SVG・非空の Canvas のような「視覚的なコンテンツ」がブラウザ画面に最初に描画された時点。背景色のみの画面は含まれない。Core Web Vitals の補助指標で、1.8 秒以内が良好とされる。
LCP Largest Contentful Paint — 最大コンテンツ描画
ページ内で最大のコンテンツ要素 (ヒーロー画像・主要見出し等) が描画された時点。「意味のある初回描画」の代理指標で、Core Web Vitals の主指標。2.5 秒以内が良好、4 秒以上は要改善。
TTI Time to Interactive — 操作可能になった時刻
ページが完全にインタラクティブ (クリックや入力に反応できる) になった時点。全てのイベントリスナーが付与され、main thread が 5 秒間 Long Task を出さなくなるタイミング。2024 年に Core Web Vitals 主指標から外れ INP に置き換わったが、実ユーザーの「使えるようになる時刻」として依然重要。

レンダリングライフサイクル

SPA が URL 入力から操作可能になるまでの 22 フェーズを 8 レーンに分解。JS Engine で React が VDOM を commit した後、Browser (Style/Layout) → Compositor (Raster) → GPU (フレームバッファ) → Display (vsync でピクセル表示) という実レンダリングパイプラインが流れる。FCP と LCP と TTI の物理的な違いがここで見える。

User
Browser
Network
Server
JS Engine
Compositor
GPU
Display
  1. 01

    URL 入力 / リンククリック

    01 User URL 入力 / リンククリック

    ユーザーが URL をアドレスバーに入力する、もしくは既存ページのリンクをクリック。ここから全てのフェーズが始まる。

  2. 02

    HTTP リクエスト発出

    02 Browser HTTP リクエスト発出

    ブラウザがナビゲーションを検知し、宛先への HTTP リクエストを準備。Cookie / Cache / Service Worker などの介在があればここで判定される。

  3. 03

    DNS / TCP / TLS

    03 Network DNS / TCP / TLS

    ネットワーク層で DNS 解決 → TCP 接続 → TLS ハンドシェイクが順次走る。近年の Web パフォーマンスで無視できない時間帯で、HTTP/3 / 0-RTT で短縮可能。

  4. 04

    空 HTML シェルを返却

    04 Server 空 HTML シェルを返却

    サーバー (通常は静的ホスティング) がほぼ空の HTML を返す。中身は <div id="app"></div> のようなルート要素と、JavaScript バンドルへの <script> 参照だけ。

  5. 05

    HTML パース → DOM (ルートのみ)

    05 Browser HTML パース → DOM (ルートのみ)

    ブラウザが HTML をパースして DOM を構築。ただしアプリのルート要素しか無いので、DOM 自体はほぼ空。

  6. 06

    CSS パース → CSSOM

    06 Browser CSS パース → CSSOM

    リンクされた CSS をパースして CSSOM を構築。DOM と合成するとレンダーツリーができる — ただしこの時点で実コンテンツは無い。

  7. 07

    Style / Layout / Paint-record

    07 Browser Style / Layout / Paint-record

    メインスレッドでスタイル計算 → レイアウト → Paint-record (描画コマンドのリスト生成)。描くものが空のルート要素だけなので、生成される描画コマンドもほぼ空。この時点ではまだピクセル化されていない。

  8. 08

    Raster + レイヤー合成 (空タイル)

    08 Compositor Raster + レイヤー合成 (空タイル)

    コンポジタースレッド (Chrome の Compositor thread は Main thread と独立) が Paint-record をタイルに分割してラスタライズ。レイヤー情報に基づいて合成コマンドを組み立てる。

  9. 09

    GPU コマンド実行 → フレームバッファ

    09 GPU GPU コマンド実行 → フレームバッファ

    GPU プロセス (Chrome では別プロセス) が合成コマンドを実行し、フレームバッファに書き込む。ハードウェアアクセラレーションによって、何万タイルでもミリ秒オーダーで処理される。

  10. 10

    最初のフレームを画面に表示

    10 Display 最初のフレームを画面に表示

    モニタのリフレッシュ (60 Hz なら 16.67 ms 毎の vsync) に合わせてフレームバッファが画面に出力される。ここでようやく「ピクセルがユーザーの目に届く」。SPA ではここに空画面しかない。Core Web Vitals の FCP は厳密にはこのタイミング (paint-record 完了時点ではない)。

  11. FCP 到達 — ただし画面は空
  12. 11

    JS バンドル要求

    11 Browser JS バンドル要求

    HTML パース中に検出した <script> 要素に従って、JS バンドルを並行取得開始。取得するのはまだ「ファイル」で、コードはまだ動いていない。

  13. 12

    JS バンドル転送

    12 Network JS バンドル転送

    ネットワーク経由でバンドル (React / Vue / Angular 等 + アプリコード) を転送。サイズが大きいほど所要時間が伸び、モバイル 4G では 500 KB 超で数秒のスタールが起きやすい。

  14. 13

    JS パース / コンパイル

    13 JS Engine JS パース / コンパイル

    V8 / SpiderMonkey などの JS エンジンがソースをパースしてバイトコードへコンパイル。大きいバンドルほどメインスレッドが止まる時間 (Long Task) が長引き、ミッドレンジスマホなら 1 MB ≒ 200 ms のパース時間が目安。

  15. 14

    フレームワーク起動 → VDOM 生成

    14 JS Engine フレームワーク起動 → VDOM 生成

    React の createRoot / render() などが呼ばれ、コンポーネント関数を実行して仮想 DOM を構築する。この段階ではまだ実 DOM は変わっていない。

  16. 15

    DOM commit (VDOM → 実 DOM)

    15 JS Engine DOM commit (VDOM → 実 DOM)

    フレームワークが仮想 DOM を実 DOM に反映。createElement / appendChild 等の DOM API を JS Engine からブラウザに対して呼び出す。この瞬間、次のレンダリングサイクルの引き金を引く。

  17. 16

    Style 再計算 + Layout (コンテンツ込み)

    16 Browser Style 再計算 + Layout (コンテンツ込み)

    DOM が変わったのでブラウザが再度スタイル計算 → レイアウト → Paint-record を実行。今回は実コンテンツが入っているので、生成される描画コマンドも豊富。

  18. 17

    再ラスタ + レイヤー再合成

    17 Compositor 再ラスタ + レイヤー再合成

    コンポジターがタイルをラスタライズし直し、新しいレイヤーツリーを組み立てる。Chrome RenderingNG では変更があったタイルだけ再ラスタする最適化がかかる。

  19. 18

    フレームバッファ更新

    18 GPU フレームバッファ更新

    GPU がコンポジターのコマンドを実行し、新しいフレームを生成。transform / opacity だけの変更なら Layout / Paint-record をスキップして GPU の合成だけで済むため高速。

  20. 19

    コンテンツが画面に出る

    19 Display コンテンツが画面に出る

    vsync に同期してフレームが出力される。ここでユーザーは初めて実コンテンツを見る。web.dev の Web Vitals 定義によれば LCP (Largest Contentful Paint) は「ビューポート内に表示される最大の画像・テキスト・動画のレンダー時刻」を測る指標。SPA は最初にほぼ空のシェル HTML を返し、JS バンドル実行後に実コンテンツを描画する構造のため、FCP (最初の描画) と LCP (実コンテンツの描画) の間に時間差が生じる。

  21. LCP 付近 — 実コンテンツが表示される
  22. 20

    イベントリスナー付与 → TTI

    20 JS Engine イベントリスナー付与 → TTI

    フレームワークが各コンポーネントにクリック / 入力のリスナーを付与し、useState / ストアを初期化。Main thread がアイドルになりクリックに反応可能に。Time to Interactive (TTI) はこの時点。

  23. TTI 到達 — ここからページが「使える」
  24. 21

    以降: クライアント内ナビゲーション

    21 User 以降: クライアント内ナビゲーション

    リンククリックや操作は History API (pushState) で URL を書き換え、JavaScript が該当コンポーネントをレンダリング。以降のネットワーク往復は API データの取得 (JSON) だけ。これが SPA の「速い」印象の核心。

  25. 22

    VDOM diff → DOM patch (繰り返し)

    22 JS Engine VDOM diff → DOM patch (繰り返し)

    状態が変わるたびにフレームワークが新旧の VDOM を比較し、変更ノードだけを実 DOM にパッチ。その後 Browser → Compositor → GPU → Display のパイプラインが再び走るが、ネットワーク往復は一切無い。これが SPA 最大の強み。

よくある疑問

初めて SPA を学ぶ人が実際に詰まるポイントと、その根本原因。

Q. なぜ「CSR-only な SPA は SEO に弱い」と言われる?
初回の HTML にコンテンツが入っていないから。Googlebot は JavaScript を実行するが、レンダリングキュー処理のため実行タイミングが遅延したり、一部のページが欠落したりする。それ以外のクローラー (Bing / Twitter カード / OGP 展開ボット) は JavaScript を実行しないものが多く、空の HTML しか見えない。対策としては (a) プリレンダリング (ビルド時に静的 HTML を生成) か (b) SSR への移行、または (c) 主要コンテンツだけ初期 HTML に埋め込む (critical content pre-render)。逆に言うと、Next.js / Nuxt / SvelteKit のように SPA の開発体験を保ちつつ SSR / SSG を組み合わせるモダン構成であれば、従来の MPA と同等の SEO 強度に持っていける。「SPA という設計を採ったから SEO に弱い」のではなく、「初期 HTML を空にする CSR-only 運用を選んでいるから弱い」と理解するのが正確。
Q. 「FCP は速いのに LCP が遅い」のはなぜ?
ライフサイクル 04 (空画面の描画) でツールが FCP と記録してしまうから。本当のコンテンツが描画されるのは 07 以降で、LCP (最大コンテンツ描画) はそこで計測される。結果として FCP と LCP の差が 2〜3 秒開く。改善策は「クリティカルパスを短縮する = JS バンドルを小さくする (コード分割 / dynamic import)」、もしくは「SSR に切り替えて 04 時点で中身を出す」。
Q. URL を直接叩いたり、リロードすると 404 になるのはなぜ?
サーバー側には /about のような SPA ルート用 HTML ファイルが存在せず、index.html しか用意されていないから。SPA のルーティングは JavaScript が担当しているため、初回リクエストを必ず index.html に返す「SPA フォールバック」設定が必要。Vercel / Netlify は自動対応、nginx は try_files $uri $uri/ /index.html;、Apache は FallbackResource /index.html で設定する。
Q. 戻るボタンが効かなくなる / スクロール位置が狂うのはなぜ?
History API の 3 点セット (pushState / replaceState / popstate) のうち popstate を拾っていないパターンが多い。popstate は戻る・進むで発火するイベントで、これを購読してルーティング処理を再実行しないと画面が更新されない。スクロール位置は history.scrollRestoration = 'manual' にしてルーティングライブラリで明示管理するのが定石。React Router / Vue Router / SvelteKit はこれを内部で実装している。
Q. 「ハイドレーション」って SPA にも関係ある?
純粋な SPA には存在しない。ハイドレーションはサーバーが返した HTML を再利用するプロセスで、SSR / SSG 固有の概念。SPA は空の HTML を受け取って JavaScript が全てを描画するだけなので、ハイドレーションという段階は無い。ただし Next.js / Nuxt の SSR を export して SSG として配信している場合、実際にはハイドレーションが走っているのでログを見ると hydrateRoot が出てくる。
Q. アクセシビリティが壊れやすいと聞いたけど?
ブラウザは本来ページ遷移時にフォーカス移動・タイトル読み上げを自動で行うが、SPA は「ページ遷移していない」扱いなので全て手動対応になる。具体的には (a) ルート変更時に <h1><main> にフォーカスを移動、(b) document.title を更新、(c) aria-live="polite" 領域でスクリーンリーダーに変更を通知。React Router v6+ や Remix はこの辺りのヘルパーを提供している。

設計判断のサイン

どういう症状が出たら SPA 以外を検討すべきか、逆にどういうプロダクトなら SPA で押し通して良いか。

SPA 以外を検討

SEO 流入が主要な導線になってきた

マーケティングからの要求でランディングページやブログセクションを増やし始めたら、そこだけ SSR or SSG にする「ハイブリッド構成」を検討。Next.js / Nuxt はページ単位でレンダリングモードを変えられる。

SPA 以外を検討

初回ロードの FCP / LCP が常に 2.5 秒超

Core Web Vitals の LCP が Yellow / Red に落ちる。バンドル最適化で改善余地が無ければ、クリティカルコンテンツだけサーバー側で返す SSR / SSG にフォールバック。モバイル回線がターゲットの場合は特に重要。

SPA のままで OK

ログイン後のダッシュボード / 管理画面が主戦場

SEO 不要、クローラー対策不要、複雑な状態管理と豊富な相互作用が中心。こうしたユースケースはむしろ SPA の独擅場なので、SSR に逃げるメリットは薄い。

SPA のままで OK

バックエンドを既存の API サーバーと共有する設計

モバイルアプリや他のクライアントと同じ API を使う前提なら、フロントエンドも純粋な API 消費者にする方がシンプル。SSR を入れると「フロントエンドサーバー + API サーバー」で二層構造になり、デプロイと監視が複雑になる。

よくある落とし穴

バンドルサイズの暴走

ライブラリを気軽に追加していくと 1 MB・2 MB と肥大化し、モバイル回線で数秒スタールする。対策: コード分割 (React.lazy / dynamic import)、Bundle Analyzer で依存を可視化、ツリーシェイキング、moment → dayjs のような軽量代替への置き換え。

メモリリークの蓄積

長時間同じセッションで使われる SPA では、イベントリスナーやタイマー、WebSocket のクリーンアップ漏れが積み重なってメモリが 500 MB・1 GB と膨らむ。対策: useEffect のクリーンアップ関数で必ず解除、AbortController で Fetch を中止、Chrome DevTools の Memory タブで定期的にプロファイル。

OGP / SNS シェアプレビューが壊れる

<meta property="og:title"> を JavaScript で後から書き換えても、SNS のクローラーは JS を実行しないので初期 HTML の meta しか読まない。動的メタタグが必要なら SSR / プリレンダリングで HTML に埋め込むか、各ページ専用の静的 HTML を用意する。

認証トークンの保存場所を間違える

SPA では認証情報をブラウザに保持するため、localStorage に JWT を入れる実装をよく見るが、XSS で一発で盗まれる。対策: HTTP-only cookie + SameSite=Strict を使い、JavaScript から読めなくする。SPA でもこの形は取れる (ログイン API 側でセットすれば OK)。