LoginSignup
4
3

More than 5 years have passed since last update.

renderToStringを2回実行してサーバーサイドレンダリングする

Last updated at Posted at 2016-03-16

環境

なぜ?

renderToStringには、非同期処理を待つ機構が用意されていません事前に非同期処理を済ませなければ、空のstateでhtmlを生成します。

たとえば、redux-promiseを用いた以下のようなコードはうまく動作しません。

記事中はrenderToStringではなくrenderToStaticMarkupを用います

index.js
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createStore, applyMiddleware } from 'redux';
import promiseMiddleware from 'redux-promise';
import { Provider } from 'react-redux';
import Foo from './Foo';

const store = createStore(
  (state = {}, action) => {
    if (action.type === 'alreadyInitialized') {
      return Object.assign({}, state, { alreadyInitialized: true });
    }
    if (action.type === 'update') {
      return Object.assign({}, state, action.payload);
    }
    return state;
  },
  applyMiddleware(
    promiseMiddleware,
  ),
);

const provider = (
  <Provider store={store}>
    <Foo />
  </Provider>
);

console.log('state:', store.getState())
console.log(renderToStaticMarkup(provider));
Foo.js
import React from 'react';
import { connect } from 'react-redux';

export default connect(
  state => state,
)(
  class extends React.Component {
    componentWillMount() {
      if (this.props.alreadyInitialized) {
        return;
      }
      // any asynchronous processing...
      console.log('mount props:', this.props);
      this.props.dispatch(new Promise((resolve) => {
        setTimeout(() => {
          resolve({
            type: 'update',
            payload: {
              foo: 'complete',
            },
          });
        }, Math.random() * 500);
      }));
    }
    render() {
      return (
        <div>
          {this.props.foo || 'loading...'}
          {this.props.children}
        </div>
      );
    }
  }
);
babel-node index.js
# state: {}
# mount props: { dispatch: [Function] }
# <div>loading</div>

koba04さんのReact.jsのComponent Lifecycleで解説されているように、nodejs上でhtmlを生成するさいもcomponentWillMountが呼び出されていることは確認できましたが、storeのデータ更新はrenderToStaticMarkupより後に完了しています。

事前に非同期処理を済ませなければ”、と書きましたが、方法が2つあります。

  • コンポーネントの初期化時に非同期処理が必要なら、統一したstaticメソッド名(async-propsloadPropsなど)に非同期処理を定義する。render実行前にこのメソッドを実行し、完了を待つ。
  • renderToStringを実行すると、renderした全てのコンポーネントはcomponentWillMountを実行するので、componentWillMountに非同期処理を定義し、renderToStringで発行した非同期処理を監視し、完了を待つ。1

今回紹介するのは2つ目の方法です。

非同期処理を監視するreduxミドルウェアの作成

ということで、renderToStringしたときにdispatchされたPromiseの監視を行うミドルウェアを作成します。

createPromiseWatchMiddleware.js
export default () => {
  const promises = [];// dispatch された全てのプロミス
  const middleware = () => (next) => (action) => {
    if (action && action.then) {
      promises.push(action);
    }

    return next(action);// actionをそのまま次のmiddlewareへ渡す
  };
  // 監視対象が全てfulfill時にfulfill
  middleware.wait = () => Promise.all(promises);

  return middleware;
};

reduxミドルウェアの作成や振る舞いについてはyasuhiro-okada-aktskさんのRedux 基礎:Middleware 編reduxの公式を参考にしました。

これをstoreに追加し、middlewareの提供するwaitメソッドを使って、Promiseの完了を待ってから再描写します。

index.js
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createStore, applyMiddleware } from 'redux';
import promiseMiddleware from 'redux-promise';
import createPromiseWatchMiddleware from './createPromiseWatchMiddleware';
import { Provider } from 'react-redux';
import Foo from './Foo';

const promiseWatchMiddleware = createPromiseWatchMiddleware();
const store = createStore(
  (state = {}, action) => {
    if (action.type === 'update') {
      return Object.assign({}, state, action.payload);
    }
    return state;
  },
  applyMiddleware(
    promiseWatchMiddleware,
    promiseMiddleware,
  ),
);

