React Server Componentsが始まるらしい今だからReactの生APIのみでSSRをおさらいする。

7 min read読了の目安(約7000字

はじめに

React Server Componentsなるものが検討されているらしい。

Reactを用いた静的サイトの作成やサーバーサイドレンダリングは、Next.jsやGatsbyjsなどでとにかく簡単になりました。

https://nextjs.org
https://gatsbyjs.org

しかしながらそれらを実現するまでに先人たちの努力や紆余曲折がありました。
そして2021年、React公式で「React Server Components」なるものが検討されています。

https://github.com/reactjs/server-components-demo

このように、公式のみならず様々な企業やコミッターが「React」という巨大なプロジェクト群とNode.jsサーバーの採用に対しての積極性について推進しています。
本当に感謝しかありません。

さて今回は、そういったNode.jsサーバーを本格的に導入してReactをサーバーサイドレンダリングで対応するという往年の課題について、便利かつ豊富なライブラリを無視して初心に返り、ReactとReactDOMの元々持つAPIのみで解決する方法をおさらいしていこうと思っています。

今回の記事の内容はReactのAPIを用いた検証とその仕組みを理解するための記事であり、本番環境での使用は想定しておりません。

構成

ディレクトリ

|-dist # ビルド排出先ディレクトリ
|-server
    |-main.tsx # サーバーサイドのエントリーポイント
|-src
    |-index.tsx # Reactコンポーネントファイル
    |-server.tsx # サーバー側でのReactコンポーネントのレンダリング関数

ツール

  • バンドルツール: webpack
  • サーバーフレームワーク: express
  • 言語: Typescript

package.json

{
    "name": "react",
    "version": "0.1.0",
    "private": true,
    "dependencies": {
        "express": "^4.17.1",
        "react": "^17.0.1",
        "react-dom": "^17.0.1"
    },
    "devDependencies": {
        "@babel/core": "^7.13.10",
        "@babel/preset-env": "^7.13.10",
        "@babel/preset-react": "^7.12.13",
        "@types/express": "^4.17.11",
        "@types/jest": "^26.0.15",
        "@types/node": "^12.0.0",
        "@types/react": "^17.0.0",
        "@types/react-dom": "^17.0.0",
        "babel-loader": "8.1.0",
        "cross-env": "^7.0.3",
	"nodemon-webpack-plugin": "^4.4.4",
	"ts-loader": "^8.0.18",
        "tsconfig-paths-webpack-plugin": "^3.3.0",
        "typescript": "^4.1.2",
	"webpack": "4.44.2",
	"webpack-cli": "^4.5.0",
        "webpack-node-externals": "^2.5.2"
	
    }
    "scripts": {
        "dev": "cross-env NODE_ENV=development npx webpack --watch",
        "build": "cross-env NODE_ENV=production npx webpack"
    }
}

tsconfig.json

{
    "compilerOptions": {
        "downlevelIteration": true,
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,
        "esModuleInterop": true,
        "lib": ["DOM"],
        "module": "ESNext",
        "moduleResolution": "node",
        "baseUrl": ".",
        "rootDir": ".",
        "strict": true,
        "strictPropertyInitialization": false,
        "target": "ES6",
        "isolatedModules": true,
        "noEmit": false,
        "allowJs": true,
        "alwaysStrict": true,
        "forceConsistentCasingInFileNames": true,
        "jsx": "react-jsx",
        "noFallthroughCasesInSwitch": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "resolveJsonModule": true,
        "skipLibCheck": true,
        "typeRoots": [
            "node_modules/@types",
            "types"
        ]
    },
    "exclude": [
        "node_modules"
    ],
    "include": [
        "**/*.ts",
        "**/*.tsx"
    ]
}

webpack.config.js

const path = require('path');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const nodeExternals = require('webpack-node-externals');
const NodemonPlugin = require('nodemon-webpack-plugin');

module.exports = {
    entry: './server/main.tsx',
    target: 'node',
    node: {
        __dirname: false
    },
    output: {
        filename: 'main.js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /\.jsx?$/,
                exclude: /core-js/,
                use: [
                    {
                        loader: 'babel-loader',
                        options: {
                            presets: [
                                '@babel/preset-env',
                                [
                                    '@babel/preset-react',
                                    {
                                        runtime: 'automatic'
                                    }
                                ]
                            ]
                        }
                    }
                ]
            },
            { test: /\.tsx?$/, loader: 'ts-loader' }
        ]
    },
    plugins: [new NodemonPlugin()],
    resolve: {
        extensions: ['.ts', '.tsx', '.js', '.jsx'],
        plugins: [new TsconfigPathsPlugin()]
    },
    externals: [nodeExternals()]
};

書いていく

src/index.tsx

Reactコンポーネントを定義します。

import { FC } from 'react';

const App: FC = () => (
    <div className={'app'}>
        <h1>Hello! React SSR!</h1>
    </div>
);

export default App;

src/about.tsx

別のページです。

import { FC } from 'react';

const About: FC = () => (
    <div className={'app'}>
        <h1>This page about React SSR.</h1>
    </div>
);

export default About;

src/server.tsx

サーバー側からテンプレートとコンポーネントをHTML形式にしてレンダリングする関数を定義します。

import type { ReactElement } from 'react';
import { renderToString } from 'react-dom/server';

export type TemplateRenderProps = {
    title: string;
    description: string;
};

type T = (props: TemplateRenderProps & {
    content: string;
} => string;

/** renderTemplate
 * 引数propsの`content`の中にReactコンポーネントを文字列化したものを渡して、
 * HTMLを書いたテンプレートリテラル(string型)を返す。
 */
const renderTemplate: T = (props) => `
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>${props.title}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="description" content="${props.description}" />
    </head>
    <body>
        <div id="root">${props.content}</div>
    </body>
</html>
`;

/** render
 * 第一引数にタイトルなどのSSRしたいデータ、
 * 第二引数にReactコンポーネントを取り、`ReactDOMServer`の関数、`renderToString`で
 * 文字列に変換する。
 */
export const render = (props: TemplateRenderProps, component: ReactElement) =>
    renderTemplate({
        ...props,
	content: renderToString(component)
    });

server/main.tsx

サーバー側のコードを書いて終わり!

import express from 'express';
import App from '../src/index';
import About from '../src/about';
import { render } from '../src/server';

const server = express();
const port = process.env.PORT || 3000;

server.get('/', (_req, res) => {
    const page = render(
        {
	    title: 'React ssr sample',
	    description: 'React サーバーサイトレンダリングのサンプルです'
	},
	<App />
    );
    res.status(200).send(page);
});
server.get('/about', (_req, res) => {
    const page = render(
        {
	    title: 'About | React ssr sample',
	    description: 'React サーバーサイトレンダリングのサンプルです'
	},
	<About />
    );
    res.status(200).send(page);
});


server.listen(port, () => {
    console.log(`listening on port ${port});
});

起動

$ yarn dev # webpack watchモード
$ yarn build # production ビルド

最後に

以上、基本的なサーバーサイドレンダリングの素振りでした。
実用するならば、今回作成したrender関数をジェネリクスで引数を渡せるようにしてサーバーで取得したデータをpropsとして渡したり、クライアント側で動くReactコンポーネントも用意してそれらを読み込むscriptタグを追加して・・と設定すること盛沢山です。

そしてそれらをすべてハイパフォーマンスにチューニングしているのがNext.jsやGatsbyjsです。

今はそういった便利なライブラリが増えましたが、それでもReactが大好きなら一度はサーバー側のトップレベルAPI(react-dom/server)を弄ってみると楽しいのでおススメです。

お わ り