Counts the Clouds

Reactで単純なSPA・SSG・SSRのビルドをできるようにする
2022.05.23

React
SSG
SSR

luca-bravo-xnqVGsbXgV4-unsplash.jpg

今や、Next.jsによって自在にコントロールできるようになってきたが、これまで手動で設定したことがなかったので仕組みの理解のためにも試してみる。

SSG(Static Site Generation)をできるようにする

こちらを参考に、起点としてまずはSSGをできるようにする。

簡単のため、JavaScriptで書き直そうかと思ったけど、.babelrcの設定やNode.jsでES Modulesを動かす設定をするよりTypeScriptのほうが簡単だったのでTypeScriptのまま進める。

% npm init -y
% npm i react react-dom react-helmet express
% npm i -D @types/node @types/react @types/react-dom @types/react-helmet \
@types/express
% npm i -D typescript ts-node \
webpack webpack-cli webpack-dev-server ts-loader \
copy-webpack-plugin clean-webpack-plugin html-webpack-plugin \
npm-run-all

ファイル構成

各ファイルを追加していく。ファイル構成は参考サイトとほぼ同じで以下の通り。

.
├── (build)
├── (node_modules)
├── package-lock.json
├── package.json
├── public
│   └── favicon.ico
├── server # あとで追加
│   └── index.tsx # あとで追加
├── src
│   ├── generate.tsx
│   ├── index.tsx
│   └── page.tsx
├── tsconfig.json
└── webpack.config.js

publicディレクトリは必要なので、faviconを適当にcreate-react-appなどから取る。

コンポーネント類

SSG処理を行うgenerate.tsx。エントリーポイントのファイル名をindex.jsに変更。

// generate.tsx
import fs from 'fs';
import path from 'path';
import ReactDOMServer from 'react-dom/server';
import { Helmet } from 'react-helmet';
import { Page } from './page';

const pageString = ReactDOMServer.renderToString(<Page />);
const helmet = Helmet.renderStatic();

const html = `<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta name="viewport" content="width=device-width">
    ${helmet.title.toString()}
    ${helmet.meta.toString()}
  </head>
  <body>
    <div id="react-root">${pageString}</div>
    <script src="index.js"></script>
  </body>
</html>
`;

async function writeFile(file: string, data: string): Promise<void> {
  await fs.promises.mkdir(path.dirname(file), { recursive: true });
  fs.promises.writeFile(file, data);
}

writeFile(path.resolve(__dirname, '../build/index.html'), html);

エントリーポイントは各処理で共通のファイルにするためファイル名をindex.tsxに変更。

// index.tsx
import ReactDOMClient from 'react-dom/client';
import { Page } from './page';

const rootElement = document.getElementById('react-root');
if (rootElement === null) throw new Error('rootElement not found.');

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

マウントするコンポーネント。これは参考サイトから変更なし。

// page.tsx
import { FC, useCallback, useState } from 'react';
import { Helmet } from 'react-helmet';

export const Page: FC = () => {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  return (
    <>
      <Helmet>
        <title>React Counter</title>
        <meta name="description" content="Static Generation のテスト" />
      </Helmet>
      <div>
        <button onClick={increment}>increment</button>
        <p>count: {count}</p>
      </div>
    </>
  );
};

TypeScriptの設定

TypeScriptの設定は"include": ["**/*"],だけ追加。

{
  "include": ["**/*"],
  "compilerOptions": {
    "target": "es2016",
    "jsx": "react-jsx",
    "module": "esnext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "ts-node": {
    "compilerOptions": {
      "module": "commonjs"
    }
  }
}

Webpackの設定

buildディレクトリをクリアするため clean-webpack-pluginを追加。entry先をindex.tsx(index.js)に変更。

// webpack.config.js
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

/** @type {import("webpack").Configuration} */
module.exports = {
  mode: 'production',
  entry: './src/index.tsx',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'index.js',
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js'],
  },
  module: {
    rules: [{ test: /\.tsx?$/, loader: 'ts-loader' }],
  },
  plugins: [
    new CleanWebpackPlugin(),
    new CopyPlugin({
      patterns: [{ from: 'public' }],
    }),
  ],
};

package.json

package.jsonにスクリプトを追加。今後、他のビルドも追加していくため、コマンド名をstaticに変更。clean-webpack-pluginのため、run-prun-sに変更。

  "scripts": {
    "static": "run-s static:*",
    "static:client": "webpack",
    "static:generate": "ts-node src/generate.tsx"
  },
% npm run static

で、ビルドが走ることを確認。

% npx serve ./build

で、アプリが動作していることを確認。

開発環境を起動できるようにする

開発環境(webpack-dev-server)はたいてい必要になるので追加しておく。以下のコマンドで実行できるようにする。

"dev": "webpack serve --open",

webpack-dev-serverを経由していると環境変数WEBPACK_SERVEtrueとなるので、これを利用して開発環境用の設定を分岐させる。他の環境では何も返らないのでfalseが返るように強制した。html-webpack-pluginを追加。

// webpack.config.js
  const path = require('path');
+ const webpack = require('webpack');
  const CopyPlugin = require('copy-webpack-plugin');
  const { CleanWebpackPlugin } = require('clean-webpack-plugin');
+ const HtmlWebpackPlugin = require('html-webpack-plugin');

+ const WEBPACK_SERVE = process.env.WEBPACK_SERVE;

+ const plugins = [
+   new CleanWebpackPlugin(),
+   new CopyPlugin({
+     patterns: [{ from: 'public' }],
+   }),
+   new webpack.DefinePlugin({
+     'process.env': {
+       WEBPACK_SERVE: JSON.stringify(WEBPACK_SERVE || false),
+     },
+   }),
+ ];

