https://zenn.dev/stin/articles/static-generation-without-framework

Next.js が何をしているかを理解したかった

ふと自己紹介ページを作りたくなったので作って公開しました!

このサイトを作るに当たって、 Static Generation を Next.js なしでできないかなと考えました。ページは 1 枚でルーティングはないし、サーバーも要らないし、HTML 吐き出すだけならできるだろうと。

そして何より、Next.js 依存からいつでも脱却できる知識は備えておきたいと常々感じていました。

今回は最低限 Static Generation ができるために調べたことを書き残していきます。使用する React のバージョンは 18 です。

react-dom/serverreact-dom/client

create-react-app でプロジェクトを作ると、 src/index.tsx には次のようなコードが書かれています。

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

react-dom/client はブラウザ側で使用されること想定されているモジュールで、 ReactDOM.createRoot が空の div 要素 document.getElementById('root') に対して React アプリを構築する API です。「空の div 要素」ということで、 React.createRoot はブラウザ側で 1 から DOM を構築するいわゆる Client Side Rendering を行うために使用します。

一方、本記事の目的でもある Static Generation は予めコンポーネントの初期状態を HTML ファイルに書き込んでおくことを指します。そのためにはまずコンポーネントを HTML の文字列に変換する必要がありますが、それをやってくれるのが react-dom/server モジュールです。

import ReactDOMServer from "react-dom/server";

const MyComponent = () => <div className="my-class">text</div>;

const html: string = ReactDOMServer.renderToString(<MyComponent />);

console.log(html); // <div class="my-class">text</div>

たったこれだけでコンポーネントが生成する HTML を string 型で受け取れるようになります。じゃあもうこれをブラウザからのリクエストに返すだけでいいんだ!…とは当然なりません。

もう少し React らしい例を試してみましょう。みんな大好き(?)カウンターアプリです。

const App: FC = () => {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  return (
    <div>
      <button onClick={increment}>increment</button>
      <p>count: {count}</p>
    </div>
  );
};

const html = ReactDOMServer.renderToString(<App />);

console.log(html); // <div><button>increment</button><p>count: <!-- -->0</p></div>

useState を使っています。 increment 関数を用意して、 buttononClick イベントに仕込んでいます。ですが、 html には useState が使われていることも button にイベントリスナーがセットされていることも情報として含まれていません。これをブラウザに送って読み込んでも、ブラウザは動かない button を描画するだけで React アプリとして認識しないのです。

ReactDOMServer.renderToString で生成した HTML を受け取ったブラウザには、それが React アプリであると認識してもらう必要があります。それを担当するのが react-dom/client に含まれている hydrateRoot です。これを使用することで React アプリとして読み込まれ、イベントリスナーが DOM にアタッチされていきます。この操作は hydrate/hydration と呼ばれます。

import ReactDOMClient from "react-dom/client";
import { MyRoot } from "./component";

const rootElement = document.getElementById("react-root");

if (rootElement === null) throw new Error("rootElement was not found.");

ReactDOMClient.hydrateRoot(rootElement, <App />);

ここで App は先程の ReactDOMServer.renderToString(<App />) で使った App と同じものを指しています。また、その DOM は予め <div id="react-root"> の子要素として HTML 化されているとします。