flux
RubyOnRails
react.js

React, Flux, Railsを用いて作るチャットアプリ

React, Flux, Railsでチャットアプリを作成したのでその際学んだことを記していきます。

LINEをイメージして作ったアプリですが、今回はメッセージを打ち込んで送信ボタンを押し、そのメッセージが画面に新たに表示されるところをピックアップして見ていきます。相手ユーザーなども作りません。

まだ初学者のため、用語の違和感やコードがぐちゃぐちゃとかは目をつぶっていただきたいです。
また、ここではReactやFluxたちがどういうものかという説明は省きます。

今後の改良に向けて、それから見てくださった方にご迷惑をおかけしないよう、間違っている点などありましたらご指摘いただけますと幸いです。

データの流れ

今回はただメッセージを送るだけなので、回していくデータはメッセージのcontentだけです(contentはメッセージテーブルにあるカラムで、メッセージの文言を指します)。
例として、ユーザーが ”ご飯にしよう” というcontentのメッセージを送ったとします。

ユーザーが ”ご飯にしよう” を作成し、送信する: components/messagesBox.js

”ご飯にしよう” をRails側に連れていき、処理されて返って来たデータをJSONとしてdispatcherへ渡す: actions/messages.js

JSONをstoreへと送る: dispatcher.js

過去のメッセージログの配列の最後にJSONを加え、componentへ渡す: stores/messages.js

storeから受け取ったメッセージのログを画面に表示させる。最新のメッセージに ”ご飯にしよう” が表示されているようになる: components/messagesBox.js

ビューの処理

先の流れで便宜上書かなかったのですが、実は、messagesBoxの中には、replyBoxというコンポーネントが取り込まれています。

そのため、実際の処理としては、storeから受け取ったメッセージのログを表示させるのはmessagesBoxの仕事。
”ご飯にしよう” というテキストを書き込み、エンターキーで送信できるようにしてくれるのはreplyBoxの仕事です。

components/messagesBox.js
import React from 'react'
import classNames from 'classNames'
import ReplyBox from './replyBox'

class MessagesBox extends React.Component {

constructor(props) {
    super(props)
    this.state = this.initialState
    this.onChangeHandler = this.onStoreChange.bind(this)
  }

  get initialState() {
    return this.getStateFromStores()
  }

getStateFromStore() {
    return {
      messages: MessagesStore.getMessages(),
    }
  }

  componentDidMount() {
    MessagesStore.onChange(this.onChangeHandler)
  }

  componentWillUnmount() {
    MessagesStore.offChange(this.onChangeHandler)
  }

  onStoreChange() {
    this.setState(this.getStateFromStore())
  }

  render() {
    const messages = this.state.messages.map(message => {
      const messageClasses = classNames({
        'message-box__item': true,
        'message-box__item--from-current': message.user_id === currentUserId,
        'clear': true,
      })
      return (
        <li key={message.id} className={messageClasses}>
          <div className='message-box__item__contents'>
            {message.content}
          </div>
        </li>
      )
    })
    return (
      <div className='message-box'>
        <ul className='message-box__list'>
          {messages}
        </ul>
        <ReplyBox />
      </div>
    )
  }
}
export default MessagesBox
components/replyBox.js
import React from 'react'
import MessagesAction from '../../actions/messages'

class ReplyBox extends React.Component {

  constructor(props) {
    super(props)
    this.state = {value: ''}
  }

