環境
- NodeJS v5.7.0
- Npm v3.7.1
babel-cli@6.5.1
- package.json
なぜ?
renderToStringには、非同期処理を待つ機構が用意されていません。事前に非同期処理を済ませなければ、空のstateでhtmlを生成します。
たとえば、redux-promiseを用いた以下のようなコードはうまく動作しません。
記事中は
renderToString
ではなくrenderToStaticMarkup
を用います
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));
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-propsのloadProps
など)に非同期処理を定義する。render実行前にこのメソッドを実行し、完了を待つ。 -
renderToString
を実行すると、renderした全てのコンポーネントはcomponentWillMount
を実行するので、componentWillMount
に非同期処理を定義し、renderToString
で発行した非同期処理を監視し、完了を待つ。1
今回紹介するのは2つ目の方法です。
非同期処理を監視するreduxミドルウェアの作成
ということで、renderToString
したときにdispatch
されたPromise
の監視を行うミドルウェアを作成します。
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の完了を待ってから再描写します。
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
は、リクエストの度に使い捨てることになるので、ファクトリ関数として定義し直します。
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を生成します。
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
を使うことで簡潔に書けるため、可読性を高く保てる点がお勧めです。