Skip to content

mini-next(SSR + ルーティング + ハイドレーション)

サーバで HTML を組み、URL でページを選び、届いた静的 HTML にイベントを後付けして動かす。Next.js のようなフレームワークの核だけを仮想DOM の上に作る。1 つの仮想DOM 木を、実 DOM 化・文字列化(SSR)・イベント付け(hydrate)の 3 通りに使い回すのが要点だ。ハイドレーションは作り直さず、文字列に載らなかったイベントだけを後から付ける。

この章で作るもの

仮想DOMは「仮想DOM木 → 実DOM」を作った。だが実際のフレームワークは、同じ木をもっと別の使い方もする。最初の画面をサーバで HTML にして返し(速い・SEO に効く)、その静的HTMLをクライアントで対話可能にする。この2つを繋ぐのが SSR とハイドレーションで、どのページを描くかを決めるのがルーティング。

作るのは3つ:

  • SSR (renderToString): 仮想DOM木を実DOMでなく HTML文字列にする
  • ルーティング (createRouter): URL のパスをページコンポーネントに対応づける
  • ハイドレーション (hydrate): サーバHTMLを作り直さず、イベントリスナだけを既存DOMに後付けする

順に見ていく。

  1. 同じ木を3通りに使う: コンポーネントが返す仮想DOM木を、実DOM化・文字列化・イベント付けの3方向に回す。コンポーネントは「どこに描かれるか」を知らなくてよい
  2. ハイドレーション = 作り直さない: サーバ描画済みの実DOMを捨てず、文字列に載らなかったイベントだけを付ける
  3. SSR は Node で動く: renderToString は文字列連結だけ。実DOMが無いサーバでも走る

全体像: 1つの木、3つの行き先

              コンポーネント → 仮想DOM木

        ┌────────────┼─────────────────┐
        ▼            ▼                  ▼
   renderToString   mount(vdom)      hydrate
   (HTML文字列)     (実DOMを新規作成)  (既存DOMにイベント付与)
        │                               ▲
        ▼                               │
   サーバが返す ──▶ ブラウザが静的描画 ──┘
                    (この時点では“動かない”)
コンポーネントが返す1つの仮想DOM木の3つの行き先。サーバでは文字列化(SSR)して返す。ブラウザはその文字列を静的DOMとして描く。そこへ同じ木からイベントだけを後付け(hydrate)して対話可能にする。mount(vdom章)は『DOMがまだ無い』場面用で、hydrate は『DOMが既にある』場面用

mount(前章)と hydrate の使い分けがこの章の中心になる。DOM がまだ無いなら mount で作る。DOM が既にある(サーバが描いた)なら、作り直さず hydrate でイベントだけ足す。

SSR: 木を HTML文字列にする

サーバには実DOM(document)が無い。だから木を文字列として組み立てる。実DOMを一切触らないので Node でそのまま動く:

ts
import { type VNode, isEventProp } from "../vdom/vdom";

// SSR(Server-Side Rendering): 同じ仮想DOM木を、実DOMでなく HTML文字列に変換する。
// サーバで文字列を組み立てて返せば、初回表示が速く、クローラも中身を読める。
// クライアントに実DOMは要らない——文字列を組むだけなので Node でも動く。

// 閉じタグを持たない void 要素。
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);

// #region render{ts}
export function renderToString(vnode: VNode): string {
  // テキストは中身をエスケープするだけ(タグ注入を防ぐ最初の一歩)
  if (vnode.kind === "text") return escapeHtml(vnode.text);

  const attrs = Object.entries(vnode.props)
    .filter(([key, value]) => !isEventProp(key) && value !== false && value !== null && value !== undefined)
    .map(([key, value]) => ` ${key}="${escapeAttr(String(value))}"`)
    .join("");

  // イベント(on*)は関数なので文字列化できない → 出力しない。
  // ハイドレーション(hydrate.ts)でクライアント側から付け直す。
  if (VOID.has(vnode.type)) return `<${vnode.type}${attrs}>`;

  const inner = vnode.children.map(renderToString).join("");
  return `<${vnode.type}${attrs}>${inner}</${vnode.type}>`;
}
// #endregion render{ts}

