普通のRailsアプリケーションにReactを導入する

普通のRailsアプリケーションにReactを導入する

Clock Icon2017.02.10

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

私の所属するモバイルアプリサービス部では、Webアプリケーションの場合大体Railsで作ります。
Railsに特に不満はないのですが、複雑なUIが必要な画面になると実装が大変です。今まではテンプレートエンジンとJQueryで四苦八苦しながらなんとか対応していましたが、このままではいずれ保守するのが難しくなってきます。
フロント側でも保守性の高いコードで継続して機能追加できるようにするため、Railsアプリケーションに部分的にReactを導入しました。
今回は、すでに実装済みのRailsアプリケーションに後からReactを導入するための手順をまとめてみます。
また、JavaScriptもCoffeeScriptではなくES2015を使うようにします。

アセットファイルのビルド戦略

Sprocketsとの共存

Railsには最初からJavaScriptの依存関係の管理、AltJsのコンパイル、minifyをしてくれるSprocketsが用意されています。
Sprocketsをやめて全てwebpackでアセットファイルのビルドをすることも考えましたが、 今回は一部の画面でのみReactを使うのでSprocketsはそのまま残すことにしました。
新しく追加するJavaScriptコードはProjectルート直下のclientディレクトリに配置し、 その下のファイルのみwebpackを使ってビルドすることにします。

環境構築、ビルド設定

webpack

webpack3

webpackはJavaScriptファイルやクライアントサイドのアセットファイルの依存関係を解決して、
一つにまとめたアセットファイルを生成するビルドツールです。
webpack

Babel

Babel

Babelは新しいJavaScriptの仕様で書かれたコードを現状のブラウザで動くES5のコードに変換するトランスパイラです。 ES2015とReactで使用するJSXをトランスパイルするために使用します。
Babel

React

React

Reactの情報はインターネット上に沢山ありますので詳細な説明は省略しますが、Facebookが自社Webサービスのために開発しOSSとして公開されたライブラリです。
複雑なUIを持つ画面を従来のDOM操作を意識したプログラミングではなく、データモデルに変更があった場合にDOM全てを上書きするようなプログラミングができます(実際にはVirtualDOMで部分的に更新しています)。
サーバサイドのパラダイムでプログラミングできることが触ってみて非常に嬉しかったです。
Reactについての詳細は以下を参考にしてください。チュートリアルが非常に良くできていて理解しやすいです。

React
チュートリアル

install

それでは、npmでwebpackとBabel関連ライブラリをインストールしましょう。
clientディレクトリで npm init -y を実行するとpackage.jsonが生成されます。
その後に、以下コマンドでwebpackとbabelをインストールします。

$ npm install -D webpack babel-core babel-loader babel-preset-es2015 babel-preset-react

babel関連ライブラリはそれぞれ以下の役割です。

名称 役割
babel-core babel-loaderを使うために必要なパッケージ
babel-loader webpackからbabelを使えるようにする
babel-preset-react babel-loaderでJSXのコンパイルができるようにする
babel-preset-es2015 babel-loaderでES2015のコンパイルができるようにする

次にreactをインストールします。

$ npm install -S react react-dom

package.jsonは以下のようになりました。

{
   (中略)   
   "devDependencies": {
     "babel-core": "^6.21.0",
     "babel-loader": "^6.2.10",
     "babel-preset-es2015": "^6.18.0",
     "babel-preset-react": "^6.16.0",
     "webpack": "^1.14.0"
   },
   "dependencies": {
     "react": "^15.4.2",
     "react-dom": "^15.4.2",
   },
   (中略)
}   

ビルド設定

webpackでビルドできるように設定ファイルを作ります。
clientディレクトリにwebpack.config.jsファイルを作成し以下の内容を記述します。

