HOME
2023.10.16

SPA、SSR、SSGってなんだ?|フロントエンド

CODETypeScriptVueNuxt.jsフロントエンド

目次

仕事(と趣味)で思ったより忙しくて記事を書く時間がなかなか取れない今日このごろです。
本エントリーではnuxt.jsやnext.jsでのレンダリングモード周りの話(SSR、SSG、SPAなど)をまとめてみます。

  • なんでSSRは静的ホスティングサービスじゃ使えないの?
  • SSGは動的なサイトに向かないってどういうこと?
  • できることが同じならSSRよりもSPAのほうが開発も楽だし静的ホスティングもできていいじゃん
  • SSGって静的なサイト作るって言ってるけどアウトプットファイル見ると普通にjavascript書いてあるし、javascriptのファイルもいっぱいあるんだけどどういうこと?

といった疑問の答えを提供することを目標に調べたことを書き残したいと思います。

基本的な用語の解説

API

APIはApplication Programming Interfaceの略です。
言葉上の意味はアプリケーションにプログラムでアクセスする際のインターフェース(窓口)という感じですが、実際にはこのインターフェースの仕様を言うこともあります。

例えばSlack BotなどがAPIの代表的な活用事例です。
これ以外にも、DOMなどはJavaScriptからHTMLを操作するためのAPIですし、なんらかのライブラリをプログラムで制御するものに関してもAPIと言われます。

しかし、最もよく使われるのはウェブAPIと呼ばれるAPIの形式で、この代表がRESTと呼ばれる仕様です。
これはHTTPを使ったAPIで、ざっくり言うとあるURLにアクセスするとJSONが返ってくるようなアプリケーションのことです。

近年のウェブ開発ではウェブサーバーとAPIサーバーを用意することが多く、ウェブサーバーから提供されたHTMLをユーザーが閲覧し、その中のボタンなどを押すとAPIサーバーへのリクエストが送られてページの見た目が変わる、といった動きがウェブサービスの定番となっています。

ところが、SPAならたしかにこの説明は正しいのですが、実はSSGやSSRだと少し事情が変わってくることがあります。
ということで、「APIアクセスがどこから行われるのか?」ということを考えるのは現在のフロントエンド開発では非常に重要なことなのです。

イベント

クリックやカーソルのホバーなどの何らかの処理を始めるきっかけとなるような動作のことです。
また、このようなイベントが発生したときに行う処理のことをイベントハンドラと言います。
ただし、JavaScriptにおいてはこのイベントをキャッチする処理そのものをイベントハンドラということもあります。
JavaScriptで扱えるイベントハンドラは以下のサイトに一覧化されています。

レンダリング(rendering)

日本語では「描画」と訳されることの多いこの言葉は、正直かなりわかりにくい言葉です。

確かにコンピュータグラフィックスやゲーム、ブラウザのHTMLレンダリングエンジンなどについて話題にしているときに使われる「レンダリング」という言葉は描画そのものを表します。
例えば、ゲームの描画に関する文脈でプリレンダリングとリアルタイムレンダリングという言葉がよく使われたりします。
これらの言葉は我々が目にする映像などを実際に画面上に描画するタイミングの違いを表していて、「レンダリング」という言葉も画面に描画するという意味で使っています。

一方のSSR(サーバーサイドレンダリング)などで出てくる「レンダリング」という言葉はこの意味とは少し違います。
具体的には、VueやReactなどのJavaScriptを使ってブラウザがすぐに画面に表示できるHTMLを作るという作業がここでいうレンダリングの意味です(HTMLファイルができるかというとそうでもない)。
なのでイメージ的には人間には描画されているようには見えなくても、ブラウザから見ると描画されているも同然という状態まで持っていくこと、と言う感じです。(逆にわかりにくいですかね)

ハイドレーション(hydration)

日本語では「水分補給」という意味です。
正直これだけだとわけがわからないですね。
この言葉については英語のwikiを引用したいと思います。

In web development, hydration or rehydration is a technique in which client-side JavaScript converts a static HTML web page, delivered either through static hosting or server-side rendering, into a dynamic web page by attaching event handlers to the HTML elements. Because the HTML is pre-rendered on a server, this allows for a fast "first contentful paint" (when useful data is first displayed to the user), but there is a period of time afterward where the page appears to be fully loaded and interactive, but is not until the client-side JavaScript is executed and event handlers have been attached.
Frameworks that use hydration include Next.js and Nuxt.js. React v16.0 introduced a "hydrate" function, which hydrates an element, in its API.