// #region escape{ts}
// テキスト中の < > & を実体参照に。これを怠ると文字列がタグとして解釈される(XSS)。
function escapeHtml(s: string): string {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// 属性値は " で囲むので、" と & を実体参照に。
function escapeAttr(s: string): string {
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
// #endregion escape{ts}

要点は2つ。1 つ目はイベント(on*)を出力しないこと。関数は文字列にできないので、ここで欠ける。この欠けをクライアントの hydrate が埋める、という対称性が後で効く。もう一つはエスケープだ:

ts
import { type VNode, isEventProp } from "../vdom/vdom";

// SSR(Server-Side Rendering): 同じ仮想DOM木を、実DOMでなく HTML文字列に変換する。
// サーバで文字列を組み立てて返せば、初回表示が速く、クローラも中身を読める。
// クライアントに実DOMは要らない——文字列を組むだけなので Node でも動く。

// 閉じタグを持たない void 要素。
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);

// #region render{ts}
export function renderToString(vnode: VNode): string {
  // テキストは中身をエスケープするだけ(タグ注入を防ぐ最初の一歩)
  if (vnode.kind === "text") return escapeHtml(vnode.text);

  const attrs = Object.entries(vnode.props)
    .filter(([key, value]) => !isEventProp(key) && value !== false && value !== null && value !== undefined)
    .map(([key, value]) => ` ${key}="${escapeAttr(String(value))}"`)
    .join("");

  // イベント(on*)は関数なので文字列化できない → 出力しない。
  // ハイドレーション(hydrate.ts)でクライアント側から付け直す。
  if (VOID.has(vnode.type)) return `<${vnode.type}${attrs}>`;

  const inner = vnode.children.map(renderToString).join("");
  return `<${vnode.type}${attrs}>${inner}</${vnode.type}>`;
}
// #endregion render{ts}

// #region escape{ts}
// テキスト中の < > & を実体参照に。これを怠ると文字列がタグとして解釈される(XSS)。
function escapeHtml(s: string): string {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// 属性値は " で囲むので、" と & を実体参照に。
function escapeAttr(s: string): string {
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
// #endregion escape{ts}

テキストに <script> が混ざったとき、エスケープしないとそれがタグとして解釈される(XSS)。SSR は「文字列を組む」ので、この危険と常に隣り合わせ。テキストは < > &、属性値は " & を実体参照にするのが最初の防御。

ルーティング: URL → ページ

どのページを描くかは URL で決まる。/posts/4242 のような動的セグメントを取り出せると、記事ページを1つのテンプレートで書ける:

ts
import { type VNode } from "../vdom/vdom";

// ルーティング: URL のパスをページコンポーネントに対応づける。
// Next.js の pages/posts/[id].tsx が /posts/:id に対応するのと同じ発想を、
// ファイルの代わりに明示的な route 配列で表す。

export type Params = Record<string, string>;
export type PageComponent = (params: Params) => VNode;

export interface Route {
  path: string; // "/posts/:id" のように :name で動的セグメントを表す
  page: PageComponent;
}

export interface Matched {
  page: PageComponent;
  params: Params;
}

export interface Router {
  resolve(url: string): Matched | null;
}

// #region router{ts}
export function createRouter(routes: Route[]): Router {
  // 各 route のパスをセグメント配列に前処理しておく。
  const compiled = routes.map((r) => ({ segments: split(r.path), page: r.page }));

  return {
    resolve(url) {
      const target = split(stripQuery(url));
      // 登録順に先頭一致で採用する(静的 route を動的より先に置けば優先できる)
      for (const route of compiled) {
        const params = matchSegments(route.segments, target);
        if (params) return { page: route.page, params };
      }
      return null;
    },
  };
}

// パスのセグメントどうしを突き合わせる。:name は任意の1セグメントを捕まえて params に入れる。
// 一致しなければ null。
function matchSegments(pattern: string[], target: string[]): Params | null {
  if (pattern.length !== target.length) return null;
  const params: Params = {};
  for (let i = 0; i < pattern.length; i++) {
    const p = pattern[i] as string;
    const t = target[i] as string;
    if (p.startsWith(":")) {
      params[p.slice(1)] = decodeURIComponent(t); // 動的: 捕獲
    } else if (p !== t) {
      return null; // 静的: 不一致
    }
  }
  return params;
}
// #endregion router{ts}

const stripQuery = (url: string): string => {
  const q = url.indexOf("?");
  return q === -1 ? url : url.slice(0, q);
};

// "/posts/42" → ["posts","42"]。前後のスラッシュと空セグメントを落とす。
const split = (path: string): string[] => path.split("/").filter((s) => s.length > 0);

パスをセグメント配列(/posts/42["posts","42"])に割り、route のパターンと位置ごとに突き合わせる。:id は任意の1セグメントを捕まえて params に入れる。Next.js の pages/posts/[id].tsx がファイル名でやっていることを、明示的な配列でやっているだけ。

ハイドレーション: 作り直さず、イベントだけ足す

ここが SSR と対になる仕組みだ。サーバが返した HTML はブラウザで実DOMとして既に描かれている。でもイベントは載っていないので、ボタンを押しても動かない。ここで木を mount し直したら、同じDOMをもう一度作ることになり無駄で、一瞬ちらつく。

代わりに、既存のDOMをたどりながら、木の on* をリスナとして付けていく:

ts
import { type VNode, isEventProp, eventName } from "../vdom/vdom";

// ハイドレーション(hydration): サーバが吐いた静的HTMLを「作り直さず」に、
// イベントリスナだけを後付けして対話可能にする。
//
// なぜ mount で作り直さないのか: サーバ描画済みの実DOMが既に画面にある。
// それを捨てて mount し直すと、一瞬ちらつくし無駄。既存ノードを使い回し、
// 「文字列HTMLには乗らなかった部分(=イベント)」だけを付けるのがハイドレーション。

// #region hydrate{ts}
// vnode 木と、サーバ描画済みの実ノードを平行にたどり、on* をリスナとして付ける。
export function hydrate(vnode: VNode, node: Node): void {
  if (vnode.kind === "text") return; // テキストは中身だけ。付けるものは無い

  const el = node as HTMLElement;
  for (const [key, value] of Object.entries(vnode.props)) {
    if (isEventProp(key)) {
      el.addEventListener(eventName(key), value as EventListener);
    }
    // 属性は既にサーバHTMLに乗っている。ここでは触らない(作り直さないのが肝)
  }

  // 子を同じ位置(index)どうしで対応づけて再帰。
  // サーバとクライアントで同じ木を描いていれば構造は一致している前提。
  vnode.children.forEach((child, i) => {
    const childNode = el.childNodes[i];
    if (childNode) hydrate(child, childNode);
  });
}
// #endregion hydrate{ts}

新しいノードは1つも作らない。サーバHTMLで欠けていた「イベント」だけを、位置を頼りに後付けする。これがハイドレーション。SSR で欠けた分を、ちょうど補完する。

動かす

下のデモで、URL を選ぶとルータがページを解決し、SSR が HTML文字列を作り、ブラウザが静的に描画する。この時点ではまだ未ハイドレートで、「いいね」ボタンを押しても動かない(イベント未接続)。「ハイドレート」を押すと、既存DOMはそのままにイベントだけが付き、ボタンが反応するようになる。SSR とハイドレーションの間にある「見えるが動かない」瞬間を体感してほしい。

デモmini-next(SSR → ハイドレーション)静的(未hydrate)
1. ルータの解決
URL /posts/42
page 記事 #42
2. SSR 出力(サーバが返すHTML・イベントは載らない)
<main><h1>記事 #42</h1><p>本文のプレビュー…</p><button>いいね</button></main>
3. ブラウザ(静的描画 → ハイドレートで対話可能)
記事 #42
本文のプレビュー…
イベント未接続

サーバが返した静的HTMLをブラウザが描画済み。だが未ハイドレート=ボタンはまだ繋がっていない

サーバは HTML文字列を返すだけ(実DOM不要=Nodeで動く)。イベントは文字列に載らないハイドレーション=既存DOMを作り直さず、欠けていたイベントだけを後付けする

繋ぐ: renderRoute と hydrateRoute

サーバ側は「URL → HTML」、クライアント側は「URL → 既存DOMにイベント付与」。同じルータと同じページ関数を両側で使う:

ts
import { type Router } from "./router";
import { renderToString } from "./ssr";
import { hydrate } from "./hydrate";

// SSR → hydrate の一連の流れをまとめる。
// サーバ: renderRoute で HTML文字列を作って返す。
// クライアント: hydrateRoute で、その HTML の上にイベントを付けて動かす。

// #region app{ts}
// サーバ側: パスに対応するページを HTML文字列にして返す。
// 未一致なら空文字(404 をどう出すかは呼び出し側=フレームワーク利用者の責務)。
export function renderRoute(router: Router, url: string): string {
  const matched = router.resolve(url);
  if (!matched) return "";
  const tree = matched.page(matched.params);
  return renderToString(tree);
}

// クライアント側: 同じパスから同じ木を作り、サーバ描画済みの container にハイドレート。
// tree を作り直しても mount はしない——既存DOMにイベントを付けるだけ。
export function hydrateRoute(router: Router, url: string, container: HTMLElement): void {
  const matched = router.resolve(url);
  if (!matched) return;
  const tree = matched.page(matched.params);
  const root = container.firstChild;
  if (root) hydrate(tree, root);
}
// #endregion app{ts}

サーバとクライアントで同じ木を作るのが前提。ここがズレると、サーバの <h1>A</h1> にクライアントが <h1>B</h1> のつもりでイベントを付ける、といった食い違い(hydration mismatch)が起きる。

設計の観点: なぜ SSR とハイドレーションなのか

「SPA でいいのに、なぜサーバでも描くのか」に答えられるか:

  • 初回表示(FCP)と SEO: SPA は「空の HTML + 大きな JS」から始まり、JS を落として実行するまで何も見えない。SSR は最初から中身のある HTML を返すので、速く見え、クローラも読める
  • ハイドレーションのコスト: だが SSR HTML を送っても、対話可能にするには結局クライアントで同じ木を作ってイベントを付ける。この二度手間が TTI(操作可能までの時間)を押し上げる。「見えるのに押せない」時間が生まれる
  • 部分/選択的ハイドレーション: 全部を一度に hydrate せず、必要な島だけを hydrate する(Islands / React Server Components)。この章は一括 hydrate なので、そこは踏み込まない
  • サーバ/クライアントの同型性: 同じコンポーネントを両側で実行するので、window 依存のコードがサーバで壊れる、といった落とし穴がある

メリット・デメリットと実例

方式初回HTML対話可能まで向く場面実例
CSR(SPA)空 + JSJS実行後管理画面など SEO 不要素の React/Vue SPA
SSR + hydrate中身ありhydrate後コンテンツ + 対話Next.js(Pages)、Nuxt
SSG事前生成の中身hydrate後更新が稀なサイトNext.js(SSG)、Astro、VitePress
Islands / RSC中身あり島だけ hydrate大半が静的なページAstro Islands、React Server Components

裏どり:

  • Next.js: この章の renderToString + ルーティング + ハイドレーションを、はるかに作り込んだもの。データ取得・コード分割・画像最適化などが乗る
  • VitePress(この教科書自身): SSG。ビルド時に各ページを HTML 化し、ブラウザで Vue が hydrate する。まさにこの章の仕組みの上で動いている
  • Astro: 既定は JS ゼロの静的HTML。対話が要る部分だけを「島」として hydrate する(部分ハイドレーション)
  • React Server Components: サーバでしか動かないコンポーネントを混ぜ、クライアントに送る JS を減らす新しい方向

簡略化したこと

  • 状態管理・再描画なし: hydrate 後の状態変化→再描画のループは無し(前章 vdom の diff を繋げば作れる)。ここは「静的HTML→対話可能」への接続まで
  • hydration mismatch の検出なし: サーバとクライアントで木がズレたときの警告・修復は無し
  • ネストルート・レイアウト・データ取得なし: getServerSideProps 的なデータ取得、ネストレイアウト、コード分割は無し
  • 単純な :id 捕獲のみ: [...slug] のワイルドカードやオプショナルセグメントは無し
  • ストリーミング・部分ハイドレーションなし: 一括で文字列化・一括で hydrate

参考資料