Virtual DOMは速い以外にもDOMを文字列としてレンダリングできるという利点があります。
これを利用するとモダンなブラウザなどではクライアント側でJSをバリバリ動かして、古いIEやクローラー等にはサーバー側でレンダリングしたものを返したりといったことができます。
そういうわけで練習がてらReact+expressで試しにブログ風のウェブページを作ってみました(ukyo-react-sample/blog at master · ukyo/ukyo-react-sample)。普通にページングできてタグがある感じのものです。
モダンブラウザの場合は初回アクセスだけwebpackでビルドしたJSを含むファイルを受け取ってその後はpushStateを使ってルーティングを行い、必要なデータを得るためにAPIを直接叩きます。
ボットや古いIEではアクセスする度にサーバーでレンダリングしたものを返します。APIはサーバー側で叩きます。
構成
どうでもいいファイルまでjsxになってますが、時間の都合上の問題なので気にしないでください。
特に重要なのはpublicとroutesの中のファイルなのでそこだけ。
├── public # Reactのコンポーネントはここ
│ ├── src
│ │ ├── common
│ │ │ └── req.jsx
│ │ ├── components
│ │ │ ├── EntryList.jsx
│ │ │ ├── Link.jsx
│ │ │ ├── MarkdownViewer.jsx
│ │ │ ├── Pager.jsx
│ │ │ ├── TagList.jsx
│ │ │ └── __tests__
│ │ │ ├── EntryList-test.jsx
│ │ │ ├── Link-test.jsx
│ │ │ ├── MarkdownViewer-test.jsx
│ │ │ └── TagList-test.jsx
│ │ ├── layouts
│ │ │ └── base.jsx
│ │ ├── main.jsx
│ │ ├── pages
│ │ │ ├── about.jsx
│ │ │ ├── entry.jsx
│ │ │ ├── error.jsx
│ │ │ ├── index.jsx
│ │ │ └── tag.jsx
│ │ └── routes.jsx
│ └── styles
├── routes # サーバー側のルーティング
│ ├── api
│ │ ├── entry.jsx
│ │ ├── index.jsx
│ │ └── tag.jsx
│ ├── index.jsx
│ └── react.jsx
各ページの構造
表示するページに相当するものはpublic/src/pages以下に置いておきます。各ファイルは外からは{title, resources, Page}
のオブジェクトとして見えます(説明は以下)。必要に応じてpublic/src/components以下にコンポーネントとして切り分けておきます。あとisomorphicにしたいのでこれらファイルではpurejsなコードだけ書くようにします。
title
title要素に設定する文字列を返す関数。
resources
各メソッドごとにAPIにリクエストするための情報を生成します。実際にAPIからデータを得る部分はクライアント、サーバーそれぞれで実装します。
Page
完全に表示するだけのReactComponentです。props
にAPIから得たデータが入っている前提で使います。
var TagPage = React.createClass({
render() {
return (
<div>
<div className="tag-page-title"><i className="fa fa-tags"></i> {this.props.ctx.params.tag}</div>
<EntryList entries={this.props.entries}/>
</div>
);
}
});
var resources = {
entries(ctx) {
return {
method: 'GET',
url: `/tags/${ctx.params.tag}`
}
}
};
module.exports = {
title(props) {
return `tag | ${props.ctx.params.tag}`;
},
resources: resources,
Page: TagPage
};
ルーティング
以下の様なルーティングの設定を作ってサーバー・クライアント側でこれを使いまわします。
module.exports = {
'/': require('./pages/index'),
'/entry/:yyyy/:mm/:dd/:slug': require('./pages/entry'),
'/tags/:tag': require('./pages/tag'),
'/about': require('./pages/about')
};
データの読み込みは多少共通化しました(もうちょいできそうですがwebpackの設定でハマってこんなかんじに・・・)。ctx
(expressで言うとこのreq
)とhandler
(上で説明したやつ)とloadFn
(パラメータを受け取ってプロミスでデータを返すだけの関数)を登録するとデータをPromiseで返してくれます。
module.exports = {
loadPageData(params) {
var {ctx, handler, loadFn} = params;
var promises, props = {ctx: ctx};
promises = _.map(handler.resources, (resource, k) => {
var o = resource(ctx);
o.url = constants.API_PATH + o.url;
if (o.method === 'GET' && !_.isEmpty(ctx.query)) {
o.url += `?${qs.stringify(ctx.query)}`;
}
return loadFn(o).then(data => props[k] = data);
});
return Promise.all(promises).then(() => props);
}
};
サーバー側
サーバー側ではexpressを使うのでそれに付いているルーターを使います。
モダンブラウザだけはそれ用のファイルを返して、ボット・IEは普通にルーティングさせます。これでボット・IEから見たら普通のブログとして機能します。
router.get('/*', (req, res, next) => {
var ua = req.get('User-Agent').toLowerCase();
// ie ~9 and bot
if (/msie\s[6-9]|bot|crawler|baiduspider|80legs|ia_archiver|voyager|curl|wget|yahoo! slurp|mediapartners-google/.test(ua)) {
return next();
}
// モダンブラウザだけJSバリバリ使うページを返す。
res.render('index', { title: constants.BLOG_TITLE });
});
// 一応、rpはrequest-promise
var load = (o) => {
o.uri = o.url;
return rp(o).then(JSON.parse);
};
// server side rendering
var setupRoute = (handler, k) => {
router.get(k, (req, res, next) => {
loadPageData({
ctx: req,
handler: handler,
loadFn: load
})
.then(props => {
var title = handler.title(props);
var {Page} = handler;
res.render('server_index', {
title: constants.BLOG_TITLE + (title ? ` | ${title}` : ''),
result: React.renderToString(<Base><Page {...props}/></Base>)
});
})
.catch(next);
});
};
_.forEach(require('../public/src/routes'), setupRoute);
// error page
router.get('/*', (req, res) => {
res.render('server_index', {
title: constants.BLOG_TITLE + ' | ' + errorPage.title(),
result: React.renderToString(<Base><errorPage.Page /></Base>)
});
});
クライアント側
expressの作者が作ったpage.jsというルーターを使います。こいつはexpress風のpushStateを使ったルーターでサーバー側と同じ感じで使えます。
var Main = React.createClass({
getInitialState() {
return {
Page: Empty,
props: {}
};
},
setTitle(title) {
document.title = constants.BLOG_TITLE + (title ? ` | ${title}` : '');
},
setupRoute(handler, path) {
page(path, (ctx, next) => {
loadPageData({
ctx: ctx,
handler: handler,
loadFn: req // expressのreqと紛らわしいですが、簡単なXMLHttpRequestのラッパーです
})
.then(props => {
this.setTitle(handler.title(props));
this.setState({
Page: handler.Page,
props: props
});
})
.catch(e => {
console.log(e, e.stack);
next();
});
});
},
setupErrorPage() {
page('*', ctx => {
var {Page} = errorPage;
this.setTitle(errorPage.title());
this.setState({
Page: Page,
props: {}
});
});
},
componentWillMount() {
_.forEach(require('./routes'), this.setupRoute);
this.setupErrorPage();
page();
},
render() {
var {Page, props} = this.state;
return (
<Base><Page {...props}/></Base>
);
}
});
大体同じですね。
その他、開発環境周り
Reactで開発するにあたってはwebpack的なものは絶対必要だし、実際使ってみたらとんでもなく便利なものですね。bowerとnpm両方使わなくてもいいし、設定ファイル的なものもrequireするだけなので楽です。まぁ、webpackの設定についてはもうちょっとどうにかなりそうですが(isomorphic度も上がるはず)。