せっかくなのでChatGPTに翻訳してもらうと以下のようになります。

ウェブ開発において、hydrationまたはrehydrationとは、クライアントサイドのJavaScriptが、静的なHTMLウェブページ(静的ホスティングまたはサーバーサイドレンダリングを介して提供される)を、HTML要素にイベントハンドラをアタッチすることで、動的なウェブページに変換する技術のことです。HTMLはサーバー側で事前にレンダリングされるため、これにより高速な「最初の有用なコンテンツの表示」(ユーザーに有用なデータが最初に表示されるタイミング)が可能になりますが、その後にページが完全にロードされてインタラクティブになるように見えるが、クライアントサイドのJavaScriptが実行され、イベントハンドラがアタッチされるまでの時間があります。
hydrationを使用するフレームワークには、Next.jsやNuxt.jsなどがあります。React v16.0では、「hydrate」関数が導入され、要素をhydrationするためのAPIとして提供されました。

これはつまり何かというとSSRの仕組みそのものの説明になっています。
補足するとイベントハンドラをアタッチするというのは、上で説明したクリックイベントなどに応じて画面を切り替えたりAPIにアクセスしたりするjavascriptをaddEventListenerメソッドなどを使ってDOM要素に関連付ける操作です。
具体的なコードで言うと以下のような感じです。

<button id="myButton">Click me</button>

<script>
  // ボタン要素を取得
  const button = document.getElementById("myButton");

  // クリックイベントハンドラ関数の作成
  function handleClick() {
    console.log("Button clicked!");
  }

  // クリックイベントハンドラのアタッチ
  button.addEventListener("click", handleClick);
</script>

この操作はブラウザ上でしか行うことができません。
というのも、この操作では実際に作られたDOMツリー(上のコードのdocumentオブジェクト)を扱う必要があり、これができるのはブラウザ上だけだからです。

「いやいや、別にサーバーサイドでもbutton要素のonClick属性にイベントハンドラつければhydrationできるやん」という声が聞こえてきそうですが、それはVueやReactに慣れすぎているために出てくる言葉です。

htmlのbuttonタグにonClickなんて属性はありません。
なのでhydrationはブラウザ上で行う必要があるというわけです。

そして、SSGなどでサーバーサイドでHTMLを作っても生成物の中にJavaScriptのファイルが含まれているのはハイドレーションのためのJavaScriptが必要となるためです。

各レンダリングモードの概要

ここからは各レンダリングモードの概要とメリット・デメリットを解説します。
各レンダリングモードの呼び方はフロントエンド界隈で一般的な(Next.jsなどで使われている)呼称を用いますが、これはNuxt.jsにおける呼称とは異なるのでその点は注意してください。

SPA

一枚のHTMLファイルをブラウザ上でJavaScriptを動かすことで書き換えていくレンダリング方式です。
ReactやVueではデフォルトでこの方式を使うようになっています。

SPAの動きを表したのが下の図です。
SPA.webp

メリット

  • 静的ファイルを配信するだけでいいのでコストが少ない
  • 一度ファイルを配信したらあとはネットワーク通信が不要
  • UXの幅が増える

最後の話を補足すると、SPAはコンポーネントを切り替えるだけでルーティングができるので、スタイリッシュなルーティングができたり、ページをまたいで音楽を流すような体験も可能です。
そういう意味でMPA(multiple page application)にはないUXを実現できるということです。

デメリット

  • 初期ローディングに時間がかかる
  • SEOで不利になる

特に1つ目の初期表示時間はもろにユーザー離脱率に直結するのでコンテンツによってはかなり重要です。

SSR

サーバーサイドでページのHTMLをビルドしておいてリクエストのたびにサーバーサイド側でレンダリングを行う方式です。

SSR.webp

メリット

  • SPAのメリットを享受できる
  • 初期ローディングの時間が短い
  • SEOにおいて有利