  handleKeyDown(e) {
    const value = this.state.value
    if (e.keyCode === 13 && value !== '') {
      MessagesAction.saveMessage(value)
            this.setState({
        value: '',
      })
    }

  updateValue(e) {
    this.setState({
      value: e.target.value,
    })
  }

  render() {
    return (
      <div className='reply-box'>
        <input
          value=this.state.value
          onKeyDown={ this.handleKeyDown.bind(this) }
                    onChange={ this.updateValue.bind(this) }
          className='reply-box__input'
        />
        <span className='reply-box__tip'>
          Press <span className='reply-box__tip__button'>Enter</span> to send
        </span>
      </div>
    )
  }
}
export default ReplyBox

それぞれのdivタグがどんな役割を持つかわかりやすいように、cssも残しておきました。

ここでの動きですが、まず、replyBoxのinputタグでテキストを打つフィールドができ、そこに ”ご飯にしよう” を打ってenterキーを押すと、先にhandleKeyDownでsetStateされ、valueの初期値を ' ' にセットします。それからupdateValueが呼ばれることで、e.target.value、すなわち ”ご飯にしよう” がvalueにsetStateされ、となっていたvalueのstateが ”ご飯にしよう” に更新され、それからhandleKeyDownメソッドでMessagesActionに送られます。

これらのメソッドがなぜこの順番で呼ばれるかは、こちらの記事をご参照ください。
Reactのキーボードイベントをクロスブラウザで確認する

そしてそのデータが巡り巡って、最終的にmessagesBoxで表示されるようになりますがそれは最後にまた取り上げます。

ちなみに今のこのcssの状態だと、自分自身が送信したメッセージはLINEと同じように右側に配置されるはずです。
ここにはまた戻って来ます。

アクションの処理

アクションでは、dispatcherとRailsにデータを送るタスクが与えられています。

通信手段としてsuperagent, 非同期通信にするためpromiseを使用していますが、なんとなくRailsとやり取りをしているんだなくらいに今は思ってください。

ではアクションの中身を見ていきますが、先に、スクロールの手間を少し省くため先に、app.jsを貼ります。アクションでインポートされるのですが、なんとなく定数管理をしているんだなと感じてください。

constants/app.js
import keyMirror from 'keymirror'

export const ActionTypes = keyMirror({
  GET_MESSAGES: null,
  SAVE_MESSAGE: null,
})

export function CSRFToken() {
  return document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}

const Root = window.location.origin || `${window.location.protocol}//${window.location.hostname}`
const APIRoot = `${Root}/api`
export const APIEndpoints = {
  MESSAGES: APIRoot + '/messages',
}

Railsではnamespace :api を作るので、api/messagesというurlからデータのあれこれをやっていきます。そのためAPIRootが定数管理に必要になるのです。

次に、アクションを見ていきます。

actions/messages.js
import request from 'superagent'
import Dispatcher from '../dispatcher'
import {ActionTypes, APIEndpoints, CSRFToken} from '../constants/app'

export default {
  getMessages() {
    return new Promise((resolve, reject) => {
      request
      .get(APIEndpoints.MESSAGES)
      .end((error, res) => {
        if (!error && res.status === 200) {
          const json = JSON.parse(res.text)
          Dispatcher.handleServerAction({
            type: ActionTypes.GET_MESSAGES,
            json,
          })
          resolve(json)
        } else {
          reject(res)
        }
      })
    })
  },

  saveMessage(content) {
    return new Promise((resolve, reject) => {
      request
      .post(APIEndpoints.MESSAGES)
      .set('X-CSRF-Token', CSRFToken())
      .send({content})
      .end((error, res) => {
        if (!error && res.status === 200) {
          const json = JSON.parse(res.text)
          Dispatcher.handleServerAction({
            type: ActionTypes.SAVE_MESSAGE,
            json,
          })
        } else {
          reject(res)
        }
      })
    })
  },
}

さて、”ご飯にしよう” は、ビューで指定した通りにsaveMessageに送られます。
それを順に辿ると、まずは、 APIEndpoints.MESSAGES にpostメソッドがリクエストされ、そレから、sendメソッドで ”ご飯にしよう” が送られます。

ここではRails側の処理は書きませんが、Railsではparams[:content]などを使ってcreateしていきます。

X-CSRF-Tokenは、postメソッドでリクエストする際の安全対策です。

それから、endメソッドですが、通信がうまくいった時、いかなかった時の場合分けをしています。

うまくいった時は、dispatcherに、変数jsonとしてRailsから受け取ったデータを渡していきます。saveMessageでは、入力した文字列、すなわち ”ご飯にしよう” がres.textとして返ってきて、それをJSON.parseでJSONとして認識し、変数jsonに入れるのです。

通信に失敗した時には、rejectされるようになっています。

getMessagesは、今回はデータベースから今までのメッセージを取り出すようにしています。messagesBoxで呼び出され、”ご飯にしよう” を送る以前のメッセージのやりとりが表示されます。

ディスパッチャーの処理

アクションやストアはメッセージアクションや、ユーザーストアのように様々な種類ができるのに対し、ディスパッチャーはただ一つ存在するだけです。数あるアクションを一つのディスパッチャーに集約し、そこから適切なストアへとデータを分配していきます。

しかし、今回のディスパッチャーの中身はとてもシンプルでした。

dispatcher.js
import {Dispatcher} from 'flux'
import assign from 'object-assign'

const appDispatcher = assign(new Dispatcher(), {
  handleServerAction(action) {
    this.dispatch({
      source: 'server',
      action: action,
    })
  },

  handleViewAction(action) {
    this.dispatch({
      source: 'view',
      action: action,
    })
  },
})

export default appDispatcher

でしたと僕自身述べているように、ここの理解があまり深くないので、書けるようになり次第ここを編集していきます。ご容赦を。。。

現在大事なのは、handleServerActionとhandleViewActionが存在していて、データを保存したり削除したりとサーバーに関わってくるのが前者、viewをgetするような際には後者を用いることです。

saveMessageではServerの方を指定していたので ”ご飯にしよう” はそちらに運ばれ、messagesStoreに繋げていきます。

ストアの処理

ディスパッチャーからメッセージストアへデータが渡され、いよいよ ”ご飯にしよう” の表示へ大詰めです。

stores/messages.js
import Dispatcher from '../dispatcher'
import BaseStore from '../base/store'
import {ActionTypes} from '../constants/app'

class MessagesStore extends BaseStore {

