はじめに
2017年2月にRails 5.1betaがリリースされました。すでに各所で話題になっている通り、webpacker
を用いてモダンJSの環境を構築することが容易になっています。
そこで今回はWebpackerに加え、hypernova
も使ってReactのServerSide RenderingをRails5.1で実現するまでのステップをまとめます。
完成版のリポジトリはこちら
KeitaMoromizato/rails5.1-react-app
[解説]Webpackとbabel
いわゆるモダンJSと呼ばれるもので、抑えておきたいのはWebpackとbabel。
Webpack
近年のJavaScriptでは、npm(Node Package Manager)でライブラリを配布するのが主流になっています。ただnpmは本来node.jsのパッケージとして作られているので、ここで配布されているモジュールの形式(CommonJS)では、直接ブラウザ上で実行できません。
そこで**Webpack**という依存性解決ツールを使い、ブラウザ上で実行できる形に変換します。これによりフロントエンド開発でも、import/export
でライブラリ(や自分で書いたJSのコード)を読み込むという、サーバーサイドのようなモダンな設計ができます。
ちなみにWebpackerは、npmと互換性のあるyarnというツールを使っています。雑にいうとライブラリのダウンロードの仕方、管理の仕方が違うだけなので、ダウンロードしたライブラリの依存性を解決するWebpackからすると、特に気にすることはありません。
babel
JavaScript->JavaScriptのコンパイラ。主な用途としては、ES2015のような次世代の構文で書かれたJavaScriptを、現代の環境(ブラウザ/node.js)でも動く形に変換します。例えばES205の変換結果はBABEL Try it outで試せます。
単体で使うこともありますが、ここではWebpackが依存性を解決するついでに、babelでのコンパイルを行っています。
[手順]1.Webpackerのセットアップ
通常通りrails new
でアプリを作成。
$ mkdir react-app
$ cd react-app
$ rails new .
次にwebpacker
のインストールをします。一応rails new . --webpack=react
でもできるようですが、執筆時点でうまく行かなかったのでこちらの方法で...。Webpacker自体はRails4.1でも動くらしいからこの手順だと5.1の意味ないんだけど...。
Gemfile
に追記して、
gem 'webpacker', github: 'rails/webpacker'
webpacker
のコマンドを叩くと、それぞれ必要なファイルが生成されます。
$ bundle install
$ bin/rails webpacker:install
$ bin/rails webpacker:install:react
次に/hello/index
という適当なページを作ります。ルーティングの設定はご自由に。webpackerを使っていたらcoffeeは不要なので、--no-assets
してもよいかと。
$ bin/rails g controller hello index --no-assets
そしてapp/views/hello/index.erb.html
を以下のように書き換えます。この場所にWebpackerがhello_react
の<script>
タグを挿入してくれます。
<%= javascript_pack_tag 'hello_react' %>
Railsを起動する前に、Webpackで一度JavaScrptをビルドします。これを実行しないと、いくらJavaScriptファイルを更新しても画面は変わらないので気をつけてください。
$ bin/webpack
これでlocalhost:3000/hello/index
に「Hello React!」と表示されたら成功です。
$ bin/rails s
ここまでは、いわゆるClientSide Renderingという形で、ブラウザ上で実行したJavaScriptによりDOM(HTML要素)を構築しています。試しに、開発者コンソールで/hello/index
のレスポンスを見てみると、レスポンスは次のようになっていて、「Hello React!」のDOMはありません。
<body>
<script src="/packs/hello_react.js"></script>
</body>
DOMを構築している部分のコードはapp/javascript/packs/hello_react.jsx
にあり、body要素に追加していることがわかります。
document.addEventListener('DOMContentLoaded', () => {
ReactDOM.render(
<Hello name="React" />,
document.body.appendChild(document.createElement('div')),
)
})
次はClientSide Renderingに対比してServerSide Renderingと呼ばれているものを実装します。
[手順]2.Webpack WatchをDockerComposeで管理する
先にもある通り、JavaScriptファイルを修正したらbin/webpack
で再ビルドする必要があります。これは面倒なので、bin/webpack-watcher
というJSの変更を検知して再ビルドしてくれるコマンドもあるのですが、これはRailsと別プロセスで動かし続ける必要があります。
毎回両方立ち上げるのも面倒なので、DockerComposeを使って複数プロセスを管理しましょう。後のServerSide Renderingのためにはもうひとつプロセスが増えるので、先に作っておくと便利です。Dockerを使わない手順も合わせて記載していますので、使わない人はスルーで。
Docker及びDockerComposeについての解説はこちらの記事をどうぞ。
開発環境をDockerに乗せる方法とメリットを3ステップで学ぶチュートリアル
Dockerfileは雑にこんな感じのものを使っています。これをDockerfile
という名前でプロジェクトのルートディレクトリに置きます。
# これは開発環境用です
FROM ruby:2.4.0
LABEL maintainer "Keita Moromizato <keita.moromi@gmail.com>"
ENV APP_ROOT /var/app
ENV BUNDLE_PATH vendor/bundle
WORKDIR $APP_ROOT
RUN apt-get update && \
curl -sL https://deb.nodesource.com/setup_6.x | bash - && \
apt-get install -y nodejs \
--no-install-recommends && \
rm -rf /var/lib/apt/lists/*
RUN \
npm install yarn -g && \
echo 'gem: --no-document' >> ~/.gemrc && \
cp ~/.gemrc /etc/gemrc && \
chmod uog+r /etc/gemrc && \
bundle config --global build.nokogiri --use-system-libraries && \
bundle config --global jobs 4 && \
gem install rails -v 5.1.0.beta1
EXPOSE 3000
CMD ['rails', 's', '-b', '0.0.0.0']
今回はRailsプロセスとwebpackのwatchプロセス、計2つを管理するので、docker-compose.yml
は以下のようになります。これもプロジェクトのルートディレクトリに設置します。
version: '3'
services:
app:
build: .
volumes:
- ./:/var/app
working_dir: /var/app
ports:
- 3000:3000
command: rails s -b 0.0.0.0
webpack:
image: rails:5.1
volumes:
- ./:/var/app
working_dir: /var/app
command: ./bin/webpack-watcher
そして下記のコマンドを実行することで、2つのプロセスが起動します。
# Dockerな人
$ docker-compose up
# Dockerを使わない人はコンソールを2つ開いて
$ rails s
$ bin/webpack-watcher
試しに起動中に、app/javascript/packs/hello_react.jsx
の下記の部分を変更してブラウザをリロードすると、画面に表示されるメッセージがHello Angular!
に更新されることがわかります。
document.addEventListener('DOMContentLoaded', () => {
ReactDOM.render(
<Hello name="Angular" />,
document.body.appendChild(document.createElement('div')),
)
})
[解説]ServerSide Rendering
先ほど説明したとおり、ここからはServerSide Renderingの実装方法を説明します。その前に、Reactで書かれたJSがどのようになっているのかを確認しておきましょう。
React.jsはコンポーネント思考と呼ばれ、JSXという独自の記法でJSファイル一つひとつが画面の部品を表すように設計されます(ちなみにこのJSXもBabelで実行可能な形に変換される)。超雑にいうと、JSの中にHTMLが書かれているようなイメージです。
// React.jsのサンプル
import React from 'react';
import { Component } from 'react-dom';
class Hello extends Component {
render() {
return (
<div>
<h1>Hello {this.props.title}!</h1>
</div>
);
}
}
Hello.defaultProps = {
title: "React"
};
// htmlに変換すると
// <div><h1>Hello React!</h1></div>
// となる
今はnode.jsという、サーバーサイドのJS実行環境があるので、上記のコードはサーバーサイドでも実行できます。それを利用して、サーバーサイドでReactコンポーネントを実行してHTMLに変換し、それをHTTPレスポンスとして返すことができます。これがServerSide Renderingです。
よくSPA(Single Page Application) VS ServerSide Rendering、みたいな話がありますが、どちらかというとテンプレートエンジン VS React Componentの方がしっくりきます。プログラマブルにHTMLを返すというのは、昔からテンプレートエンジンを使って行われていて、それがReact(JS)でもできるようになった、という方が正しいです。
今まではテンプレートエンジンを使って初期状態をレンダリングしつつ、ブラウザではJSでDOMの書き換えを行っていました。するとどちらにも似たような処理を書くケースが多く、処理の共通化の面で課題が残っていました。Reactなら、同じコンポーネントをサーバーサイドでも、ブラウザでも実行できるので、DOMの管理が共通化できるようになったのです。
[手順]3.ServerSide Rendering用のJSをビルドする設定
JSをサーバーサイドで実行できると言っても、流石にRubyから読むのは難しいので、レンダリングの部分はnode.jsプロセスにお任せします。今回は、このRails -> node.jsのブリッジを簡単に実現してくれるhypernova
というパッケージを使います。
react-rails
でも実現できそうでしたが、webpackerの導入によりdigest生成器と成り果てたsprocketsに依存している風だったのでやめました。細かく調べたわけではない。
それではまずは、JS側のパッケージをインストールします。
$ bin/yarn add hypernova hypernova-react
次に描画するコンポーネントを作成します。app/javascript/components/hello/index.js
というファイルでいいでしょう。このファイルは、サーバーサイドでもブラウザでも共通して使います。
import React, { Component } from 'react';
export default class Hello extends Component {
constructor(props) {
super(props);
this.state = { liked: props.liked };
}
onClickLike() {
this.setState({
liked: !this.state.liked
});
}
render() {
return (
<div>
<h1>Hello {this.props.title}!</h1>
<button onClick={() => this.onClickLike()}>{this.state.liked ? "いいね済み" : "いいね!" }</button>
</div>
);
}
}
最初からあったhello_react.jsx
ファイルは削除してしまって、app/javascript/packs/application.js
をビルドのエントリポイントとしましょう。
application.js
では、HypernovaReact.renderReact()
を実行するだけです。
元のhello_react.jsx
を見ると、document.body
という既存のDOMを指定して、そこにReactDOM.render
でレンダリングしています。HypernovaReact
はそのあたりをラップしてくれるモジュールです。renderReact()
はサーバーで実行されたときと、ブラウザで実行されたときで振る舞いが変わるように作られています。
import { renderReact } from 'hypernova-react';
import Hello from 'components/hello';
export default renderReact('hello', Hello);
次はサーバー側固有の処理を書いていきます。先ほどのコンポーネントをサーバーサイドでも実行したいのですが、import
構文やJSXはいまのnode.js(v6.9)ではサポートされていません。そのため、フロントエンド側のコードと同様にWebpackでビルドをします。
ただ、Webpackは元々フロントエンドのビルドをするためのものなので、上記server.js
のようなexport
によりモジュール化されたCommonJSのコードを、ビルドのエントリポイントに設定してもうまく動きません。
そこでCommonJS形式を保ったままビルドするために、Webpackの設定を変更しましょう。デフォルトの設定はconfig/webpack/shared.js
を使うので、それoverrideしたようなconfig/webpack/server.js
を作ります。
const merge = require('webpack-merge');
const path = require('path');
const sharedConfig = require('./shared');
// ./app/javascript/packs/application.jsをcommonjs形式でビルドし、結果をpublic/packs/server.jsに出力する
module.exports = merge(sharedConfig.config, {
entry: './app/javascript/packs/application.js',
output: {
filename: 'server.js',
path: path.resolve('public', sharedConfig.distDir),
libraryTarget: 'commonjs',
},
});
shared.js
は、実際にはconfig/webpack/development.js
で読み込まれます。development.js
で、同様にserver.js
を読み込むように変更しましょう。Webpackの設定は配列にすることで、複数の設定を反映できます。
const sharedConfig = require('./shared.js')
const serverConfig = require('./server');
module.exports = [merge(sharedConfig.config, {
// 略
}), serverConfig];
あとはhypernova.js
をプロジェクトのルートディレクトリに置いて終わりです。hypernovaは特定のポート(ここでは3030)でRailsからのリクエストを待ち受けて、レンダリングしたコンポーネントを返します。
const hypernova = require('hypernova/server');
hypernova({
devMode: true,
getComponent: name => require('./public/packs/server').default,
port: 3030 || process.env.HYPERNOVA_PORT,
});
このhypernovaプロセスを起動するための設定を、docker-compose.yml
に追記します。service.hypernova
の追加と、service.app
にlinks属性を追加しましょう。
version: '3'
services:
app:
build: .
volumes:
- ./:/var/app
working_dir: /var/app
command: rails s -b 0.0.0.0
ports:
- 3000:3000
links:
- hypernova
webpack:
image: rails:5.1
volumes:
- ./:/var/app
working_dir: /var/app
command: ./bin/webpack-watcher
hypernova:
image: node:6.9
volumes:
- ./:/var/app
working_dir: /var/app
command: node hypernova.js
environment:
HYPERNOVA_PORT: 3030
4. Hypernovaの設定
最後にRails側の設定をします。Gemfile
にhypernova
を追加し、インストールします。
gem 'hypernova'
$ bundle install
hypernovaの設定ファイルを追加します。config/initializers/hypernova_initializer.rb
にファイルを設置しましょう。DockerComposeを使っている場合はhost名はhypernova
に、使わない場合はlocalhostとかで良いでしょう。
Hypernova.configure do |config|
# Dockerを使わない場合はlocalhost
config.host = "hypernova"
config.port = 3030
end
hypernovaでServerSide Renderingするには、レンダリングするControllerでhypernova_render_support
が必要です。
class HelloController < ApplicationController
around_action :hypernova_render_support
def index
end
end
app/views/hello/index.erb.html
に下記のタグを追加することで、指定の位置にレンダリングします。
<%= render_react_component('hello', liked: false) %>
<%= javascript_pack_tag 'application' %>
これで実装は完了。起動してlocalhost:3000/hello/index
にアクセス。
# Dockerな人
$ docker-compose up
# Dockerを使わない人はコンソールを3つ開いて
$ rails s
$ bin/webpack-watcher
$ node hypernova.js
/hello/index
へのレスポンスを見てみると、<body>
の中は次のようになっていることがわかります。hypernovaが独自のデータを入れ込んでいるので見づらいですが、正しく<h1>Hello</h1><button>いいね!</button>
がレンダリングされて返ってきていることがわかります。
<div data-hypernova-key="hello" data-hypernova-id="85d13807-3091-4d33-96b0-1275439b7bd1"><div data-reactroot="" data-reactid="1" data-react-checksum="-1236480788"><h1 data-reactid="2"><!-- react-text: 3 -->Hello <!-- /react-text --><!-- react-text: 4 -->!<!-- /react-text --></h1><button data-reactid="5">いいね!</button></div></div>
<script type="application/json" data-hypernova-key="hello" data-hypernova-id="85d13807-3091-4d33-96b0-1275439b7bd1"><!--{"liked":false}--></script>
最上位のdivタグにはdata-hypernova-key
が含まれています。ブラウザでrenderReact()
されたコンポーネントは、このkeyを元に自分がどこにレンダリングされるべきかを判断します。今回のサンプルでは雑ないいねボタンを作っていますが、初期状態(false)がサーバーサイドでレンダリングされ、ブラウザのユーザーアクション(onClick)
に合わせて正常にDOMが書き換わっていることがわかります。
ボタンの初期状態を書き換えても正しく動くはずです。これがReactを使わないと、初期状態はerbでレンダリングして、clickに合わせてjQueryで更新してと、同じような処理が2箇所に分散してしまうことがイメージできるでしょう。
<%= render_react_component('hello', liked: true) %>
<%= javascript_pack_tag 'application' %>
次はRouter入れてSSR + SPAとテストでも書いてみようと思います。
参考
http://qiita.com/mugi_uno/items/8e21ed4472577dc19cea
http://qiita.com/noriaki/items/e2dac69b9dd88334dd43
https://github.com/Roilan/react-server-boilerplate/blob/master/webpack.config.js