NuxtなどにおけるSSRはブラウザ上ではSPAとして動くので、前項で説明したSPAのメリットはSSRでも得ることができます。
またこれに加えてSSRではSPAでデメリットとなっていた初期ローディングの遅さやSEOの問題も解決されています。

デメリット

  • バックエンドでレンダリングを行うためのNode.js環境が必要

これによってサーバー代などのコストがかなりかかります。
参考として、AWSでSSRを行おうとすると例えばEC2上に乗せるという選択肢が考えられます。
この場合、t3.smallなどのかなり弱いマシンを使ったとしても月間100000PVくらいで6,000円以上はかかります。
一方、SPAならS3から配信を行うことができ、これであれば同様のPVでも3,400円くらいで抑えることができます。

SSG

全てのページをHTMLとして事前にレンダリングしておく方式です。

SSG.webp

メリット

  • ブラウザ上での処理が少なく、また軽量なHTMLのみを配信するため高速なページの表示が可能
  • 静的ホスティングサービスで配信することができるためコストが少ない
  • SEOに置いて有利

SSRのメリットに加えて、SSRのデメリットであったコストの大きさについても解決できています。

デメリット

  • SPA特有のUXを提供できない
  • 動的なページを表示するのが難しい

SSGではレンダリングを事前に行うため、ページの追加などを行おうと思うと全てのページをビルドし直す必要があります。
なので、ブログやホームページのような表示されるページが変化しないコンテンツを作成するのに向いています。

各レンダリングモードの生成物を比較

今回以下の2つのページ(index画面とabout画面)を持つウェブサイトをnuxtで作成しました。

index.webp

about.webp

このサイトについて各レンダリングモードでの生成物とパフォーマンスの比較を行っていきます。
なお、各ページに遷移時に以下のようなAPIアクセスを行い、これをもとにページ内のコンテンツを表示します。

const url = 'https://api.github.com/users/Asunaro276'
const userData = ref<any>(null)
const { data } = await useFetch<any>(url)
userData.value = data.value

SPA

SPAでの生成物は以下のようなものになります。(mockServiceWorkerがありますがこれは無視してください。)

spa-generate.webp

これはnuxt.config.tsで以下の設定をし、

export default defineNuxtConfig({
  ssr: false,
})

次のコマンドで生成します。

yarn run nuxi generate

まず、public/index.htmlを見てみましょう

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="modulepreload" as="script" crossorigin href="/_nuxt/entry.15c5f3da.js">
  <link rel="prefetch" as="script" crossorigin href="/_nuxt/error-component.2e03f275.js">
</head>

<body>
  <div id="__nuxt"></div>  <!-- bodyの中に空のdivが一つあるだけ -->
  <script type="application/json" id="__NUXT_DATA__"
    data-ssr="false">[{"_errors":1,"serverRendered":2,"data":3,"state":4},{},false,{},{}]</script>
  <script>window.__NUXT__ = {}; window.__NUXT__.config = { public: {}, app: { baseURL: "/", buildAssetsDir: "/_nuxt/", cdnURL: "" } }</script>
  <script type="module" src="/_nuxt/entry.15c5f3da.js" crossorigin></script>
</body>

</html>

これがindex画面のHTMLファイルです。
注目すべきはbodyタグの中身です。
この中にはdivタグがありますが、この中身は空です。
他にあるのはscriptのみなので、このHTMLをただ表示しただけだと真っ白なページが表示されます。
ではどうするかというとレスポンスで一緒に返ってきたJavaScriptを動かして要素を作ったり文字を埋め込んだりします。
実はaboutページもこのpublic/index.htmlにJavaScriptを当てることで生成されています。(ただしこれはvue-routerを使った場合の話)
ですのでpublic/about/index.htmlは普通にアクセスした場合には使われません。

これがいつ使われるかといえば、いきなりaboutページにアクセスした場合、例えばaboutページでリロードした場合などです。
この場合にはpublic/about/index.htmlが配信され、これをもとにSPAを作るので逆にpublic/index.htmlは使われません。

SPAでこのページを表示したときのパフォーマンスは下の図のようになります。

spa-performance.webp

