ES6で書くIsomorphicアプリのBoilerplateを調べました。いくつか手を動かしながら勉強していこうと思います。最初はなるべくシンプルなIsomorphicな動作を選びたいのですが、Reactアプリは複雑になりがちで周辺ツールも多くどの構成を選んだら良いか悩みます。Tutorial: Setting Up a Simple Isomorphic React appのポストがとてもわかりやすいので写経していきます。
Isomorphicな特徴
IsomorphicなReactアプリはクライアントのコードは1つのファイルにバンドルして、HTMLからロードします。最初にHTMLを開いたときはサーバーサイドでレンダリングされたコンポーネントを表示しますが、バンドルされたReactアプリがロードされたら上書きします。
プロジェクト
Tutorial: Setting Up a Simple Isomorphic React appのリポジトリはreact-isomorphic-boilerplateです。
今回作成したディレクトリ構造です。作業リポジトリはこちらです。
$ cd ~/node_apps/react-isomorphic-boilerplate-app
$ tree
.
├── Dockerfile
├── README.md
├── css
├── docker-compose.yml
├── node_modules -> /dist/node_modules
├── package.json
├── src
│ ├── client
│ │ └── entry.js
│ ├── server
│ │ ├── server.js
│ │ └── webpack.js
│ └── shared
│ ├── components
│ │ └── AppHandler.js
│ └── routes.js
├── views
│ └── index.jade
└── webpack.config.dev.js
Dockerfile
io.jsのベースイメージを使いDockerfileを用意します。
FROM iojs:2.3
MAINTAINER Masato Shimizu <ma6ato@gmail.com>
RUN mkdir -p /app
WORKDIR /app
RUN adduser --disabled-password --gecos '' --uid 1000 docker && \
mkdir -p /dist/node_modules && \
ln -s /dist/node_modules /app/node_modules && \
chown -R docker:docker /app /dist/node_modules
USER docker
COPY package.json /app/
RUN npm install
COPY . /app
CMD ["npm","start"]
docker-compose.yml
docker-compose.ymlには環境変数としてpublic ip addressを指定します。HTMLからWebpack dev serverに接続するためのIPアドレスです。サーバーサイドはクラウド上で動作しているため、ブラウザからはリモートでアクセスする必要があります。PUBLIC_IPにインターネットから接続できるIPアドレスを指定します。
npm:
build: .
volumes:
- .:/app
- /etc/localtime:/etc/localtime:ro
environment:
- PUBLIC_IP=xxx.xxx.xxx.xxx
- EXPRESS_PORT=3030
- WEBPACK_PORT=8090
ports:
- 3030:3030
- 8090:8090
docker-composeコマンドを短縮するためにエイリアスを用意します。
alias iojs-run='docker-compose run --rm npm'
alias iojs-up='docker-compose up npm'
package.jsonはデフォルトで最小限を書いておきます。
{
"name": "react-isomorphic",
"description": "react-isomorphic",
"version": "0.0.1",
"private": true
}
docker-composeのエイリアスを使って必要なモジュールをnpmでインストールします。
$ iojs-run npm install --save-dev react webpack react-router react-hot-loader webpack-dev-server babel-loader
用意しておいたpackage.jsonにdevDependencies
のセクションが追加されました。
{
"name": "react-isomorphic",
"description": "react-isomorphic",
"version": "0.0.1",
"private": true,
"devDependencies": {
"babel": "^5.6.14",
"babel-core": "^5.6.17",
"babel-loader": "^5.3.1",
"express": "^4.13.1",
"jade": "^1.11.0",
"node-libs-browser": "^0.5.2",
"nodemon": "^1.3.7",
"react": "^0.13.3",
"react-hot-loader": "^1.2.8",
"react-router": "^0.13.3",
"webpack": "^1.10.1",
"webpack-dev-server": "^1.10.1"
},
"scripts": {
"clean": "rm -rf lib",
"watch-js": "babel src -d lib --experimental -w",
"dev-server": "node lib/server/webpack.js",
"server": "nodemon lib/server/server.js",
"start": "npm run watch-js & npm run dev-server & npm run server",
"build": "npm run clean && babel src -d lib --experimental"
}
}
scripts
セクションに開発に必要なコマンドを用意します。ごちゃごちゃしているのでGulpでまとめた方が良さそうです。ホットロードもしますが、ES6で書いたコードはbuild
でコンパイルしておきます。ブラウザからのリクエストを処理するExpressのサーバーと、バンドルされたReactアプリを返すWebpack dev serverの2つを起動します。
package.jsonが出来上がったのでイメージにビルドします。
$ docker-compose build
サーバーサイド
プロジェクトにプログラムを配置するディレクトリを作成します。
$ mkdir -p src/{server,shared,client} views
- server: ExpressとWebpack dev server
- client: Reace bundleのエントリポイント
- shared: componentsとroutes
views/index.jade
ビューはJadeのテンプレートエンジンを使います。#app!= content
の記述で<div id="app">
要素を作成します。
html
head
title="React Isomorphic App"
meta(charset='utf-8')
meta(http-equiv='X-UA-Compatible', content='IE=edge')
meta(name='description', content='')
meta(name='viewport', content='width=device-width, initial-scale=1')
body
#app!= content
script(src='http://'+public_ip+':'+webpack_port+'/js/app.js', defer)
src/server/server.js
ExpressのコードもES6で書きます。routeは/*
で全部拾ってreact-router
に渡します。このプロジェクトにはfavicon.icoを用意していませんが、staticなコンテンツもreact-routerにroutingの役割が回ってしまいます。
Warning: No route matches path "/favicon.ico". Make sure you have <Route path="/favicon.ico"> somewhere in your routes
react-router
はサーバーとクライアントでshared/routes
を共有しています。デフォルトのサンプルだと同じコンポーネントを使っているので、Expressが<div id="app">
にrenderしたcontentと、React bundleがロードされた後に、document.getElementById('app')
でrenderする内容の区別がつきません。Isomorphicではなくなりますが、処理をわかりやすくするためにcontents変数にはコンポーネントではなく、デフォルトの文字列を入れるようにしました。
import express from 'express';
import React from 'react';
import Router from 'react-router';
const app = express();
// set up Jade
app.set('views', './views');
app.set('view engine', 'jade');
import routes from '../shared/routes';
app.get('/*', function(req, res) {
Router.run(routes, req.url, Handler => {
//let content = React.renderToString(<Handler />);
let content = 'empty!';
res.render('index', { public_ip_port: process.env.PUBLIC_IP_PORT,
content: content });
});
});
var server = app.listen(process.env.EXPRESS_PORT, function() {
var host = server.address().address;
var port = server.address().port;
console.log('Example app listening at http://%s:%s', host, port);
});
src/server/webpack.js
Reactアプリをレンダーする開発用サーバーです。Node.jsのサーバーとは別に動作します。WebpackDevServerインスタンスは、webpack.config.dev.js
に書かれた設定を読み込んで使います。今回はサーバーサイドがクラウド上で動作しているため、Webpack dev serverはリモートから接続します。localhost
でなく0.0.0.0
でLISTENするように変更しました。
import WebpackDevServer from "webpack-dev-server";
import webpack from "webpack";
import config from "../../webpack.config.dev";
var server = new WebpackDevServer(webpack(config), {
// webpack-dev-server options
publicPath: config.output.publicPath,
hot: true,
stats: {colors: true},
});
server.listen(process.env.WEBPACK_PORT, "0.0.0.0", function() {});
webpack.config.dev.js
entryにapp.jsにbundleするエントリポイントを複数指定します。
- webpack-dev-serverのホストとポート
- ホットロード
- アプリのクライアント
var webpack = require('webpack');
var public_url = 'http://'+process.env.PUBLIC_IP+':'+process.env.WEBPACK_PORT;
module.exports = {
devtool: 'inline-source-map',
entry: [
'webpack-dev-server/client?'+public_url,
'webpack/hot/only-dev-server',
'./src/client/entry',
],
output: {
path: __dirname + '/public/js/',
filename: 'app.js',
publicPath: public_url+'/js/',
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin(),
],
resolve: {
extensions: ['', '.js']
},
module: {
loaders: [
{ test: /\.jsx?$/, loaders: ['react-hot', 'babel-loader?experimental'], exclude: /node_modules/ }
]
}
}
共有
src/shared/components/AppHandler.js
AppHandler.jsはサーバーとクライアントで共有しているコンポーネントです。今回のテストではExpressからIsomorphicにコンポーネントを共有して、サーバーサイドレンダリングできることを確認した後、静的な文字列に変更しています。
import React from 'react';
export default class AppHandler extends React.Component {
render() {
return <div>Hello App Handler</div>;
}
}
src/shared/routes.js
react-router
のroutesを定義します。Routeはpathの/
にAppHandlerコンポーネントを表示します。
import { Route } from 'react-router';
import React from 'react';
import AppHandler from './components/AppHandler';
export default (
<Route handler={ AppHandler } path="/" />
);
クライアント
src/client/entry.js
React bundleのエントリポイントです。サーバーサイドではReact.renderToString(<Handler />);
でJadeでレンダリングするcontents変数を作成しましたが、クライアントサイドでは React.render(<Handler />, document.getElementById('app'));
を使って、直接divのidを指定してコンポーネントをマウントします。
import React from 'react';
import Router from 'react-router';
import routes from '../shared/routes';
Router.run(routes, Router.HistroyLocation, (Handler, state) => {
React.render(<Handler />, document.getElementById('app'));
});
起動とテスト
一応クリーンビルドしておきます。
$ iojs-run npm run clean
$ iojs-run npm run build
...
npm info postclean react-isomorphic@0.0.1
npm info ok
src/client/entry.js -> lib/client/entry.js
src/server/server.js -> lib/server/server.js
src/server/webpack.js -> lib/server/webpack.js
src/shared/components/AppHandler.js -> lib/shared/components/AppHandler.js
src/shared/routes.js -> lib/shared/routes.js
npm info postbuild react-isomorphic@0.0.1
npm info ok
docker-compose up npm
のエイリアスである、iojs-up
を実行します。
$ iojs-up
...
npm_1 | webpack: bundle is now VALID.
ブラウザからDockerホストのパブリックIPアドレスを実行します。接続先はExpressサーバーの3030ポートです。
本来はIsomorphicに共有しているコンポーネントをサーバーサイドでrenderします。React bundleが上書きする動作を確認するため、'empty!'の静的な文字列が最初にrenderします。index.htmlがロードされたあと、React bundleがWebpack dev serverからロードされます。react-router
の/
pathに従いAppHandler.jsがcomponentとして表示されます。
課題
シンプルなサンプルなのでとてもわかりやすいですが、Expressのrouteを/*
としてすべてreact-routerに渡しています。その反面favicon.icoなど静的ファイルもreact-routerでハンドリングが必要になったり、Isomorphicで共有いしているコンポーネントの動作が見えづらいところがあります。