Firebase+React+Reduxで多機能チャットを実装しよう【リアルタイムチャット~React編~】


Firebase&React&Reduxで多機能チャットを実装しよう【リアルタイムチャット編】

今回から本格的にReactとReduxを触っていきます。
一気に難易度が上がるので覚悟してください!笑

ガイド

ディレクトリ構成

前回までのおさらい。
ディレクトリ構成は現在以下の通りです。

要らないファイルを削除して、ディレクトリを追加作成します。

Before
react-chat
 │- build/
 │- public/
 │   │- favicon.ico
 │   │- index.html
 │   │- logo192.png <-- REM
 │   │- logo512.png <-- REM
 │   │- manifest.json
 │   └  robots.txt
 │- src/
 │   │- firebase/
 │   │   │- config.js
 │   │   └  index.js
 │   └  index.js
 │- node_modules/
 │- .firebaserc
 │- database.rules.json
 │- firebase.json
 │- package.json
 │- package-lock.json
 └  storage.rules.json
After
react-chat
 │- build/
 │- public/
 │   │- favicon.ico
 │   │- index.html
 │   │- manifest.json
 │   └  robots.txt
 │- src/
 │   │- components/ <-- ADD
 │   │- containers/ <-- ADD
 │   │- firebase/
 │   │   │- config.js
 │   │   └  index.js
 │   │- templates/ <-- ADD
 │   │- index.js 
 │   └  style.css <-- ADD
 │- node_modules/
 │- .firebaserc
 │- database.rules.json
 │- firebase.json
 │- package.json
 │- package-lock.json
 └  storage.rules.json

React基本ファイルの準備

create-react-appで作成した開発環境の基本を簡単に説明しておきます。

  • public:雛形となるhtmlファイルを格納するディレクトリ
  • src:コンパイル前のソースコードを格納するディレクトリ
  • npm run buildsrc配下のファイルをコンパイルするコマンド
  • build:コンパイルされたファイルを格納するディレクトリ

雛形となるHTMLファイル

雛形となるHTMLファイルには以下を記載します。

  • サイトのタイトルやメタ情報
  • Firebaseに必要なスクリプト
  • Reactコンポーネントをrenderするルートdiv
public/index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Firebase React Chat!</title>
    <!-- update the version number as needed -->
    <script defer src="/__/firebase/7.5.1/firebase-app.js"></script>
    <!-- include only the Firebase features as you need -->
    <script defer src="/__/firebase/7.5.1/firebase-auth.js"></script>
    <script defer src="/__/firebase/7.5.1/firebase-database.js"></script>
    <script defer src="/__/firebase/7.5.1/firebase-messaging.js"></script>
    <script defer src="/__/firebase/7.5.1/firebase-storage.js"></script>
    <!-- initialize the SDK after all desired features are loaded -->
    <script defer src="/__/firebase/init.js"></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

Reactコンポーネントをindex.jsファイル

npm run buildでコンパイルする際に参照されるjsファイルです。
細かい説明は省きますが、後ほど登場するファイルたちの情報をまとめておきます。

src/index.js
import React from 'react';
import * as ReactDOM from 'react-dom';
import {Provider} from 'react-redux';
import {RootContainer} from './containers';
import {configureStore} from './modules';
import './style.css'

ReactDOM.render(
    <Provider store={configureStore()}>
        <RootContainer />
    </Provider>,
    document.getElementById('root')
);

ReactDOM.render(element, container)で、Reactコンポーネント(element)を雛形HTMLのDOM(container)にレンダーして表示させています。

先ほど作成したHTMLファイル内の、idがrootのdiv要素にレンダーさせているということですね。

それでは、レンダーされるコンポーネントを作っていきましょう。

Reactコンポーネントの作成

ReactをReduxと共に用いる場合、コンポーネントは2つに分けるのが一般的です。

  1. Presentational Components --> 見た目(View)を担当する。「コンポーネント」と呼ばれる
  2. Container Components --> ロジック(振る舞い)に関わる。「コンテナー」と呼ばれる。

より詳細な定義はコチラの記事を参考にしてください。

上記を踏まえて、ディレクトリ構成で追加したディレクトリに格納するファイルの役割は以下の通りです。

  • components: 最小構成要素(パーツ)となるPresentational Components、子コンポーネントです。
  • templates: components内のパーツを組み合わせたPresentational Components、親コンポーネントです。
  • containers: templatesPresentational Componentsと"状態"を紐づけるための中継役、Container Componentsです。