ここ注視すべきなのはFP、FCP、LCPです。
これらはそれぞれ次のような意味を持ちます。

  • FP (First Paint): 最初に目に見えるものがレンダリングされるまでの表示時間
    • ボーダーラインや背景色などのコンテントでないものも含む
  • FCP (First Contentful Paint): 最初にコンテンツと呼べるものが表示されるまでの時間
    • ヘッダーやフッターなど
  • LCP (Largest Contentful Paing): ページで最も大きいコンテンツが表示されるまでの時間
    • ボディなど

今回の場合これらがほぼ同じところにあり、要は最初に何かが表示されるまでの時間がこのタイミングということです。

SPAではページを表示するために必要な全てのコンテンツをロードした後に表示をしているのでFPがLやDCLのあとにあります。
また、必要なものをロードしたあとにDOMを作ったりfetchをしたりとJSによる処理が入っていることがわかります。

SSR

次にSSRでの成果物は以下のようになります。
ssr-explorer.webp

これは

export default defineNuxtConfig({
  ssr: true,
})

という設定の上で、下記のコマンドを実行することで生成することができます。

yarn run nuxi build

SPAとの違いは大きいところで

  • public下にHTMLファイルがない
  • server下に諸々のjsファイルがある

という2点です。

上記の画像のファイルの中で、SSRのエントリーポイントとなっているのはserver/index.mjsなのでこれを見てみましょう。

globalThis._importMeta_ = { url: import.meta.url, env: process.env };
import "node-fetch-native/polyfill";
import "node:http";
import "node:https";
import "destr";
import "h3";
export { n as default } from "./chunks/nitro/node-server.mjs";
import "ofetch";
import "unenv/runtime/fetch/index";
import "hookable";
import "scule";
import "klona";
import "defu";
import "ohash";
import "ufo";
import "unstorage";
import "radix3";
import "node:fs";
import "node:url";
import "pathe";
//# sourceMappingURL=index.mjs.map

これを見るとnitro-server.mjsがserverとして機能していることが推測されます。
nitroはNuxt3から採用されている新サーバーエンジンで、このnitroが使われるのがSSRということです。
なのでより深くNuxt3のSSRを理解するにはnitroを理解する必要があることがわかります。

とはいえそれを書き出すと膨大な記事になってしまうので、今回はSSRの生成物からわかることを読み取っていきましょう。

ここでserver配下の生成物を改めて詳しく見てみます。
ssr-server-explorer.webp

これをみるとSSRでの処理は次のような感じになりそうなことがわかります。

  • nitro/nitro-server.mjsページでserverとしての機能(HTMLファイルを送るなど)をしていそう
  • handlers/renderer.mjsで各ページのレンダリングを行っていそう
  • app配下のファイルは名前の通りアプリケーションを作るために使いそう
  • rollup配下のファイルはモジュールのバンドルに使いそう

ということがわかります。

ちなみに、Nuxt3のバンドラーはViteですが、Viteは内部でrollupを使っているのでビルド後の生成物の中にはrollupのファイルがあります。

また、SSRでのパフォーマンスは次のようになります。
ssr-performance.webp

この画像から分かる通りLやDCLなどのコンテンツのダウンロードが終わる前にFPやFCPが来ています。
これはサーバーサイドで作られるHTMLさえロードし終わればその他のJavaScriptをロードしなくてもコンテンツをレンダリングすることができるためです。
これらから分かる通りSSRの初期表示速度はSPAより速く、SSRが130ms程度での表示なのに対しSPAは300ms程度です。

ただし、サーバーのスペックや状況によってはSPAと同程度のパフォーマンスとなることもあります。

SSG

最後にSSGでの生成物を見てみます。
ssg-explorer.webp

これは

export default defineNuxtConfig({
  ssr: true,
})

という設定の上で、下記のコマンドを実行することで生成することができます。

yarn run nuxi generate

これをみるとSPAの場合と生成されるファイルが近いことがわかります。
SPAとの違いとしては_payload.jsonが追加されている点です。
この_payload.jsonの中身は次のようになっています。

