オフトゥン大好き

惰眠系プログラマの作業ログで( ˘ω˘ ) スヤァ…

Reactシングルページアプリケーションをサーバサイドレンダリングする

どうも、 @nukosuke です。

こちらはQiita Advent Calendar '17 React #1、3日目の記事です。

概要

この記事ではReactで構築したシングルページアプリケーション(SPA)をサーバサイドレンダリング(SSR)する過程をまとめました。

つかったもの

成果物

まずは出来上がったものから。こちらのリポジトリをcloneして以下のように起動できます。

$ npm run build:server # サーバjsをビルドします
$ npm run build:client # クライアントjsをビルドします
$ npm start            # localhost:3000で起動します

手順

ざっくりとした手順ですが、今回やったことを要約すると以下のようになります。

  1. KoaでReactコンポーネントをサーバサイドレンダリングする
  2. ReactRouterを使ってルーティング(URLに対して描画されるべきコンポーネントの定義)を行う
  3. Webpackでブラウザ側のJavaScriptをバンドルする
  4. バンドルしたJavaScriptをbodyの最後に差し込む

それでは順番に設定を見ていきます。

KoaでReactコンポーネントをサーバサイドレンダリングする

ディレクトリ構成は自由にできますが、僕はいつも以下のような構成にしています。参考程度に。

react-ssr-171203/
|- src/
    |- client.tsx # ブラウザJavaScriptのエントリポイント
    |- server.tsx # Nodeのエントリポイント
    |- components/ # Reactコンポーネントを入れておく場所
        |- Application.tsx # ReactRouterのルーティングを行うコンポーネント
|- build/ # トランスパイル済みのサーバサイドJavaScriptが入る場所。import(require)の都合上階層はsrc/の構造をそのまま移すようにしておく
|- public/javascripts/ # トランスパイル&webpack済みのブラウザJavaScriptが入る場所
|- webpack.config.js

まずはKoaを使ってReactコンポーネントレンダリングするHTTPサーバを立てます。KoaはExpressの後継として開発されているフレームワークです。

SSR用のモジュール react-dom/server から renderToString() APIを使って <Application/> コンポーネントをhtml文字列に変換し、それをbodyの中に埋め込んでいます。 <Application/> はこの時点では特に何も内容のないコンポーネントです。

import * as React from "react";

export default class Application extend React.Component<any, any> {
  render() {
    return (<div>SSRてすと</div>);
  }
}

ReactRouterを使ってルーティングを行う

次に、ReactRouterを使ってApplicationコンポーネントにルーティングを定義していきます。

デモということでちょっとサボりましたが、本来であれば <Route>component 属性に別のファイルからimportしたコンポーネントを渡します。

こうしてルーティングを定義したコンポーネント<StaticRouter> でラップします。Koaのコンテキスト(リクエスト情報など)からリクエストURLを渡すことで renderToString() で描画されるべき子コンポーネントを割り出し、よしなにレンダリングしてくれるようになるのです。 実際にReactRouterでラップしてSSRするサーバコードが次のようになります。

Webpackでブラウザ側のJavaScriptをバンドルする

さて、ここまでがサーバサイドでReactをレンダリングする手順でした。しかし、このままではただmuxとレンダリングエンジンがReactRouterとReactに置き換わっただけですのでここからはブラウザ側のJavaScriptを作っていきます。

まず、Webpackの設定を書きます。

"use strict";
const path = require("path");

module.exports = {
    entry: "./src/client.tsx",
    output: {
        filename: "application.js",
        path: path.join(__dirname, "public/javascripts")
    },
    resolve: {
        extensions: [".ts", ".tsx", ".js", ".jsx"]
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: {
                    loader: "ts-loader"
                }
            }
        ]
    }
};

client.tsx をエントリポイントとして、拡張子が ts or tsx のファイルをJavaScriptにトランスパイルし、 /public/javascripts 以下に application.js という名前で配置する設定です。

そして、 client.tsx を書いていきます。<Application/> はすでにさっきの手順で書いたので、今度はこれをブラウザ用の <BrowserRouter> コンポーネントでラップします。 ラップしたものを hydrate() APIに渡せば完了です。この際、SSRしたhtml文字列を囲んでいる <div> をhydrateに渡す必要があるので、idを設定しておいて document.getElementById() で取得します。

<!-- server.tsx -->
<div id="react-root">${markup}</div>

// client.tsx
document.getElementById("react-root")

ブラウザ側のコードは最終的にこんな風になります。

import * as React    from "react";
import * as ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import Application from "./components/Application";

ReactDOM.hydrate(
  <BrowserRouter>
    <Application/>
  </BrowserRouter>,
  document.getElementById("react-root") // コンポーネントを吊るす対象
);

バンドルしたJavaScriptをbodyの最後に差し込む

最後に、SSR後にwebpackしたJavaScriptを読み込むようにします。これで一番最初のSSR以降、レンダリングは全てブラウザ側に丸投げできるようになります。

koa-static というKoaのプラグインを使用してWebpackでバンドルしたJavaScriptをブラウザに配ります。Webpackの出力先である /public を静的ファイル置き場としてマウントし、<body> の最後に <script> を差し込みました。

最終的にサーバ側は次のようなコードになります。

動作確認

npm start でサーバを起動してブラウザから localhost:3000 にアクセスしてみます。

ブラウザ側ではサーバで構築されたDOMと hydrate() APIによってブラウザレンダリングされるDOMとの差分をチェックし、差分がある場合のみコンポーネントの再描画を行います。通常、SSRされたDOMはブラウザJavaScriptの読み込みが終わった時点では差分がないため再描画は発生しません。

一番最初に表示されるページはサーバサイドでレンダリングされたもので、リンクにより遷移したページはReactRouterによってサーバへのアクセスなしで描画されたものです。

今回やりたかったけどできなかったこと

  • Reduxの導入 (redux, react-redux, react-router-reduxとかめちゃくちゃパッケージが増えて正直しんどい)
  • テスト周り (Jestというテストライブラリが流行っている(?)らしいので時間があれば試してみたい)