ログインページを例にすると
componentsは「ボタン」や「テキストボックス」
templatesは「ボタン」や「テキストボックス」を組み合わせた「ログインフォーム」
containersはログイン処理後の見た目(View)がどのように変化するのか定義

少し難しいですよね。

「コンポーネントは見た目(View)のみを持ち、状態(State)と分離させる」というReduxの設計思想を実現するための構成だと思ってください。

それでは、実際にリアルタイムチャットを実装するためのファイルを書いていきます。

componentsファイルの作成

チャットを実装するためには以下のパーツが必要だと考えました。

  1. メッセージ表示エリア
  2. メッセージ入力エリア
  3. メッセージ送信ボタン

この記事では2と3について解説します。

当記事ではMaterial-UIを使っていますが、詳しい解説を割愛します。
デザインのカスタマイズには癖があるけど、それっぽいUIが簡単に作れて便利だゾ。

また、デモページを作った都合上、独自CSSを使っています。
CSSはGithubのソースコードから確認してください。(src/style.css)に記述しています。

src/components/Chat/TextInput.js
import React from 'react';
import TextField from '@material-ui/core/TextField';

const TextInput = (props) => {
    return (
        <form className="p-chat__textarea c-grid-center" noValidate autoComplete="off">
            <TextField
                id="standard-text"
                className="c-grid-full"
                margin="normal"
                label="メッセージを入力..."
                multiline
                rowsMax="4"
                onChange={e => props.onChange(e.target.value)}
                value={props.value}
            />
        </form>
    );
};

export default TextInput;
src/components/Chat/SendButton.js
import React from 'react';
import Button from '@material-ui/core/Button';
import SendIcon from '@material-ui/icons/Send';

const SendButton = (props) => {
    return (
        <Button
            variant="contained"
            color="primary"
            className="p-chat__button-send"
            onClick={() => props.onClick(props.value, props.roomId, props.fromId, props.toId, props.userIds)}>
          <SendIcon />
        </Button>
    );
}

export default SendButton;

それぞれのポイントを解説します。

両コンポーネントは、Stateless Functional Componentsという形式で宣言されています。

初期のReactでは、Classを用いてコンポーネントを宣言していました。
近年のReactでは、Stateless Functional Components、つまり"関数"として宣言することが推奨されています。

Stateless Functional Componentsには以下のメリットがあります。

  • constructorを使わなくて良い
  • thisが必要ない
  • 状態を持たない(stateless)コンポーネントにできる

つまり、コードがシンプルになってハッピーってことですね☆

また、ES6のアロー関数を使ってさらにシンプルに書いています。