module.exports = {
   entry: {
     app: './src/index.js',
   },

   output: {
     path: '../app/assets/javascripts/webpack',
     filename: '[name].js',
   },

   module: {
     loaders: [
       { test: /\.(js|jsx)$/,
         loader: "babel",
         exclude: /node_modules/,
         query: {
           presets: ["es2015", "react"],
         }
       },
     ]
   }
 };

path: '../app/assets/javascripts/webpack を記述することで、ビルド結果のファイルがapp/assets/javascripts/webpack/app.jsに配置されます。
この出力結果のファイルをRailsのアセットプリコンパイルの対象にします。
confing/initializes/assets.rbに以下の記述を追加します。

Rails.application.config.assets.precompile += %w( webpack/app )

続いて、package.jsonに開発用ビルドコマンドを設定します

{
  "scripts": {
     "webpack-watch": "webpack -w"
  },
  (中略)
}  

以下のコマンドをプロジェクトルートディレクトリで叩くと、ファイルが編集された場合に自動でビルド実行されます。

$ npm --prefix client run webpack-watch
> @ webpack-watch 
> webpack -w

Hash: 56f7a9cf5040b734393d
Version: webpack 1.14.0
Time: 4808ms
 Asset    Size  Chunks             Chunk Names
app.js  978 kB       0  [emitted]  app
    + 245 hidden modules

Production環境用ビルド設定

最適化されたJavaScriptコードを出力するようにwebpackの設定をします。 clientディレクトリに webpack-production.config.js を作成します。

const webpack = require('webpack');
module.exports = {
  entry: {
    app: './src/index.js',
  },
  output: {
    path: '../app/assets/javascripts/webpack',
    filename: '[name].js',
  },
  module: {
    loaders: [
      { test: /\.(js|jsx)$/,
        loader: "babel",
        exclude: /node_modules/,
        query: {
          presets: ["es2015", "react"],
        }
      },
    ]
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    }),
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: false
      }
    })
  ]
};

package.jsonにproduction用ビルドコマンドも追加します。

{
  "scripts": {
     "webpack-watch": "webpack -w",
     "webpack-build-production": "webpack -p --config webpack-production.config.js --progress"
   },
   (中略)
}   

production用にビルドする場合、以下のコマンドを実行します

$ npm --prefix client run webpack-build-production

テスト

JavaScriptコードのテストライブラリはJestを使いました。こちらもFacebook製のライブラリです。
また、作成したReactComponentをテストするためにEnzymeを使用します。
Enzymeを使うとReactコンポーネントの出力を検証しやすくなります。
以下コマンドでテスト関連のライブラリをインストールします。

$ npm install -D enzyme react-addons-test-utils jest-cli babel-jest

テスト実行コマンドもpackage.jsonに追加します。

{
  "scripts": {
     "test": "jest --verbose", 
     (中略)
   },
   (中略)
}   

テストを実施する場合は以下コマンドを実行します。

$ npm run test

Jestは __tests__ディレクトリにあるファイルをテスト対象として読み込むのでclientディレクトリの下にtestsディレクトリを作成し、
その中にテストファイルを作成します。
テストコードがどのようになるかサンプルを書きます。まずは非Reactなコードです。

describe('日時フォーマット', () => {
  test ('yyyy-MM-ddTHH:mm:ss形式の日時がyyyy/MM/dd HH:mmに変換されること', () => {
    expect(TestUtils.dateTimeFormat("2017-01-31T23:59:59:000")).toBe("2017/01/31 23:59");
  });
});

次にReactComponentのテストコードです。

import React from 'react';
import ReactDOM from 'react-dom';
import { shallow } from 'enzyme';
import SampleForm from '../src/SampleForm';

describe('<CampaignForm />', () => {
  test('authorityがadministratorの場合のvalueが「承認する」であること', () => {
    const authority = "administrator";
    const wrapper = mount(<SampleForm authority={authority} />);

    const actual = wrapper.find('.btn').props().value;
    expect(actual).toBe('承認する');
  });
});  

