https://zenn.dev/stin/articles/static-generation-without-framework
ふと自己紹介ページを作りたくなったので作って公開しました!
このサイトを作るに当たって、 Static Generation を Next.js なしでできないかなと考えました。ページは 1 枚でルーティングはないし、サーバーも要らないし、HTML 吐き出すだけならできるだろうと。
そして何より、Next.js 依存からいつでも脱却できる知識は備えておきたいと常々感じていました。
今回は最低限 Static Generation ができるために調べたことを書き残していきます。使用する React のバージョンは 18 です。
react-dom/server
と react-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
関数を用意して、 button
の onClick
イベントに仕込んでいます。ですが、 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 化されているとします。