そして重要なのが、関数の引数に渡しているpropsです。
この部分const TextInput = (props) => {です。
この部分const SendButton = (props) => {ですよ。

propsは親コンポーネントから渡された引数を一挙に受け取ります。
親コンポーネントからvalueとして渡した引数を、子コンポーネントでprops.valueのように参照することができます。

props.onClickprops.onChangeは、状態を変更するためのActionsを呼び出します。
Actionsについては[次の記事]で解説します。

templatesファイルの作成

templatesディレクトリ配下にChat.jsを作成します。
Chat.jsは親コンポーネントとして、先ほど作成したコンポーネントをまとめます。

ファイルが長いので部分ごとに解説します。

import

ファイル冒頭のimport文です。

src/templates/Chat.js
import React, {Component} from 'react';
import {Chat, Common} from '../components';
import {database} from '../firebase/index'

components/index.jsexportした"Chat"と"Common"のコンポーネントをimportします。

constructorとrender

src/templates/Chat.js
class ChatTemplate extends Component {
    constructor(props) {
        super(props);
    }

    /* 中略 */

    render() {
        return (
             <div className="p-chat">
                <Common.NavBar
                    value={this.props.messages}
                    actions={this.props.actions.messages}
                    back={this.props.actions.messages.backToRooms}
                    configure={this.props.actions.messages.configure}
                    signOut={this.props.actions.messages.signOut}
                />
                <div className="p-chat__area" id="scroll-area">
                    {this.props.messages.msgs.map((m, i) => (
                        <Chat.AlignItemsList key={i} msgs={m} />
                    ))}
                </div>
                <div className="c-grid__row">
                    <Chat.TextInput
                        onChange={this.props.actions.messages.change}
                        value={this.props.messages.value}
                    />
                    <Chat.SendButton
                        onClick={this.props.actions.messages.submit}
                        value={this.props.messages.value}
                        roomId={this.props.messages.roomId}
                        fromId={this.props.messages.userId}
                        toId={this.props.messages.partnerId}
                        userIds={this.props.messages.userIds}
                    />
                </div>
            </div>
        );
    }
}

export default ChatTemplate

親コンポーネントではcomponentDidMount()などのライフサイクルを使いたいので、Stateless Functional ComponentsではなくClass Componentsで宣言します。

constructorの宣言をします。
this.propsでreduxのStoreに保存されているGlobal Stateを参照できます。
(ごめんなさい、Storeについても[次の記事]で解説します...!)

src/templates/Chat.js
constructor(props) {
    super(props);
}

続いて、render部分です。
return()のなかにDOMやコンポーネントを記述します。

render() {
    return (
        /*
            レンダーするDOMを記述する。
            作成した子コンポーネントもこの中で宣言して使う。
        */
    )
}

"Chat"としてimportした子コンポーネントを呼び出す方法は以下の通りです。
例...TextInputコンポーネントを呼び出す。

return(
    <Chat.TextInput />
)

さらに、引数を渡してみましょう。

src/templates/Chat.js
return(
    <Chat.TextInput
        onChange={this.props.actions.messages.change}
        value={this.props.messages.value}
    />
)

少し分かりづらいかもしれませんが

  • this.props.actions.messages.changeというActionsonChangeとしてTextInputコンポーネントに渡している
  • this.props.messages.valueというGlobal StateをvalueとしてTextInputコンポーネントに渡している

これによって、TextInputコンポーネントではprops.onChangeprops.valueとして渡された値を参照できる。

ライフサイクルメソッド

ReactのClass Componentsではライフサイクルメソッドが使えます。
ライフサイクルメソッドが分かりやすく図解されている記事はコチラ

代表的なメソッドは以下。

  • componentWillMount() --> 現在は非推奨なので使わない。
  • render() --> Viewを描画する。
  • componentDidMount() --> API連携など、通信が必要な処理はここで。
  • componentDidUpdate() --> stateが変更されて再render()が走った後に
  • componentWillUnmount() --> componentDidMount()で確保したリソースを解放する

src/templates/Chat.jsでは各メソッドで以下の処理を実行している。

  • componentDidMount() --> Firebase DBにメッセージのデータが追加されたらViewを再描画するようにリスナーを仕掛ける。
  • componentDidUpdate() --> メッセージ一覧の最下部にスクロールする。
  • componentWillUnmount() --> Firebase DBへのリスナーを解除する。

ソースコード載せると長〜いので、Githubを確認してください🙏

リスナーの設定はfirebase.database().ref().on()で。
逆にリスナーの解除はfirebase.database().ref().off()です。

リスナー設定/解除の詳細はFirebaseクライアントSDKのドキュメントを参照。
データの取得|Firebase Realtime Database

containersファイルの作成

最後にContainer Componentsを作成します。

src/containers/Chat/Main.js
import ChatTemplate from '../../templates/Chat';
import {bindActionCreators, compose} from 'redux';
import {connect} from 'react-redux';
import {actions} from '../../modules/chat/index';

const mapStateToProps = state => {
  return {
    messages: state.messages,
  };
};

const mapDispatchToProps = dispatch => {
  return {
    actions: {
      messages: bindActionCreators(actions.messages, dispatch),
    },
  };
};

export default compose(
  connect(
    mapStateToProps,
    mapDispatchToProps
  )
)(ChatTemplate);

すでに説明した通り、Container Componentsの役割は中継役です。

ReactとReduxを繋げるreact-reduxというライブラリのconnectメソッドを使います。

connectメソッドは、以下2つをReactコンポーネントで参照できるようにしているイメージです。

  • mapStateToProps: Reduxで管理しているstate(状態)
  • mapDispatchToProps: state(状態)を変更するためのReduxのActions

上記のContainer Componentsの書き方は汎用的に使えるはずです。

まとめ

Reactだけならまだしも、Reduxが絡むと途端にややこしくなります。

特にContainer Componentsconnectするあたりは、最初何をやっているのか全く理解できませんでした。

分からなくてもコピペしておきましょう。
この記事で少しでも理解を深めていただければ幸いです。

Reactは公式のドキュメントが充実しているので、(自戒も込めて)よく読んだ方がいいですね。

[次の記事]でははいよいよReduxのActions, Reducers, Storeなどを解説していきます。