読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

RailsでReactを使ってメッセージボックスアプリを作成

Javascript React.js

f:id:nipe880324:20151122001321p:plain:w420

react-railsというReact.jsをRailsに簡単に統合できるgemを使い、React.jsについて説明します。

次のような画面をReact.jsで実装し、Reactとサーバ(Rails)間でメッセージ一覧の取得や作成をできるようにします。
ソースコードはこちらです。 https://github.com/nipe0324/rails_samples/tree/master/react_test

f:id:nipe880324:20151122000620p:plain:w420

動作確認


0. React.jsとは

React.jsはMVCフレームワークでいう、ビューのみを扱っています。

Backbone.jsやAngularJS、React.jsなど触ってきた私の個人的な感想として、

  • React.jsは、状態をルートの一箇所(※1)で管理し、その状態を更新すれば、自動的に全てのコンポーネントをレンダーしてくれます。
  • 状態を一箇所で管理できるので、レンダーの細かな制御が必要なくなるので可読性や保守性は高いと思います。また、React.jsが差分だけレンダーするのでパフォーマンスも悪くはなりにくいです。

※1: 基本的には状態をルートの1箇所で管理するとよいと思いますが、ルートのコンポーネントがFatになってしまうので大規模化するとつらくなりそうです。Fluxで解決できるかもですが、Flux実装が乱立しているのでどうしようか迷っています。Flux実装で最近よく聞くReduxを軽く触ってみようかと思います。

  • Backbone.jsの場合、モデルやコレクションの更新やクリックイベントなどをトリガーとして、細かく分けたビューを更新します。ビューが大きくなり大規模化していくと、異なるビュー同士でイベントが絡み合うので、状態が増えて、つらくなってくる気がします。
  • AngularJSの場合、個人的に嫌いじゃないですが、ng-xxxというディレクティブがたくさん必要になり覚えることがたくさんあるのでつらいかなと思います。また、Angularに処理がラッピングされているので、バグを踏んだ時に対応するのがなかなか難しい気がします。

1. react-railsのインストール

react-railsRailsでReact.jsとJSXを簡易に使えるようにするgemです。

プロジェクトを作成します

rails new react_test
cd ./react_test

react-railsをGemfileに追加します。

# Gemfile
+ gem 'react-rails', '~> 1.4.0'

gemをインストールします。

bundle install

applicaiton.jsreactを追加し、RailsがReact.jsを読み込むようにします。

// app/assets/javascripts/application.js

 //= require jquery_ujs
-  //= require turbolinks
+  //= require react
+  //= require react_ujs
 //= require_tree .

React.jsの環境値を設定します。

# config/environments/development.rb
Rails.application.configure do
...
+ # Reactの環境値を development にする
+ config.react.variant = :development
end

# config/environments/production.rb
Rails.application.configure do
...
+ # Reactの環境値を production にする
+ config.react.variant = :production
end

補足
今回は入れないですが、ReactにはAddonと言うものが付いていて、Addonも使いたい場合は、下記の設定をします。

# config/environments/(development|test|production).rb
Rails.application.configure do
...
  # React.jsのAddonを有効にする(デフォルトはfalse)
  config.react.addons = true
end

TopController#indxを作成します。

rails g controller top index

ルートのパス(/)にtop#indexを設定しておきます。

# config/routes.rb
+ root 'top#index'

http://localhost:3000にアクセスし、トップJavascriptコンソールにエラーがでないことを確認します。



2. Reactのコンポーネントの表示

まずはReactでHello react with railsを表示しています。

// app/assets/javascripts/main.js.jsx

+ $(function() {
+    ReactDOM.render(
+      <h1>Hello react with rails</h1>,
+      document.getElementById('content')
+    );
+ });

ReactDOM.render(, )でReactのコンポーネント群をレンダー開始します。
※少し前まではReact.renderでした。

Hello react with rails

がReactのコンポーネントとして認識されて表示されます。
他にも、divやspanなど基本的なHTML要素は利用できます。

<!-- app/views/top/index.html.erb -->
+ <h1>Message Box</h1>
+ 
+ <div id="content"></div>


画面を再表示すると次のように「Hello react with rails」とReactにより表示されます。
f:id:nipe880324:20151121235843p:plain:w420



3. メッセージボックスの概要