+ if (WEBPACK_SERVE) {
+   plugins.push(
+     new HtmlWebpackPlugin({
+       template: 'src/index.ejs',
+     })
+   );
+ }

  /** @type {import("webpack").Configuration} */
  module.exports = {
+   mode: WEBPACK_SERVE ? 'development' : 'production',
    entry: './src/index.tsx',
    output: {
      path: path.resolve(__dirname, 'build'),
      filename: 'index.js',
    },
    resolve: {
      extensions: ['.ts', '.tsx', '.js'],
    },
    module: {
      rules: [{ test: /\.tsx?$/, loader: 'ts-loader' }],
    },
+   plugins,
-   plugins: [
-     new CleanWebpackPlugin(),
-     new CopyPlugin({
-       patterns: [{ from: 'public' }],
-     }),
-   ],
  };

開発環境のHTMLファイルのテンプレートとしてindex.ejsを追加。

<!-- index.ejs -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta name="viewport" content="width=device-width" />
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <div id="react-root"></div>
  </body>
</html>

SSGで生成しているHTMLとちょっと違ってしまっているが、今は重要ではないので気にしないことにする。

先ほどのWEBPACK_SERVE環境変数を利用してindex.tsxに条件分岐を追加。

// index.tsx
import { createRoot, hydrateRoot } from 'react-dom/client';
import { Page } from './page';

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

if (process.env.WEBPACK_SERVE) {
  const root = createRoot(rootElement!); // createRoot(container!) if you use TypeScript
  root.render(<Page />);
} else {
  if (rootElement === null) throw new Error('rootElement not found.');
  hydrateRoot(rootElement, <Page />);
}
% npm run dev

で、webpack-dev-serverの開発環境が動くことを確認。

SPA(Single Page Application)をビルドできるようにする

Reactユーザーになれば最初に触れる基本的なビルドなので、これは簡単。以下のコマンドで実行できるようにする。SPAとSSGの設定を区別するためSSG側にパラメータをわたすようにした。

    "build": "webpack",
-   "static:client": "webpack",
+   "static:client": "webpack --env=ssg=1",

パラメータを受け取るためwebpack.config.jsを関数化し、プラグインの追加処理を関数内に移動。SPAではhtml-webpack-pluginのテンプレートを使用するため、受け取ったパラメータをプラグインの条件に追加する。

// ...

/** @type {import("webpack").Configuration} */
module.exports = (env) => {
  if (WEBPACK_SERVE || env.ssg != 1) {
    plugins.push(
      new HtmlWebpackPlugin({
        template: 'src/index.ejs',
      })
    );
  }

  // ...
};
% npm run build
% npx serve ./build

ビルドしてアプリが動作することを確認。

SSR(Server Side Rendering)をできるようにする

これらのサイトを参考にした。

以下のコマンドで実行できるようにする。 Node.jsのサーバーでリクエストを待ち、レンダリングして配信する。

"start": "run-s start:*",
"start:server": "ts-node ./server/index.tsx",

serverディレクトリ、その中にindex.tsxを追加。こうして見ると、やることはSSGとほぼ同じのようだ。

// server/index.tsx
import express from 'express';
import ReactDOMServer from 'react-dom/server';
import { Helmet } from 'react-helmet';
import { Page } from '../src/page';

const PORT = process.env.PORT || 3006;
const app = express();

app.get('/', (req, res) => {
  const pageString = ReactDOMServer.renderToString(<Page />);
  const helmet = Helmet.renderStatic();

  const html = `<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta name="viewport" content="width=device-width">
    ${helmet.title.toString()}
    ${helmet.meta.toString()}
  </head>
  <body>
    <div id="react-root">${pageString}</div>
    <script src="index.js"></script>
  </body>
</html>
`;

  return res.send(html);
});

app.use(express.static('./build'));

app.listen(PORT, () => {
  console.log(`Server is listening on port ${PORT}`);
});
% npm start

で、アプリが動くことを確認。

ごく単純な例ではあるものの、Reactで開発環境・SPA・SSG・SSRの設定を行うことができた。

レスポンスの比較

SSGとSSRではコンポーネントもHTMLにレンダリングされているのがわかる。

SSG

% npm run static
% npx serve ./build
% curl http://localhost:3000
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta name="viewport" content="width=device-width">
    <title data-react-helmet="true">React Counter</title>
    <meta data-react-helmet="true" name="description" content="Static Generation のテスト"/>
  </head>
  <body>
    <div id="react-root"><div><button>increment</button><p>count: <!-- -->0</p></div></div>
    <script src="index.js"></script>
  </body>
</html>

webpack-dev-server

% npm run dev
% curl http://localhost:8080
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta name="viewport" content="width=device-width" />
    <title>Webpack App</title>
    <script defer src="index.js"></script></head>
  <body>
    <div id="react-root"></div>
  </body>
</html>

SPA

% npm run build
% npx serve ./build
% curl http://localhost:3000
<!doctype html><html lang="ja"><head><meta name="viewport" content="width=device-width"/><title>Webpack App</title><script defer="defer" src="index.js"></script></head><body><div id="react-root"></div></body></html>

SSR

% npm start
% curl http://localhost:3006
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta name="viewport" content="width=device-width">
    <title data-react-helmet="true">React Counter</title>
    <meta data-react-helmet="true" name="description" content="Static Generation のテスト"/>
  </head>
  <body>
    <div id="react-root"><div><button>increment</button><p>count: <!-- -->0</p></div></div>
    <script src="index.js"></script>
  </body>
</html>