30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Reactのサーバーサイドレンダリング

Reactでは、サーバーサイドレンダリングを行うことが出来る。
これは、サーバーサイドでReactのコンポーネントをhtmlとして展開し、それをクライアントに返す、というもの。
これを利用することで、初期ロードの遅さ、SEOの弱さ、などの問題を解決できる。

以下ではNode.jsを使ってサーバーサイドレンダリングを試してみる。

react、react-dom、共にバージョンは15.3.2

実験用のコンポーネントを作る

Counterというコンポーネントを作り、それをサーバーサイドレンダリングすることにする。

// Counter.js
import React from 'react';

export default class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: this.props.first
    }
  };
  increment() {
    this.setState({
      count: this.state.count + 1
    })
  };
  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={() => this.increment()}>Increment</button>
      </div>
    )
  };
}

これをブラウザで読み込んで
ReactDOM.render(<Counter first={3} />, document.getElementById('mount'));
レンダリングするとCount: 3という文字列とボタンが表示され、ボタンを押す度に表示の数字が一つずつ増えていく。

このコンポーネントを、サーバーサイドでレンダリングするのが、今回の目的。

サーバーサイドのコードを書く

// server.js
const http = require('http');
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import Counter from './Counter.js';

http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/html'});
  const elem = ReactDOMServer.renderToString(<Counter first={3} />);
  res.write(elem);
  res.end();
}).listen(8080);
console.log('Server running at http://localhost:8080/');

これで、babel-node server.jsを実行してhttp://localhost:8080/にアクセスすると、ReactDOM.render()したのと同じ内容が画面に表示される。

基本的な使い方

まず、react-dom/serverからReactDOMServerをインポート。
レンダリングしたいコンポーネントを引数として渡して、ReactDOMServer.renderToString()を実行。
その戻り値を、レスポンスとしてクライアントに返す。

基本的にはこれだけである。

クライアントサイドでコンポーネントを引き継ぐ

先程のサンプルでは、表示は問題ないが、ボタンを押しても何も反応しない。

これは恐らく、サーバーサイドでレンダリングしただけで処理を終わらせてしまい、クライアントサイドには何のプログラムも読み込まれていないためだと思われる。
だから、クライアントでいくらボタンを押しても、何も起こらない。

サーバーサイドでレンダリングするだけでなく、クライアントはクライアントでレンダリングを行い、サーバーサイドでレンダリングしておいたものを引き継ぐ必要がある。
初回のレンダリングをサーバーサイドで行い、2回目以降はクライアントサイドで行う、ということだと思う。

分かりやすい資料を見つけることが出来なかったため、どうするのがベストプラクティスなのかは分からないが、以下のように書くことでボタンが動作するようになる。

const http = require('http');
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import Counter from './Counter.js';

var fs = require('fs');

http.createServer((req, res) => {
    if(req.url === '/client-b.js'){
        fs.readFile('./client-b.js', 'utf-8', function(err, data){
            res.writeHead(200, {'Content-Type': 'application/javascript'});
            res.write(data);
            res.end();
        });
        return;
    };
  res.writeHead(200, {'Content-Type': 'text/html'});
  const elem = ReactDOMServer.renderToString(<Counter first={3} />);
  res.write(`
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <title>サーバーサイドレンダリング</title>
</head>
<body>
    <div id="app">${elem}</div>
    <script type="text/javascript" src="./client-b.js"></script>
</body>
</html>
  `);
  res.end();
}).listen(8080);
console.log('Server running at http://localhost:8080/');

./client-b.jsは、CounterReactDOM.render()するスクリプトをビルドしたもの。
サーバーサイドレンダリングをした後、同一のコンポーネントをクライアントサイドでもレンダリングしている。

これだけシンプルだと、最初からクライアントでレンダリングするのとあまり変わらないが、一応、サーバーサイドレンダリングが機能している。
わざとエラーを出すことで、それを確認できる。

例えば、クライアントに返すbodyタグは次のような構造だが、

<body>
    <div id="app">${elem}</div>
    <script type="text/javascript" src="./client-b.js"></script>
</body>

これを以下のように変える。

<body>
    <div id="app">
        ${elem}
    </div>
    <script type="text/javascript" src="./client-b.js"></script>
</body>

すると、次のような警告が出る。

Warning: render(): Target node has markup rendered by React, but there are unrelated nodes as well. This is most commonly caused by white-space inserted around server-rendered markup.

これは、サーバーサイドとクライアントサイドで、レンダリングに微妙な差異があるために発生する。

// サーバーでのレンダリング
    <div id="app">
        <Counter />
    </div>
// クライアントでのレンダリング
    <div id="app"><Counter /></div>

クライアントに返ってきたソースを見る限り、サーバーでのレンダリングがそのまま使用される模様。

次は、コンポーネントに渡すpropsを変えてみる。

ReactDOMServer.renderToString(<Counter first={1} />);
ReactDOM.render(
    <Counter first={3} />,
    document.getElementById('app')
);

この場合、次のような警告が出る。

Warning: React attempted to reuse markup in a container but the checksum was invalid.
This generally means that you are using server rendering and the markup generated on the server was not what the client was expecting.
React injected new markup to compensate which works but you have lost many of the benefits of server rendering.
Instead, figure out why the markup being generated is different on the client or server:
 (client) -- react-text: 4 -->3<!-- /react-text --
 (server) -- react-text: 4 -->1<!-- /react-text --

これは、サーバーとクライアントとで、レンダリングされたものに差異があるため発生した。
サーバーでは初期値が1だが、クライアントでは3になっている。

この場合、サーバーでレンダリングされたものは破棄され、クライアントでレンダリングしたものが適用される。つまり、画面上には3が表示される。