ReactはVirtual DOMツリーを使って差分のみのHTMLを更新します。
ルートノードがstate(変更可能)を持っていて、必要な子ノードにstate(変更不可)を渡します。
子は親から渡された値をprops(変更不可)として受け取り、それを使ってHTMLをレンダーします。
各子ノードはイベントが発生したら親までイベントを伝え、親のstate(変更可能)をsetStateメソッドで更新します。
すると、親ノードから全てのノードが再度更新されます。この時、Virtual DOMツリーがあるためHTMLを差分だけ更新します。


次のようなツリー構造を作っていきます。

MessageBox
L MessageList
|  L MessageItem
L MessageForm


4. メッセージボックスのリストを作成

では、ここからメッセージボックスを作成していきます。
まずは、Reactのコンポーネントを配置するディレクトリを作成します。

mkdir app/assets/javascripts/components


MessageBoxコンポーネントの作成

MessageBoxコンポーネントを作成します。

// app/assets/javascripts/components/message_box.js.jsx
+ var MessageBox = React.createClass({
+   render: function() {
+     return (
+       <div className="messageBox">
+         This is message box.
+       </div>
+     );
+   }
+ });

React.createClass()コンポーネントを作成します。
また、コンポーネントが表示するHTMLをrenderの関数で返します。
classが予約されているので、HTMLのclass属性の指定はclassNameを使います。

main.js.jsxからMessageBoxコンポーネントを呼び出すように修正します。

// app/assets/javascripts/main.js.jsx
 $(function() {
   ReactDOM.render(
*    <MessageBox />,
     document.getElementById('content')
   );
 });

画面を確認すると次のようになります
f:id:nipe880324:20151122000052p:plain:w420


ReactDOM.renderレンダリングしているHTMLは次のようになっています。
MessageBoxコンポーネントrenderのHTMLが表示されていることがわかると思います。

<div id="content">
  <div class="messageBox" data-reactid=".0">
    This is message box.
  </div>
</div>

data-reactidは、Reactが各DOMを管理するために自動的に付与しているデータです。


Reactのrenderメソッドの実装時にはまりやすいポイント

renderで返すトップのHTML要素は1つでないといけません。
次のように、div要素を2つ返そうとするとエラーになります。

var MessageBox = React.createClass({
  render: function() {
    // 2つのdiv要素を返しているのでエラーになる
    return (
      <div className="messageBox">
        This is message box.
      </div>
      <div className="messageBox">
        This is message box.
      </div>
    );
  }
});


また、inputやimageなどの要素でも必ず閉じタグが必要です。

var MessageBox = React.createClass({
  render: function() {
    return (
      <div className="messageBox">
        <image src="/path/to/file"/> {/* 正しい */}
        <image src="/path/to/file">  {/* エラー */}
      </div>
    );
  }
});


MessageItemコンポーネントの作成

各メッセージを表示するMessageItemコンポーネントを作成します。
MessageBoxコンポーネントを作成します。

// app/assets/javascripts/components/message_item.js.jsx
+var MessageItem = React.createClass({
+  render: function() {
+    return (
+      <div className="message">
+        <h2 className="messageUser">{this.props.message.user}</h2>
+        <span>{this.props.message.text}</span>
+      </div>
+    );
+  }
+});

this.propsには、親コンポーネントから渡された値が入ります。
また、{変数名}で変数の値を表示することができます。

ここでは、親からmessage = { user: 'username', text: 'text' }といったような値が渡されて、{}を使ってそれぞれユーザ名とメッセージのテキストを表示しています。


message_box.js.jsxでMessageItemコンポーネントを作成し、returnで返すようにします。

// message_box.js.jsx
 var MessageBox = React.createClass({
   render: function() {
+   var messageItems = this.state.messages.map(function(message) {
+      return (
+        <MessageItem key={message.id} message={message}/>
+      );
+    });

     return (
       <div className="messageBox">
+        {messageItems}
       </div>
     );
   }
 });

this.stateは変更可能な値で名前からわかる通りアプリの状態を保持します。
基本的にはルートのノード(MessageBox)だけでstateを管理すると、いろいろなコンポーネントに状態が散らばらないので分かり易い、保守し易いJSコードになります。
そして、this.setStateメソッドを通してstateを更新することでルート以下のコンポーネントrenderが呼ばれ、stateの状態を表せます。この時、Reactが差分だけ更新するのでパフォーマンスをあまり気にしなくてもrenderが行えるようになっています。