const provider = (
  <Provider store={store}>
    <Foo />
  </Provider>
);

renderToStaticMarkup(provider);

promiseWatchMiddleware.wait().then(() => {
  store.dispatch({ type: 'alreadyInitialized' });

  console.log('state:', store.getState());
  console.log(renderToStaticMarkup(provider));
});
babel-node index.js
# state: { foo: 'complete' }
# <div>complete</div>

2回目のrenderToStaticMarkupで、<div>complete</div>と描写することが出来ました。

createPromiseWatchMiddlewareと同様の機能を提供するミドルウェアをredux-hermitという名前で公開しています。

express + react-router

ヘッドレスな(GUIの存在しない)クライアントから/にGETリクエストがあった場合に、上記の処理を実行するサーバーを実装してみます。

storeは、リクエストの度に使い捨てることになるので、ファクトリ関数として定義し直します。

configureStore.js
import { createStore, applyMiddleware } from 'redux';
import promiseMiddleware from 'redux-promise';
import createPromiseWatchMiddleware from './createPromiseWatchMiddleware';

const reducer = (state = {}, action) => {
  if (action.type === 'alreadyInitialized') {
    return Object.assign({}, state, { alreadyInitialized: true });
  }
  if (action.type === 'update') {
    return Object.assign({}, state, action.payload);
  }
  return state;
};

export default () => {
  let promiseWatchMiddleware;
  if (typeof window === 'undefined') {// dosn't use at browser
    promiseWatchMiddleware = createPromiseWatchMiddleware();
  }

  const middlewares = [];
  if (promiseWatchMiddleware) {
    middlewares.push(promiseWatchMiddleware);
  }
  middlewares.push(promiseMiddleware);

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

  // expose promiseWatchMiddleware (for publish the .wait)
  if (promiseWatchMiddleware) {
    store.promiseWatchMiddleware = promiseWatchMiddleware;
  }

  return store;
};

Server Rendering - react-router@v2.0.0を参考に、renderPropsが返った時だけhtmlを生成します。

server.js
import express from 'express';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { match, RouterContext } from 'react-router';
import { Provider } from 'react-redux';
import configureStore from './configureStore';
import Foo from './Foo';

const routes = [
  {
    path: '/',
    component: Foo,
  },
];

const port = process.env.PORT || 59798;
const app = express();
app.use((req, res, next) => {
  if (req.method !== 'GET') {
    return next();
  }

  // eslint-disable-next-line max-len
  const crawlerRegexp = /curl|wget|msie\s[6-9]|bot|crawler|baiduspider|80legs|ia_archiver|voyager|yahoo! slurp|mediapartners-google/i;
  if (req.get('User-Agent').match(crawlerRegexp) === null) {
    return next();
  }

  return match({ routes, location: req.originalUrl }, (error, redirectLocation, renderProps) => {
    if (error) {
      return res.status(500).send(error.message);
    }
    if (redirectLocation) {
      return res.redirect(302, redirectLocation.pathname + redirectLocation.search);
    }
    if (renderProps) {
      const store = configureStore();
      const provider = (
        <Provider store={store}>
          <RouterContext {...renderProps} />
        </Provider>
      );

      renderToStaticMarkup(provider);

      return store.promiseWatchMiddleware.wait().then(() => {
        store.dispatch({ type: 'alreadyInitialized' });

        return res.status(200).send(renderToStaticMarkup(provider));
      })
      .catch((reason) => {
        return res.status(500).send(reason.message);
      });
    }

    return next();
  });
});
app.listen(port, () => {
  console.log(`http://localhost:${port}`);
});

サーバーを起動した状態でcurlかwgetで結果を確認します。

babel-node server.js
# http://localhost:59798
curl http://localhost:59798
# <div>complete</div>

おわりに

renderToStringを2回実行するので、material-uiのような重量のあるタグを生成するフレームワークで、数百件データを表示するようなページには向きませんが、Promiseを使うことで簡潔に書けるため、可読性を高く保てる点がお勧めです。

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3