JavaScript
Express
reactjs
react-router
redux

React v16 + react-router v4 + ExpressでSSR+SPAする

経緯

React v16.3が出てサーバサイドレンダリング(以下SSR)はrenderToNodeStreamhydrateが追加されました。
同じくreact-routerもv4になり、シンタックス含め大きく変更されました。
今回はその2つを使用しExpressでサーバーを立てて、isomorphicらしいSSR + SPA環境を学びます。

ツール

React v15 -> v16.3.0

React v16.3.0のSSRについては以下の記事などでまとめられています。
ここでも参考までに少し書いておきます。

React v16でのサーバーサイドレンダリング - blog.koba04.com
http://blog.koba04.com/post/2017/10/01/serverside-rendering-in-react-v16/

v15

v15まではrenderToStaticrenderを使い、サーバーサイドではdangerouslySetInnerHTMLを使ってStringとしてクライアントサイドに返す必要がありました。
HTML部分もrenderStaticMarkUpでアプリケーション部分はrenderToStringでとちょっと複雑な部分もあったのです。

Html.js
import React from 'react';

const Html = (props) => {
    return (
        <html>
            <head>
                <title>App</title>
            </head>
            <body>
                <div id="app" dangerouslySetInnerHTML={ {__html: props.markup} }></div>
                <script src="/static/bundle.js"></script>
            </body>
        </html>
    );
};

export default Html;
server.js
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import express from 'express';

import Html from './Html';
import App from './App';

const app = express();

app.get('/', (req, res) => {
    res.send(
        ReactDOMServer.renderToStaticMarkup(
            <Html
                markup={ReactDOMServer.renderToString(<App />)}
            />
        )
    );
});

app.listen(3000);

v16.3.0

v16以降ではNode Streamに対応したrenderToNodeStreamやクライアントサイドで実行されるhydrateというAPIが登場し、サーバーサイドとクライアントサイドでエントリーポイントを合わせる必要がなくなりました。
そのためv15のときのような、ComponentをStringに変換する必要もありません。

Html.js
import React from 'react';

const Html = (props) => {
    return (
        <html>
            <head>
                <title>App</title>
            </head>
            <body>
                <div id="app">{props.children}</div>
                <script src="/static/bundle.js"></script>
            </body>
        </html>
    );
};

export default Html;
server.js
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import express from 'express';

import Html from './Html';
import App from './App';

const app = express();

app.get('/', (req, res) => {
    ReactDOMServer.renderToNodeStream(
        <Html>
            <App />
        </Html>
    ).pipe(res);
});

app.listen(3000);

こうして見ると大分コードがシンプルになっていることがわかります。
今回はこちらを使用します。

react-router v3 -> v4

react-routerもv4ではv2及びv3から破壊的な変更が行われました。
内容については以下の記事などでまとめられています。

サンプルでreact-router v4を理解してみよう。 - Qiita
https://qiita.com/park-jh/items/b4c7b16ea9eb0cf44942#onenter%E3%81%AE%E4%BB%A3%E3%82%8F%E3%82%8A%E3%81%ABcomponentwillmount%E5%8F%88%E3%81%AFcomponentwillreceiveprops%E3%82%92%E4%BD%BF%E3%81%86

ブラウザ側ではBrowserRouterを使用し、サーバーではStaticRouterを使うようにします。
あとでサンプルコードを書きますので追って説明します。

今回はreact-router-configを使用するためそこまで影響ないのですが、
以下のところは気をつけました。

reactjs/react-router-reduxを使っていたかもしれない。
しかし、このライブラリはもうメンテナンスされない。
react-router-reduxのメンテナンスはreact-training organizationに移管された。
これからはreact router v4を使う。

なので今回はreact-router-reduxはなしでいきます!

react-router-config

上述したreact-routerのヘルパーを担うプラグインです。
現在アルファ版になっているのでどうなるかはちょっと未定ではありますが、便利なので使う感じです。

https://github.com/ReactTraining/react-router/tree/master/packages/react-router-config

たとえばこんな感じでルーティング用の配列を用意して、

routes.js
import Root from './Root';
import Home from './Home';
import About from './About';

const routes = [
  { component: Root,
    routes: [
      { 
        path: '/',
        exact: true,
        component: Home
      },
      { 
        path: '/about',
        exact: true,
        component: About
      }
    ]
  }
];

export default routes;

後はrenderRoutesで使うだけです。

index.js
import React from 'react';
import ReactDOM from 'react-dom';

import { BrowserRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';

import Routes from './routes';

ReactDOM.hydrate(
    <BrowserRouter>
        {renderRoutes(Routes)}
    </BrowserRouter>,
    document.getElementById('app')
);

コードがシンプルになるためこちらを採用します。
ちなみにindex.jsで使用しているhydrateはSSRされたコンポーネントを自動的に使いまわすことでリソース削減を自動で行ってくれる便利な関数です。SSRするならrenderではなくhydrateを使用してよいでしょう。

環境

本題に入ります。
筆者の環境は以下になります。

Name Version
Node.js 8.9.0
React 16.3.0
react-router-config 1.0.0-beta.4
react-router-dom 4.2.2
Redux 4.0.0
Express 4.16.3

ディレクトリ

ディレクトリ構成は以下のようになります。
Actionsはとりあえずデモのため空にしています。

public/
    - client.js
    - server.js
src/
    - Actions
    - Container
        - About.js
        - App.js
        - Html.js 
    - Routes
        - routes.js
        - serverRoutes.js 
    - Store
        - Reducers 
    - client.js
    - server.js
.babelrc
package.json
webpack.config.js

パッケージのインストール

必要なパッケージをインストールしていきます。

$ yarn init
$ yarn add express react react-dom react-redux react-router-config react-router-dom redux -S
$ yarn add babel-core babel-loader babel-preset-env babel-preset-react webpack  webpack-cli -D

設定ファイル

設定ファイルの中身を先に記載します。
どちらもデモなのでシンプルにしています。

.babelrc
{
  "presets": [
    "env", "react"
  ]
}
webpack.config.js
const path = require('path');

const env = process.env.NODE_ENV;

const config = {
    mode: env || 'development',
    target: 'node',
    entry: {
        client: [path.resolve(__dirname, 'src/client.js')],
        server: [path.resolve(__dirname, 'src/server.js')],
    },
    output: {
        path: path.resolve(__dirname, './public/'),
        publicPath: './public/',
        filename: '[name].js',
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                loader: 'babel-loader',
            },
        ],
    },
    resolve: {
        extensions: ['.js', '.jsx'],
    }
};

module.exports = config;
package.json
    "scripts": {
        "server": "node public/server.js",
        "build": "webpack -d --progress --colors --display-error-details"
    }

各ページ

client.js

Reduxを使うためProvider経由でstoreを渡しています。
ブラウザ側で読み込むためreact-router-domからはBrowserRouterを使用します。

client.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from './Store/store';

import { BrowserRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';

import Routes from './Routes/routes';

const store = configureStore();

ReactDOM.hydrate(
    <Provider store={store}>
        <BrowserRouter>{renderRoutes(Routes)}</BrowserRouter>
    </Provider>,
    document.getElementById('app')
);

Store

storeはおなじみな感じです。
ミドルウェアはサンプルでLoggerを入れてますが、追加する場合はmiddlewaresにpushすればOKです。

store.js

Store/store.js
import { createStore, applyMiddleware } from 'redux';
import Logger from 'redux-logger';

import reducer from './reducer';

const PRODUCTION = process.env.NODE_ENV === 'production';

const configureStore = () => {
    const middlewares = [];

    if (!PRODUCTION) {
        middlewares.push(Logger);
    }

    const store = createStore(reducer, applyMiddleware(...middlewares));

    return store;
};

export default configureStore;

Reducer.js

Store/Reducer.js
import { combineReducers } from 'redux';

const Reducer = combineReducers({
    // Reducerを追加すればOK
});

export default Reducer;

Routes

ルーティングは以下のようにしました。

routes.js

Routes/routes.js
import Html from '../Container/Html';
import App from '../Container/App';
import About from '../Container/About';

const Routes = [
    {
        component: Html,
        routes: [
            {
                path: '/',
                exact: true,
                component: App,
            },
            {
                path: '/about',
                exact: true,
                component: About,
            },
        ],
    },
];

export default Routes;

ServerRoutes.js

サーバー用のルートはexpress.Routerを使ってルータ用のインスタンスを作成します。
ここでrendetToNodeStreamを使って、react-routerのStaticRouterを通したコンポーネントをNode Streamとして渡せるようにします。

Routes/ServerRoutes.js
import express from 'express';

import React from 'react';
import ReactDOMServer from 'react-dom/server';

import { StaticRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';

import routes from './routes';

const router = express.Router();

router.get('*', (req, res) => {
    let context = {};

    ReactDOMServer.renderToNodeStream(
        <StaticRouter location={req.url} context={context}>
            {renderRoutes(routes)}
        </StaticRouter>
    ).pipe(res);
});

module.exports = router;

server.js

上のように書くとサーバーの処理は以下だけになりました。

server.js
import express from 'express';

const app = express();
const router = require('./Routes/serverRoutes');

app.use(express.static('public'));

app.use('/', router);
app.use('/about', router);

app.listen(3000);

Components

各Componentは以下のようになります
ポイントはRoot ComponentであるHtml.jsrenderRoutesを使い親から渡されるコンポーネントを描画していることです。

Html.js

Html.js
import React from 'react';
import { renderRoutes } from 'react-router-config';

const Html = props => {
    return (
        <html>
            <head>
                <title>App</title>
            </head>
            <body>
                <div id="app">{renderRoutes(props.route.routes)}</div>
                <script src="/client.js" />
            </body>
        </html>
    );
};

export default Html;

App.js

App.js
import React from 'react';
import { Link } from 'react-router-dom';

const App = props => {
    return (
        <div>
            Name is {props.name || 'hoge'} <br />
            Path is {props.match.path} <br />
            <Link to={'/about'}>about</Link>
        </div>
    );
};

export default App;

About.js

About.js
import React from 'react';
import { Link } from 'react-router-dom';

const About = props => {
    return (
        <div>
            This Page is About Page!!<br />
            <Link to={'/'}>top</Link>
        </div>
    );
};

export default About;

動かしてみる

ビルドして実行します。

$ yarn build
$ yarn server

以下のように正しく動いていれば成功です!!

5月-01-2018 17-42-41.gif

おわりに

jadeやejsなどを使わなければもうこれでSSR + SPAができてしまいます。
Next.jsなどを使うことでこのあたりも省略できてしまいますがフルスクラッチするなら試してもいいと思います。
少しでも参考になれば幸いです!