Reactで単純なSPA・SSG・SSRのビルドをできるようにする 2022.05.23
今や、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-p
をrun-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_SERVE
がtrue
となるので、これを利用して開発環境用の設定を分岐させる。他の環境では何も返らないので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)をできるようにする
これらのサイトを参考にした。
- https://www.digitalocean.com/community/tutorials/react-server-side-rendering-ja
- https://nils-mehlhorn.de/posts/typescript-nodejs-react-ssr
以下のコマンドで実行できるようにする。 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>