最終的にはサーバーからデータを取得しますがまずは仮で、初期データを用意します。
getInitialStateコンポーネントが作成された時に1度だけ呼ばれ、stateの初期値を返すように実装します。

// message_box.js.jsx
 var MessageBox = React.createClass({
+  getInitialState: function() {
+    return {
+      messages: [
+        { id: 1, user: 'Tom',   text: 'Good morning' },
+        { id: 2, user: 'John',  text: 'Good afternoon' },
+        { id: 3, user: 'Emily', text: 'Good evening' }
+      ]
+    };
+  },

   render: function() {
     ...


では、画面をリロードします。
f:id:nipe880324:20151122000154p:plain:w420


ちなみにHTMLは次のようになっています。

<div id="content">
  <div class="messageBox" data-reactid=".0">
    <div class="message" data-reactid=".0.$1">
      <h2 class="messageUser" data-reactid=".0.$1.0">Tom</h2>
      <span data-reactid=".0.$1.1">Good morning</span>
    </div>
    <div class="message" data-reactid=".0.$2">
      <h2 class="messageUser" data-reactid=".0.$2.0">John</h2>
      <span data-reactid=".0.$2.1">Good afternoon</span>
    </div>
    <div class="message" data-reactid=".0.$3">
      <h2 class="messageUser" data-reactid=".0.$3.0">Emily</h2>
      <span data-reactid=".0.$3.1">Good evening</span>
    </div>
  </div>
</div>


5. メッセージフォームを作成

メッセージを入力するフォームのMessageFormコンポーネントを作成し、メッセージを投稿できるようにします。

MessageFormコンポーネントの作成

まずはイベントなどないシンプルなMessageFormコンポーネントを作成します。

// app/assets/javascripts/components/message_form.js.jsx
+ var MessageForm = React.createClass({
+   render: function() {
+     return (
+       <form className="commentForm">
+         <input type="text" placeholder="Yousr name" />
+         <input type="text" placeholder="Message" />
+         <input type="submit" value="Post" />
+       </form>
+     );
+   }
+ });

MessageBoxからMessageFormをレンダーするように修正します。

// message_box.js.jsx
var MessageBox = React.createClass({
  getInitialState: function() {
    ...
  },

  render: function() {
    var messageItems = this.state.messages.map(function(message) {
      return (
        <MessageItem key={message.id} message={message}/>
      );
    });

    return (
      <div className="messageBox">
        {messageItems}
+       <MessageForm />
      </div>
    );
  }
});

画面を確認するとFormが表示されます。
f:id:nipe880324:20151122000252p:plain:w420



Submitイベントのハンドリング

Postボタンを押した時に、動的にMessageItemを追加するように修正します。

まずは、MessageFormにイベントのハンドリングを行うhandleSubmitを追加します。

  var MessageForm = React.createClass({
+   handleSubmit: function(event) {
+     event.preventDefault();
+     var user = this.refs.user.value.trim();
+     var text = this.refs.text.value.trim();
+     // どちらか入力されてなければ何もしない
+     if (!user || !text) {
+       return;
+     }
+     // 親コンポーネントのMessageBoxのイベントを呼ぶ
+     this.props.onMessageSubmit({ user: user, text: text });
+     // フォームの内容を削除
+     this.refs.user.value = '';
+     this.refs.text.value = '';
+   },

    render: function() {
      return (
*       <form className="commentForm" onSubmit={this.handleSubmit}>
*         <input type="text" placeholder="Yousr name" ref="user" />
*         <input type="text" placeholder="Message" ref="text"/>
          <input type="submit" value="Post" />
        </form>
      );
    }
  });

まずonSubmit={this.handleSubmit}でSubmitイベントが発生したら、handleSubmitが呼ばれるように定義しています。

Postボタンを押すとリストに入力内容が追加されます。
f:id:nipe880324:20151122000403p:plain:w420


keyについて

Postボタンでもう一つメッセージを追加するとJavascriptコンソールに次のようなメッセージが表示されます。

react.self-bf407d87....js?body=1:2166
Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of `MessageBox`. See https://fb.me/react-warning-keys for more information.

和訳すると、
> 配列やイテレータのそれぞれの子要素はユニークな"key"属性をつけてください。
> "MessageBox"のrenderメソッドのところが怪しいです。
> より詳細を知りたい場合は、https://fb.me/react-warning-keys を読んでください。
です。

なぜ、Reactで複数の同じ子要素(liやReactのコンポーネントなど)を表示するときにkey属性を指定しないといけないかは、
Reactのstateが変わったときにどの要素を更新すればいいか識別するために使われています。key属性がなくてもうまく更新ができますが、Reactがいい感じにしないといけず計算量が多くなってしまいパフォーマンスが悪くなってしまう基本的にはkey属性をつけます。

今回は、MessageItemがリスト要素になっているので、key属性に値を設定します。
すでに設定していますが、新たにフォームから追加したメッセージはidがないので、handleMessageSubmitないでユニークなidを生成するようにします。
※後ほどサーバーから取得したメッセージを取得するようにしますが、今はidを生成する仕組みがないので暫定で日付を設定するようにします。

// message_box.js.jsx
  var MessageBox = React.createClass({
    getInitialState: function() {
      ...
    },

    handleMessageSubmit: function(message) {
+     message.id = new Date();
      var newMessages = this.state.messages.concat(message);
      this.setState({ messages: newMessages });
    },

    render: function() {
      var messageItems = this.state.messages.map(function(message) {
        return (
          <MessageItem key={message.id} message={message}/>
        );
      });

      return (
        <div className="messageBox">
          {messageItems}
          <MessageForm onMessageSubmit={this.handleMessageSubmit}/>
        </div>
      );
    }
  });

これでワーニングが出なくなりました。


6. サーバーサイド(Rails)との連携

RailsAPIを作成

APIのエンドポイントとして下記2つを用意します。

GET  /messages.json - メッセージの一覧を取得する
POST /messages.json - メッセージを作成する


まず、コントローラ、モデルを作成します。

rails g resource Message user:string text:string

railg g resourceは、モデル、マイグレーションファイル、コントローラ、アセット、ヘルパーを作成(ビューを作成しない)


ルートをエンドポイントの2つだけにします。

  # config/routes.rb

  Rails.application.routes.draw do
*   resources :messages, only: [:index, :create], format: 'json'
    root 'top#index'
  end


MessageControllerでindexcreateメソッドを実装します。

  # app/controllers/messages_controller.rb
+ class MessagesController < ApplicationController
+   def index
+     messages = Message.all
+     render json: messages
+   end
+
+   def create
+     message = Message.new(create_params)
+     if message.save
+       render json: message, status: :created # 201
+     else
+       render json: message, status: :unprocessable_entity
+     end
+   end
+
+   private
+
+     def create_params
+       params.permit(:user, :text)
+     end
+ end


初期データを作成し、投入しておきます。

# db/seeds.rb
Message.delete_all
Message.create!([
  { user: 'Tom',   text: 'Good morning' },
  { user: 'John',  text: 'Good afternoon' },
  { user: 'Emily', text: 'Good evening' }
])


# terminal
bundle exec rake db:migrate db:seed


サーバー再起動して、http://localhost:3000/messages.jsonにアクセスすると次のように値が返ってくると思います。

[{"id":1,"user":"Tom","text":"Good morning","created_at":"2015-11-19T04:02:27.956Z","updated_at":"2015-11-19T04:02:27.956Z"},{"id":2,"user":"John","text":"Good afternoon","created_at":"2015-11-19T04:02:27.958Z","updated_at":"2015-11-19T04:02:27.958Z"},{"id":3,"user":"Emily","text":"Good evening","created_at":"2015-11-19T04:02:27.962Z","updated_at":"2015-11-19T04:02:27.962Z"}]


Reactでサーバからメッセージ一覧を取得

メッセージの一覧を取得するエンドポイントは、GET /messages.jsonなので、Ajaxでサーバからメッセージを取得して、それをReactで表示するようにしてみます。

まずは、MessageBoxにAPIへのエンドポイントとなるurl属性を渡します。

// app/assets/javascripts/main.js.jsx
  $(function() {
    ReactDOM.render(
*     <MessageBox url="/messages"/>,
      document.getElementById('content')
    );
  });

次にgetInitialStateの仮で用意していた初期値を空の配列にします。
また、componentDidMountを定義し、その中でサーバーからメッセージ一覧を取得し、setState({ messages: messages })でstateにメッセージを設定します。

componentDidMountは、コンポーネントが表示された時にReactによって自動的に呼ばれるメソッドです。

// app/assets/javascripts/components/message_box.js.jsx
  var MessageBox = React.createClass({
    getInitialState: function() {
*     return { messages: [] };
    },

+   componentDidMount: function() {
+     $.ajax({
+       url:      this.props.url,
+       dataType: 'json',
+       cache:    false,
+       success: function(messages) {
+         this.setState({ messages: messages });
+       }.bind(this),
+       eror: function(_xhr, status, err) {
+         console.error(this.props.url, status, err.toString());
+       }.bind(this)
+     });
+   },

    ...
  });

画面を確認するとメッセージ一覧が表示されます。
f:id:nipe880324:20151122000516p:plain:w420


この時若干画面がバタつくのは、次のようになっているからです。

  1. getInitialStateでmessagesに空の配列がセットされる。
  2. ReactがMessageBoxを表示する。この時、メッセージがないので一覧は表示されない。
  3. MessageBoxが表示されたので、componentDidMountが呼ばれ、サーバからメッセージを取得し、messagesにメッセージをセットする。
  4. setStateでmessagesが更新されたので、Reactは差分をレンダーする。この時、メッセージがあるので一覧は表示される。

このように、一度なにも表示しないで、その後、サーバーからメッセージを取得して、一覧を表示するので若干バタつくようになっています。


ロード中と表示

ロード中に「ロード中」と表示するようにします。うまくスタイリングすれば、「getInitialState〜サーバーからのデータ取得」をまでの間のバタつくような表示を解消できます。
具体的には、次のようにロード中と一瞬だけ出てから一覧が表示されるようにします。
f:id:nipe880324:20151122000634p:plain:w420


MessageBoxを修正し、isLoadingというステータスをもたせて、ロードが完了した時点でロードが完了したと設定しています。
また、そのisLoadingの値を見て、レンダーする表示を変えています。

// app/assets/javascripts/components/message_box.js.jsx
var MessageBox = React.createClass({
  getInitialState: function() {
    // isLoading = true : ロード中を表示
*   return { messages:  [], isLoading: true };
  },

  componentDidMount: function() {
    $.ajax({
      url:      this.props.url,
      dataType: 'json',
      cache:    false,
      success: function(messages) {
         // isLoading = false : ロード中を表示しない
*        this.setState({ messages: messages, isLoading: false });
      }.bind(this),
      eror: function(_xhr, status, err) {
        console.error(this.props.url, status, err.toString());
      }.bind(this)
    });
  },

  handleMessageSubmit: function(message) {
    ...
  },

  render: function() {
    var messageItems = this.state.messages.map(function(message) {
      return (
        <MessageItem key={message.id} message={message}/>
      );
    });

    // isLoadingの値により表示するRenderする内容を変えている
+   if (this.state.isLoading) {
+     return (
+       <div>ロード中</div>
+     );
+   } else {
*     return (
*       <div>
*         <h1>Message Box</h1>
*         <div className="messageBox">
*           {messageItems}
*           <MessageForm onMessageSubmit={this.handleMessageSubmit}/>
*         </div>
*       </div>
*     );
+   }
  }
});

このローディング中という表示が嫌な場合は、、サーバー側でレンダリングするデータも含めて返すようにする「サーバーレンダリング」という方法があります。そちらについては、次の記事で書きます。


ReactでサーバへメッセージをPOSTする

メッセージを作成するエンドポイントは、POST /messages.jsonなので、フォームのPostボタンが押された時にAjaxでサーバーにメッセージ情報をPOSTするようにします。

MessageBoxのhandleSubmitでサーバー側にフォームに入力されたメッセージ情報をPOSTするようにします。

// app/assets/javascripts/components/message_box.js.jsx
  var MessageBox = React.createClass({
    ...

+   handleMessageSubmit: function(message) {
+     $.ajax({
+       url:      this.props.url,
+       dataType: 'json',
+       type:     'POST',
+       data:     message,
+       success: function(message) {
*         var newMessages = this.state.messages.concat(message);
          this.setState({ messages: newMessages });
+       }.bind(this),
+       error: function(_xhr, status, err) {
+         console.error(this.props.url, status, err.toString());
+       }.bind(this)
+     });
+   },

    ...
  }

確認

投稿できるようになります。
f:id:nipe880324:20151122000620p:plain:w420


以上です。

サーバーサイドでReact.jsをレンダリングする「サーバーレンダリング」について記載しましたので、ロード中による画面のばたつきや、ロード中といった表示をなくしたい場合は参考にしてください。
RailsでReact.jsをサーバーレンダリングする - Rails Webook

参考文献