ReactServerによるReactのサーバーサイドレンダリング


ReactでServerSideRenderingをやるには、renderToStringを使うだけなので非常に簡単ですが、パフォーマンス面に問題がでたりします。最適なパフォーマンスで行おうとするといろいろと手間がかかりますが、そんな場面に便利なのがプレゼンテーションサーバー用フレームワークのreact-server。開発者が意識せずにサーバーサイドレンダリングを高いパフォーマンスで手軽に実現することができます。
そんなreact-serverの紹介記事です。

react-serverがやってくれること

react-serverではページを描画する際に、スクロールせずに表示される領域(adove-the-fold)を最速で表示するように作られています。

  • バックエンドリクエストの並列化
    データ取得のためにバックエンドへ送るリクエストが複数回ある場合に、リクエストを非同期的に並列で処理します。

  • ブラウザーへのバンドルデータ送信
    通常はサーバーサイドレンダリングをした場合、サーバーがHTMLを送信しブラウザがHTMLを描画後に、コンポーネントをアクティブにするためにクライアント側はJavascriptを読み込み、ReactコンポーネントからShadowDOMの構築および再描画を行います。react-serverでは、サバーサイドでレンダリングした際に取得したデータなどを全て含めてHTMLとあわせてブラウザに送信することで、クライアント側でのShadowDOMの構築コストを最小限に収め、描画されているDOMと同じものが構築されることで再描画も起きません。

  • プリレンダーHTMLのストリーミング送信
    react-serverではRootElementというコンポーネントを提供し、ページ全体のHTMLが構築されるのを待たずに、RootElementでラップされたコンポーネントごとに描画の準備ができたものから描画を開始します。

  • クライアント初期化のストリーミング送信
    react-serverでは、ページのadove-the-foldの部分までのHTMLがブラウザに送信されたら、Reactコンポーネントを初期化するためのscriptタグを送信することで、クライアント側は後半部分のHTMLの送信を待たずにコンポーネントをインタラクティブにすることができる。初期化後に送信されたHTMLはすぐにインタラクティブになります。

  • バックエンドが遅くても描画を早くする
    データを取得する先の複数のバックエンドサーバーのうちの一つが障害などで応答が遅くなっている場合でも、他の部分の描画を遅らせること処理することができます。バックエンドからのデータ送信が完了するまでサーバーからクライアントへのHTTP通信は開いたままになり、エンドポイントからの送信が完了したらscriptタグをブラウザへプッシュして再描画を行います。
    こうすることで、バックエンドへの通信をタイムアウトして再リクエストする必要もなくなります。

  • シングルページアプリケーションでの初回読み込みを早くする
    シングルページアプリケーション(SPA)の場合、初回の読み込みですべてのリソースを読み込むので応答が遅くなりがちになります。react-serverではページごとに必要なCSSとJavascriptを非同期読み込みするので、全体のコード量が増えても初回読み込みが遅くなることはありません。

インストール

公式ではyeomanを使って雛形作成が提供されています。

# install yeoman
npm install -g yo

# install the react-server generator
npm install -g generator-react-server

# make a new react-server project in the CURRENT directory
yo react-server

# run the new app
npm run start

# go to http://localhost:3000

実行した後のディレクトリ構成はとてもシンプルです。
基本的にはページとコンポーンネントのディレクトリ、ルーティングのファイルです。

-.babelrc
-.eslintrc
-.nsprc
-.reactserverrc
-components
| -- hello-word.js
-pages
| -- hellow-world.js
-package.json
-routes.js
-test.js

.reactserverrcにはサーバーの起動オプションを書きます。(参照)
HotReloadがデフォルトでfalseになっているので、trueに変えておくといです。
また、react-serverではwebpackを使用しています。webpackのコンフィグを設定したい場合には、設定用のファイルを作り.reactserverrcにwebpack-config : "ファイルパス"を設定します。(参照)

また、react-server-cliを使って始める方法も提供されています。

$ npm install -g react-server-cli
$ react-server init
$ react-server add-page '/' Homepage
$ react-server start

initを実行すると、yeomanを使った時よりもさらにシンプルに、package.jsonとroutes.jsonのみが生成されます。add-pageを行うことで、pagesディレクトリとページファイルの生成、routs.jsonへのルートの追加が行われます。

ルーティング

ルーティングはroutes.jsonにURL、HTTPメソッド、ページファイルのパスをセットで書きます。

const path = require('path');

module.exports = {
    routes: {
        HelloWorld: {
            path: ['/'],
            method: 'get',
            page: path.join('.', 'pages', 'hello-world'),
        },
        Show: {
            path: ['/show/:id'],
            method: 'get',
            page: path.join('.', 'pages', 'show'),
        }
    },
};

ページ遷移はブラウザのURLもちゃんと変更してくれるのでSEO的にも安心です。

ページ

cliからadd-pageを実行すると以下のようなファイルが生成されます。ページは最低限、getElementsのメソッドを持っている必要があります。

page.js
export default class Homepage {
        handleRoute(next) {
                // Kick off data requests here.
                return next();
        }

        getElements() {
                return <div>This is Homepage.</div>
        }
}

以下は公式のドキュメントに乗っているページの例です。

import HttpStatus from 'http-status-codes';
import MobileEnabled from ('./middleware/MobileEnabled');
const ExampleComponent = require("./components/example-component");
const ExampleStore = require("./stores/example-store");
const exampleAction = require("./actions/example-action");

class ExamplePage {
    // See [writing middleware](/docs/writing-middleware) for how to write middleware
    static middleware() { return [MobileEnabled]; }