mountでReactComponentがレンダリングされるので出力結果をアサーションすることができます。
上のサンプルでは const actual = wrapper.find('.btn').props().value; で出力結果のボタンの値を取得し、期待値通りか検証しています。

また、Enzymeを使うと、イベントが正しく発火していることの検証もできます。 以下では、jest.fn() でスパイを生成して、btn.simulate('click') でクリックイベントをシミュレートさせ、渡されてくる値が期待値通りかを検証しています。

const clickEvent = jest.fn();
const wrapper = mount(<SampleForm onClick={onClick} />);
const btn = wrapper.find('.btn');
btn.simulate('click');
expected(clickEvent).toBeCalledWith("ボタンクリックで渡される値");

デプロイ

デプロイの実行順序ですが、assets:precompile が実行される前にnpmライブラリのインストールとwebpackのビルドを行う必要があります。
capistranoでデプロイする場合は、以下のスクリプトを足しました。

namespace :npm do
  task :install do
    on roles(:app) do
      execute "cd '#{release_path}/client'; npm install"
    end
  end
  before 'deploy:updated', 'npm:install'

  task :webpack do
    on roles(:app) do
      execute "cd '#{release_path}'; npm --prefix client run webpack-build-production"
    end
  end
  after 'npm:install', 'npm:webpack'
end

Ansibleやその他のツールでデプロイする場合も同様です。assets:precompileの前にnpmライブラリのインストール、webpackのビルドを行います。

その他

ES2015

導入手順とは関係ないですが、ES2015になって非常にJavaScriptが書きやすくなりました。
ES2015の代表的な機能を紹介します。

モジュール管理

個人的にES2015で一番嬉しかったのが、モジュール機能です。
今まではモジュール管理の仕組みが言語に用意されていなかったのですが、ES2015で追加されました。
クラス定義側で以下のようにモジュール定義し、

// SampleClass.js
export default class SampleClass {
...
}

読み込む側でimportします。

// main.js
import SampleClass from 'SampleClass';

const obj = new SampleClass();

複数のモジュールをexportするときは以下のように記述します。

// Utils.js
export class SampleClass {
...
}

export function SampleFunc() {
...
}

読み込む側で名前を指定します。

import { SampleClass, SampleFunc } from 'Utils';

アロー関数

functionの省略記法が導入されました。楽です。

[0,1,2,3,4].filter(n => n % 2 == 0).forEach(n => console.log(n))

クラス定義

クラス構文が正式にサポートされました。

class SampleClass {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  
  func1() {
    consolo.log("instance method");
  }

  static func2() {
    console.log("static method");
  }
}

テンプレートリテラル

バッククォートで文字列を囲み、変数を${}で囲むことで、変数が評価されます。

const language = "JavaScript";
console.log(`Hello ${language}!`);

他にもPromise, Generator, 変数の分割代入など便利な機能が多く追加されています。
以下のページに詳細なサンプルがありますので参考にしてください。

Learn ES2015

まとめ

今回すでに運用中のRailsアプリケーションに後からフロントエンドのビルド周りの仕組みやライブラリを導入してみたのですが、 webpack, Babelなどのツールが非常に成熟しているため、最初に想像していたより簡単でした。
JavaScriptのビルドツールも以前は新旧交代の激しい印象でしたが、ここ一年間くらいは急激な変化がなくある程度ベストなツールが固まったのかなと思います。
複雑なUIを持つ画面の場合、フロント側に責務を寄せるのはサーバサイドでロジックを書くのに比べ保守性の面で非常に有利です。また、フロントエンドの進化の恩恵も享受することができます。
私のプロジェクトではこれからも積極的にRailsでReactを使っていくつもりです。

(今年出てくる予定のRails5.1でもwebpackが導入されるようです Drop jQuery as a dependency )

参考情報

一人React.js Advent Calendar 2014
非SPAなサービスにReactを導入する
ES6版React.jsチュートリアル
非SPAなサービスにReactを導入する

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.