  getMessages() {
    if (!this.get('userMessages')) this.setMessages([])
    return this.get('userMessages')
  }
  setMessages(array) {
    this.set('userMessages', array)
  }
}

const messagesStore = new MessagesStore()

messagesStore.dispatchToken = Dispatcher.register(payload => {
  const action = payload.action

  switch (action.type) {
    case ActionTypes.GET_MESSAGES:
      messagesStore.setMessages(action.json)
      messagesStore.emitChange()
      break

    case ActionTypes.SAVE_MESSAGE:
      const messages = messagesStore.getMessages()
      messages.push(
        action.json.message
      )
      messagesStore.emitChange()
      break
  }
  return true
})

export default messagesStore

メッセージにまつわるデータがdispatcherからきちんとメッセージストアに届くのは、ActionTypesで定数管理を行い、そこから定数であるGET_MESSAGESなどを呼び出しているためです。

さて、まずはgetMessages、setMessagesのゲッター、セッターの関係ですが、ここでは、もしゲッターが何もデータを取得できなかった時、セッターには空の配列をセットしています(これはエラーが出ない為の配慮で、もしゲッターが配列ではなくユーザーのidをとるメソッドだった場合、セッターには初期値として0をいれておきます)。取得できた時には、そのデータを返します。
一方セッターは、第一引数に、あれば取得したデータを置き、それがなければ空の配列を引数に取ります。

そして、MessagesStoreクラスからnewメソッドでインスタンスを生成し、case文で場合分けをしながらそれぞれ処理をします。

”ご飯にしよう” は、アクションのsaveMessageメソッドで

type: ActionTypes.SAVE_MESSAGE

 とされていたので、getMessagesにpushされて、晴れてメッセージの仲間入りです。

getMessagesの方は、APIEndpoints.MESSAGES からメッセージのindexを受け取り、それをJSONとして渡しています。

そしてemitChangeでビューのonChangeに変更があったことを伝えます。それからビューはsetStateしてコンポーネントに変更を加えます。

今再びのビュー

components/messagesBox.js
import React from 'react'
import classNames from 'classNames'
import ReplyBox from './replyBox'

class MessagesBox extends React.Component {

constructor(props) {
    super(props)
    this.state = this.initialState
    this.onChangeHandler = this.onStoreChange.bind(this)
  }

  get initialState() {
    return this.getStateFromStores()
  }

getStateFromStore() {
    return {
      messages: MessagesStore.getMessages(),
    }
  }

  componentDidMount() {
    MessagesStore.onChange(this.onChangeHandler)
  }

  componentWillUnmount() {
    MessagesStore.offChange(this.onChangeHandler)
  }

  onStoreChange() {
    this.setState(this.getStateFromStore())
  }

  render() {
    const messages = this.state.messages.map(message => {
      const messageClasses = classNames({
        'message-box__item': true,
        'message-box__item--from-current': message.user_id === currentUserId,
        'clear': true,
      })
      return (
        <li key={message.id} className={messageClasses}>
          <div className='message-box__item__contents'>
            {message.content}
          </div>
        </li>
      )
    })
    return (
      <div className='message-box'>
        <ul className='message-box__list'>
          {messages}
        </ul>
        <ReplyBox />
      </div>
    )
  }
}
export default MessagesBox

onChangeでビューが変更されることが伝わったので、最終的にgetStateFromStoreで、”ご飯にしよう” というメッセージが加わった配列を取得します。

そして、setStateでstateを更新し、renderしてあげることで、 ”ご飯にしよう” が画面に表示されました!