    handleRoute(next) {
        var params = this.getRequest().getQuery();
        this._exampleStore = new ExampleStore({
            id: +params.id
        });
        return next();
    }

    getTitle() {
        return "Example page"
    }

    getHeadStylesheets() {
        return [
            "/styles/example.css",
            "/styles/reset.css"
        ]
    }

    getMetaTags() {
        var tags = [
            { name: "example", content: "Demonstrate a full react-server page" },
        ];
        return tags;
    }

    getLinkTags() {
        return [
            // prefetch analytics to improve performance
            { rel: "prefetch", href: "//www.google-analytics.com" },
        ];
    }

    getBodyClasses() {
        return ["responsive-page", "typography"];
    }

    getElements() {
        return (
            <RootElement when={this._store.whenResolved()}>
                <h1>Example Page</h1>
                <ExampleComponent handleOnClick={exampleAction} {...this._exampleStore} />
            </RootElement>
        );
    }
}
  • handleRoute - データの取得処理を書きます。
  • getTitle - ページタイトルを書きます。
  • getHeadStylesheets -headタグ内で読み込むCSSファイルを書きます。
  • getMetaTags - head内のmetaタグを書きます。
  • getLinkTags - Linkタグ(後で後述)のデータの事前読み込みをする。使い方がいまいち不明。。
  • getBodyClasses - bodyタグのクラスを書きます。
  • getElements - bodyタグ内のコンポーネントを書きます。 その他のメソッドはこちらを参照。

ページライフサイクル

ページの描画は以下の順序で行われます。

1. 以下のメソッドを同時に呼び出します。

  • renderDebugComments
  • renderTitle
  • renderScripts
  • renderStylesheets

2. renderStylesheetsが完了すると、次に以下のメソッドを同時に呼び出します。
- renderMetaTags
- renderLinkTags
- renderBaseTag

3. 2のメソッドがすべて完了したら、以下のメソッドを順に呼び出します。

  1. getBodyClasses
  2. getBodyStartContent
  3. getElements

RootComponent & RootElement

上記にもある通り、react-serverではRootComponentとRootElementというコンポーネントを提供し、このコンポーネントごとに非同期で描画を行うことができます。RootComponentとRootElementはwhenとlistenのプロパティを持ち、promiseのオブジェクトを設定します。

getElements() {
    return <RootContainer>
        <RootElement when={headerPromise}>
            <Header />
        </RootElement>
        <RootContainer listen={bodyEmitter}>
            <MainContent />
            <RootElement when={sidebarPromise}>
                <Sidebar  />
            </RootElement>
        </RootContainer>
        <TheFold />
        <Footer />
    </RootContainer>
}

上記は、公式ドキュメント(参照)にある例です。

  • Headerコンポーネントは、headerPromiseがresolveした時に、promiseの結果をpropsに受け取りレンダリングを行います。
  • MainContentコンポーネントは、bodyEmitterが発火した時に、その結果をpropsに受け取りレンダリングを行います。また、クライアントサイドではbodyEmitterが実行されるたびにその結果をもとに再描画が行われます。
  • Sidebarコンポーネントは、bodyEmitterが発火し、sidebarPromiseがresolveした時に、bodyEmitterとsidebarPromiseの結果の両方をpropsに受け取りレンダリングを行います。
  • TheFoldは、この時点までがadove-the-fold(スクロールせずに表示できる範囲)であることを示します。scriptタグが送られ、クライアント側での描画が開始されます。
  • Footerコンポーネントは、即座にレンダリングを行い、それよりも前のエレメントがすべてブラウザに送信されたら送信されます。

サーバーサイドレンダリングを使わないページ間遷移

一度サーバーサイドレンダリングしたページを表示した後は、ページ間の遷移はサーバーを介さずにクライアント側のみでのページの書き換えだけで行うこともできます。
Linkコンポーネントを使うことで、aタグが生成されページ遷移するクリックハンドラがつきます。通常のページ遷移とは違い、Javascriptでのページ書き換えを行うので軽い動作でページ遷移することができます。もちろんブラウザのURLも変わりヒストリーにも入るのでブラウザの戻るボタンも有効です。

const {Link} = require("react-server");

const MyPageLink = () => <Link path="/my-page">My page!</Link>

また、オプションを付けることでさらに最適化することができます。
bundleData

<Link path={path} bundleData={true}>...</Link>

データの取得をクライアント側でXHRを使用せずに、サーバーにbundleDateを要求してデータを取得します。サーバー側はサーバーサイドレンダリングをするときの仕組みを使うので、並列化してデータ取得を行うため高いパフォーマンスでデータ取得が行えます。

reuseDom

<Link path={path} reuseDom={true}>...</Link>`

シングルページアプリケーションでは特に有効で、サイドバーなどの共通するDOMを再利用して表示します。Reactのstateも引き継がれます。

frameback

<Link path={path} frameback={true}>...</Link>

一覧ページと詳細ページを行き来するような構成で、一覧ページを表示するのにとても負荷が高い場合に使うものです。詳細ページをiframeで作り画面全体に表示し、一覧に戻るときはiframeを隠すことで一覧ページの描画を省くことができあmす。

reuseFrame

<Link path={path} frameback={true} reuseFrame={true}>...</Link>

framebackを使っている場合にのみ有効です。framebackを使って詳細ページを開くときは毎回新しいiframeを作りますが、reuseFrameを指定した場合は同じiframe内でページ切り替えを行います。その場合、bundleDataとreuseDomを併用することが可能です。

さいごに

実際にまだreact-serverを使ってプロダクトを作ったわけではないので、内容に間違い・不備などあればご指摘願いします。