[
  { "data": 1, "prerenderedAt": 29 },
  ["Reactive", 2],
  { "7U8zAuYCjJ": 3 },
  {
    "login": 4,
    "id": 5,
    "node_id": 6,
    "avatar_url": 7,
    "gravatar_id": 8,
    "url": 9,
    "html_url": 10,
    "followers_url": 11,
    "following_url": 12,
    "gists_url": 13,
    "starred_url": 14,
    "subscriptions_url": 15,
    "organizations_url": 16,
    "repos_url": 17,
    "events_url": 18,
    "received_events_url": 19,
    "type": 20,
    "site_admin": 21,
    "name": 22,
    "company": 23,
    "blog": 8,
    "location": 23,
    "email": 23,
    "hireable": 23,
    "bio": 23,
    "twitter_username": 23,
    "public_repos": 24,
    "public_gists": 25,
    "followers": 26,
    "following": 25,
    "created_at": 27,
    "updated_at": 28
  },
  "Asunaro276",
  57317148,
  "MDQ6VXNlcjU3MzE3MTQ4",
  "https://avatars.githubusercontent.com/u/57317148?v=4",
  "",
  "https://api.github.com/users/Asunaro276",
  "https://github.com/Asunaro276",
  "https://api.github.com/users/Asunaro276/followers",
  "https://api.github.com/users/Asunaro276/following{/other_user}",
  "https://api.github.com/users/Asunaro276/gists{/gist_id}",
  "https://api.github.com/users/Asunaro276/starred{/owner}{/repo}",
  "https://api.github.com/users/Asunaro276/subscriptions",
  "https://api.github.com/users/Asunaro276/orgs",
  "https://api.github.com/users/Asunaro276/repos",
  "https://api.github.com/users/Asunaro276/events{/privacy}",
  "https://api.github.com/users/Asunaro276/received_events",
  "User",
  false,
  "Ryuhei Nakano",
  null,
  15,
  0,
  1,
  "2019-11-03T11:24:44Z",
  "2023-05-01T15:39:21Z",
  1697256654745
]

これはindexとaboutで使われているhttps://api.github.com/users/Asunaro276へのAPIレスポンスです。
つまり、SSGではビルドを行う時点でAPIアクセスを行い、その結果を_payload.jsonとして格納しているということです。

続いてpublic/index.htmlの中身を見てみると、その内容はは次のようになっています。

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="preload" as="fetch" crossorigin="anonymous" href="/_payload.json">
  <link rel="modulepreload" as="script" crossorigin href="/_nuxt/entry.15c5f3da.js">
  <link rel="modulepreload" as="script" crossorigin href="/_nuxt/index.364f2f15.js">
  <link rel="modulepreload" as="script" crossorigin href="/_nuxt/nuxt-link.2885a033.js">
  <link rel="modulepreload" as="script" crossorigin href="/_nuxt/fetch.756e09a6.js">
  <link rel="prefetch" as="script" crossorigin href="/_nuxt/error-component.2e03f275.js">
</head>

<body>
  <div id="__nuxt">
    <div>
      <div>
        <h1>Index</h1>
        <p>Hello, Nuxt!</p><a href="/about" class="">About</a>
        <div>Asunaro276</div>
      </div>
    </div>
  </div>
  <script type="application/json" id="__NUXT_DATA__" data-ssr="true"
    data-src="/_payload.json">[{"state":1,"_errors":3,"serverRendered":6,"prerenderedAt":7},["Reactive",2],{},["Reactive",4],{"7U8zAuYCjJ":5},null,true,1697204597157]</script>
  <script>window.__NUXT__ = {}; window.__NUXT__.config = { public: {}, app: { baseURL: "/", buildAssetsDir: "/_nuxt/", cdnURL: "" } }</script>
  <script type="module" src="/_nuxt/entry.15c5f3da.js" crossorigin></script>
</body>

</html>

これを見るとSSGでは必要なコンテンツがすでにHTMLとしてレンダリングされていることがわかります。

実際、パフォーマンスを見ると次のようになっています。
ssg-performance.webp

SSGでのパフォーマンスはSSRと同程度かさらに速いです。
また、ブラウザ上での動きに関してはSSRとほとんど同様となっています。

まとめ

本記事ではSPA、SSR、SSGそれぞれの挙動やパフォーマンスの差を比較しました。
結論としては作りたいアプリケーションとコストとの兼ね合いで適切なレンダリング方式を選ぶべきということになります。

それにしても完全に記事の粒度を失敗しました。
今後は記事の粒度を小さくできるようにネタ探ししていきたいですね。

CODE

© Asunaro 2022