モダンJS on Rails5.1(Webpacker+React+ServerSide Rendering)

  • 29
    いいね
  • 0
    コメント

はじめに

2017年2月にRails 5.1betaがリリースされました。すでに各所で話題になっている通り、webpackerを用いてモダンJSの環境を構築することが容易になっています。

そこで今回はWebpackerに加え、hypernovaも使ってReactのServerSide RenderingをRails5.1で実現するまでのステップをまとめます。

完成版のリポジトリはこちら
KeitaMoromizato/rails5.1-react-app

[解説]Webpackとbabel

いわゆるモダンJSと呼ばれるもので、抑えておきたいのはWebpackbabel

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に追記して、

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>タグを挿入してくれます。

app/views/hello/index.erb.html
<%= 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要素に追加していることがわかります。

app/javascript/packs/hello_react.jsx
document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <Hello name="React" />,
    document.body.appendChild(document.createElement('div')),
  )
})

手順1のdiff

次は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という名前でプロジェクトのルートディレクトリに置きます。

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は以下のようになります。これもプロジェクトのルートディレクトリに設置します。

docker-commpose.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!に更新されることがわかります。

app/javascript/packs/hello_react.jsx
document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <Hello name="Angular" />,
    document.body.appendChild(document.createElement('div')),
  )
})

手順2のdiff

[解説]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というファイルでいいでしょう。このファイルは、サーバーサイドでもブラウザでも共通して使います。

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()はサーバーで実行されたときと、ブラウザで実行されたときで振る舞いが変わるように作られています。

app/javascript/packs/application.js
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を作ります。

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からのリクエストを待ち受けて、レンダリングしたコンポーネントを返します。

hypernova.js
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

手順3のdiff

4. Hypernovaの設定

最後にRails側の設定をします。Gemfilehypernovaを追加し、インストールします。

Gemfile
gem 'hypernova'
$ bundle install

hypernovaの設定ファイルを追加します。config/initializers/hypernova_initializer.rbにファイルを設置しましょう。DockerComposeを使っている場合はhost名はhypernovaに、使わない場合はlocalhostとかで良いでしょう。

config/initializers/hypernova_initializer.rb
Hypernova.configure do |config|
  # Dockerを使わない場合はlocalhost
  config.host = "hypernova"
  config.port = 3030
end

hypernovaでServerSide Renderingするには、レンダリングするControllerでhypernova_render_supportが必要です。

app/controllers/hello_controller.rb
class HelloController < ApplicationController
  around_action :hypernova_render_support
  def index
  end
end

app/views/hello/index.erb.htmlに下記のタグを追加することで、指定の位置にレンダリングします。

app/views/hello/index.html.erb
<%= 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

手順4のdiff

/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箇所に分散してしまうことがイメージできるでしょう。

app/views/hello/index.html.erb
<%= 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