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を用意します。
~/node_apps/react-isomorphic-boilerplate-app/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アドレスを指定します。
~/node_apps/react-isomorphic-boilerplate-app/docker-compose.yml 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コマンドを短縮するためにエイリアスを用意します。
~/.bashrc alias iojs-run='docker-compose run --rm npm' alias iojs-up='docker-compose up npm'
package.jsonはデフォルトで最小限を書いておきます。
~/node_apps/react-isomorphic-boilerplate-app/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
のセクションが追加されました。
~/node_apps/react-isomorphic-boilerplate-app/package.json { "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が出来上がったのでイメージにビルドします。
サーバーサイド プロジェクトにプログラムを配置するディレクトリを作成します。
$ 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">
要素を作成します。
~/node_apps/react-isomorphic-boilerplate-app/views/index.jade 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変数にはコンポーネントではなく、デフォルトの文字列を入れるようにしました。
~/node_apps/react-isomorphic-boilerplate-app/src/server/server.js import express from 'express' ;import React from 'react' ;import Router from 'react-router' ;const app = express();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 = '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するように変更しました。
~/node_apps/react-isomorphic-boilerplate-app/src/server/webpack.js import WebpackDevServer from "webpack-dev-server" ;import webpack from "webpack" ;import config from "../../webpack.config.dev" ;var server = new WebpackDevServer(webpack(config), { 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のホストとポート
ホットロード
アプリのクライアント
~/node_apps/react-isomorphic-boilerplate-app/webpack.config.dev.js 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にコンポーネントを共有して、サーバーサイドレンダリングできることを確認した後、静的な文字列に変更しています。
~/node_apps/react-isomorphic-boilerplate-app/src/shared/components/AppHandler.js 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コンポーネントを表示します。
~/node_apps/react-isomorphic-boilerplate-app/src/shared/routes.js 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を指定してコンポーネントをマウントします。
~/node_apps/react-isomorphic-boilerplate-app/src/client/entry.js 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ポートです。
http://xxx.xxx.xxx.xxx: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で共有いしているコンポーネントの動作が見えづらいところがあります。