Reddit風掲示板をFirebaseとReactを組み合わせてモダンに作る方法

2017/08/23

Nirmalya Ghosh

93

Articles in this issue reproduced from SitePoint
Copyright © 2017, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2017, KADOKAWA ASCII Research Laboratories, Inc. Japanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

米国版2ちゃんねるとも言われる巨大掲示板Reddit。バックエンドにFirebase、フロントエンドにReactを使うことで、時間をかけずにReddit風掲示板を作る方法を解説します。

Reactはユーザーインターフェイスの構築に使われるJavaScriptのライブラリーです。Create-React-Appを利用すれば、Reactアプリケーションのひな形を簡単に準備できます。

この記事ではCreate-React-AppとFirebaseを使って、ユーザーが作成したリンクにほかのユーザーが投票できる機能を持つRedditのようなアプリを構築します。

完成形のデモを実際に操作してみてください。

Firebaseを使う理由

Firebaseは、ユーザーの投票を即座に反映するような、リアルタイムのデータを簡単にユーザーへ提示できます。実装には、Firebaseのリアルタイムデータベースを使います。ReactアプリケーションをFirebaseを使ってすばやく立ち上げる方法を解説します。

Reactを使う理由

Reactは、主にコンポーネントアーキテクチャーを使ってユーザーインターフェイスを構築するために使います。それぞれのコンポーネントは内部ステート(State)を持つか、プロップ(Prop)としてデータを受け取ります。Reactを使うには、2つのコンセプト「ステートとプロップ」を理解しましょう。ステートとプロップでアプリケーションの状態が規定されます。ステートとプロップに関してはReactドキュメントに目を通してください。

メモ:ReduxMobXのようなステートコンテナも存在しますが、シンプルさを優先して、この記事では扱いません。

この記事のプロジェクトはすべてGithubで入手できます。

プロジェクトを設定

プロジェクトを立ち上げて依存オブジェクトを設定する方法を、ステップごとに説明します。

Create-React-Appをインストール

次のコマンドをターミナルに打ち込んで、Create-React-Appをインストールします。

npm install -g create-react-app

グローバルインストールすれば、どのフォルダーからでもReactプロジェクトのひな形を作成できます。

reddit-cloneという名前の新しいアプリを作成します。

create-react-app reddit-clone

新しいCreate-React-Appプロジェクトのひな形がreddit-cloneディレクトリに作成されます。Bootstrapを終えると、reddit-cloneディレクトリへ移動し開発用サーバーを起動します。

npm start

http://localhost:3000/にアクセスして、アプリのスケルトンが起動していることを確認してください。

アプリの大枠

メンテナンスしやすくするために、コンテナとコンポーネントを分離しています。コンテナは判断を伴うコンポーネントで、アプリケーションのビジネスロジックを記載して、Ajaxリクエストを処理します。一方、コンポーネントは判断を伴わないデザインのコンポーネントです。内部のステートを持ち、コンポーネントのロジックをコントロールします(例:コントロールされたインプットコンポーネントの現在のステートを表示)。

不要なロゴとCSSファイルを消去して、次のフォルダー構成にします。componentsフォルダーとcontainersフォルダーを作成してApp.jscontainers/Appフォルダーに移動し、utilsフォルダーにregisterServiceWorker.jsを作成します。

Structuring the app

src/containers/App/index.jsには次の内容を記載します。

// src/containers/App/index.js

import React, { Component } from 'react';

class App extends Component {
  render() {
    return (
      <div className="App">
        Hello World
      </div>
    );
  }
}

export default App;

src/index.jsは次のとおりです。

// src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './containers/App';
import registerServiceWorker from './utils/registerServiceWorker';

ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();

問題がなければ、ブラウザーに「Hello World」と表示されます。

ここまでのファイルをGithubにコミットしました。

React-Routerを追加

React-Routerはアプリのルートを定義するツールで、カスタマイズの自由度が高く、Reactとセットで広く使われています。

ここではバージョン3.0.0を使います。

npm install --save react-router@3.0.0

srcフォルダーに新しいファイル「routes.js」を作成し、次のコードを記載します。

// routes.js

import React from 'react';
import { Router, Route } from 'react-router';

import App from './containers/App';

const Routes = (props) => (
  <Router {...props}>
    <Route path="/" component={ App }>
    </Route>
  </Router>
);

export default Routes;

Routerコンポーネントの内にすべてのRouteコンポーネントを配置します。Routeコンポーネントのpathプロップに従って、componentプロップに渡されたコンポーネントがページ上にレンダリングされます。ここではRouterコンポーネントを使ってルートURL(/)Appコンポーネントをロードします。

<Router {...props}>
  <Route path="/" component={ <div>Hello World!</div> }>
  </Route>
</Router>

上記のコードでも動作します。/パスに<div>Hello World!</div>がマウントされます。

次にsrc/index.jsからroutes.jsを呼び出します。src/index.jsには次のコードを記載します。

// src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { browserHistory } from 'react-router';

import App from './containers/App';
import Routes from './routes';
import registerServiceWorker from './utils/registerServiceWorker';

ReactDOM.render(
  <Routes history={browserHistory} />,
  document.getElementById('root')
);

registerServiceWorker();

routes.jsからRouterコンポーネントをマウントします。historyプロップを渡すことで、Routes履歴を追跡できます。

ここまでのファイルをGithubにコミットしました。

Firebaseを追加

Firebaseのアカウントを持っていなければ、Webサイトにアクセスして取得してください(無料です)。アカウント作成後ログインし、コンソールの「プロジェクトを追加」をクリックします。

プロジェクト名(ここではreddit-clone)を入力、国を選択して、「プロジェクトを作成」ボタンをクリックします。

デフォルトだと、Firebaseのデータ読み込みと書き込みにはユーザー認証が必要です。そこで次に進む前にデータベースのルールを変更します。プロジェクトを選択して左端のDatabaseタブをクリックして、データベースを表示し、上部のRulesタブをクリックして、次のデータを含む画面を表示します。

{
  "rules": {
    ".read": "auth != null",
    ".write": "auth != null"
  }
}

以下の通り修正します。

{
  "rules": {
    ".read": "auth === null",
    ".write": "auth === null"
  }
}

これでログインしていないユーザーでもデータベースを更新できるようになりました。データベースの更新に認証が必要なフローにするのなら、Firebaseのデフォルトルールのまま使います。この記事ではアプリケーションをシンプルにするために、ユーザー認証は用いません。

重要:この修正をしないと、アプリからFirebaseのデータベースを更新できません。

次のコマンドで、firebaseのnpmモジュールをアプリに追加します。

npm install --save firebase

以下のコードでFirebaseをApp/index.jsにインポートします。

// App/index.js

import * as firebase from "firebase";

Firebaseにログインしてこのプロジェクトを選択すると、「Add Firebase to your web app」のボタンが表示されます。

Add Firebase to your web app

そのボタンをクリックすると、config変数の値が表示されます。これはcomponentWillMountメソッドで使います。

Configs

Firebaseの設定ファイルをfirebase-config.jsという名前で作成します。Firebaseへの接続に必要なすべての設定情報を、このファイルに記載します。

// App/firebase-config.js

export default {
  apiKey: "AIzaSyBRExKF0cHylh_wFLcd8Vxugj0UQRpq8oc",
  authDomain: "reddit-clone-53da5.firebaseapp.com",
  databaseURL: "https://reddit-clone-53da5.firebaseio.com",
  projectId: "reddit-clone-53da5",
  storageBucket: "reddit-clone-53da5.appspot.com",
  messagingSenderId: "490290211297"
};

Firebaseの設定情報をApp/index.jsにインポートします。

// App/index.js

import config from './firebase-config';

constructorでFirebaseへのデータベース接続を初期化します。

// App/index.js

constructor() {
  super();

  // Initialize Firebase
  firebase.initializeApp(config);
}

componentWillMount()ライフサイクルフックで、先ほどインストールしたfirebaseパッケージのinitializeAppメソッドを呼び出して、config変数を渡します。このオブジェクトにはアプリに関するデータすべてを格納します。initializeAppメソッドはアプリをFirebaseデータベースに接続して、データの読み書きができるようにします。

正しく設定できたか確かめるため、Firebaseにデータを追加します。Databaseタブへ移動し、次のデータをデータベースに追加してください。

Test data

Addをクリックすると、データベースに保存されます。

Demo data

データを画面に表示するコードをcomponentWillMountメソッドに追加します。

// App/index.js

componentWillMount() {
    ...

    let postsRef = firebase.database().ref('posts');

    let _this = this;

    postsRef.on('value', function(snapshot) {
      console.log(snapshot.val());

      _this.setState({
        posts: snapshot.val(),
        loading: false
      });
    });
  }

firebase.database()でデータベースサービスへの参照を取得します。ref()でデータベースからの具体的な参照を取得します。たとえばref('posts')でデータベースからposts参照を取得して、postsRefに格納します。

postsRef.on('value', ...)はデータベースが変更されたときに変更後の値を扱います。データベースイベントに基づいてリアルタイムでユーザーインターフェイスを更新するときに使用します。

postsRef.once('value', ...)がデータを返すのは1度だけで、再読み込みが不要なデータに適しており、頻繁に更新されたりアクティブリスニングが必要なデータには不向きです。

更新後の値をon()コールバックで取得して、postsステートに保存します。

これでコンソールにデータが表示されます。

Sample data

子要素にもデータを渡せるように、App/index.jsrender関数を書き換えます。

// App/index.js

render() {
  return (
    <div className="App">
      {this.props.children && React.cloneElement(this.props.children, {
        firebaseRef: firebase.database().ref('posts'),
        posts: this.state.posts,
        loading: this.state.loading
      })}
    </div>
  );
}

react-routerで渡されたpostsデータのすべてを子要素で使えるようにします。

this.props.childrenの存在を確認し、存在すれば要素をクローンして、すべてのプロップを子要素に渡しています。動的な子要素にプロップを渡す効率的な方法です。

cloneElementは、this.props.childrenに存在するプロップと渡すプロップ(firebaseRef、posts、loading)をシャローマージします。

これでfirebaseRefpostsloadingプロップをすべてのRoutesで使えるようになります。

ここまでファイルをGithubにコミットしました。

アプリをFirebaseに接続

Firebaseはデータをオブジェクトとして保存します。標準では配列をサポートしていないので、データを以下の形式で保存します。

Database structure

上記のスクリーンショットのデータを手動で追加して、ビューをテストできるようにします。

Postsのビューを追加

すべてのPostsを表示するビューを追加します。次の内容でsrc/containers/Posts/index.jsを作成します。

// src/containers/Posts/index.js

import React, { Component } from 'react';

class Posts extends Component {
  render() {
    if (this.props.loading) {
      return (
        <div>
          Loading...
        </div>
      );
    }

    return (
      <div className="Posts">
        { this.props.posts.map((post) => {
            return (
              <div>
                { post.title }
              </div>
            );
        })}
      </div>
    );
  }
}

export default Posts;

データをマップして、ユーザーインターフェイスにレンダリングし、routes.jsに追加します。

// routes.js

...
<Router {...props}>
  <Route path="/" component={ App }>
    <Route path="/posts" component={ Posts } />
  </Route>
</Router>
...

Postsが/postsルートに表示されます。Postsコンポーネントをcomponentプロップに渡し、/postsReact-RouterRouteコンポーネントのpathプロップに渡します。

localhost:3000/postsにアクセスすると、FirebaseのデータベースからPostsが表示されます。

ここまでのファイルをGithubにコミットしました。

新しいPostを投稿するビューを追加

新しいPostを追加するビューを作成します。次の内容でsrc/containers/AddPost/index.jsを作成します。

// src/containers/AddPost/index.js

import React, { Component } from 'react';

class AddPost extends Component {
  constructor() {
    super();

    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  state = {
    title: ''
  };

  handleChange = (e) => {
    this.setState({
      title: e.target.value
    });
  }

  handleSubmit = (e) => {
    e.preventDefault();

    this.props.firebaseRef.push({
      title: this.state.title
    });

    this.setState({
      title: ''
    });
  }

  render() {
    return (
      <div className="AddPost">
        <input 
          type="text" 
          placeholder="Write the title of your post" 
          onChange={ this.handleChange } 
          value={ this.state.title }
        />
        <button 
          type="submit" 
          onClick={ this.handleSubmit }
        >
          Submit
        </button>
      </div>
    );
  }
}

export default AddPost;

handleChangeメソッドが、インプットボックスに入力された値でステートを更新します。ボタンをクリックするとhandleSubmitメソッドが呼び出されて、データベースに書き込むAPIのリクエストを出します。この際、子要素へ渡したfirebaseRefプロップを使います。

this.props.firebaseRef.push({
  title: this.state.title
});

上記のコードで、現在のタイトルの値をデータベースに書き込みます。

新しいPostがデータベースに保存されると、インプットボックスを空にして、再び新しいPostが追加できるようにします。
Routesに追加します。

// routes.js

import React from 'react';
import { Router, Route } from 'react-router';

import App from './containers/App';
import Posts from './containers/Posts';
import AddPost from './containers/AddPost';

const Routes = (props) => (
  <Router {...props}>
    <Route path="/" component={ App }>
      <Route path="/posts" component={ Posts } />
      <Route path="/add-post" component={ AddPost } />
    </Route>
  </Router>
);

export default Routes;

/add-postルートを追加して、新しいPostをこのルートから追加できるようにしました。AddPostコンポーネントをコンポーネントプロップに渡しています。

Firebaseは配列をサポートしていないためsrc/containers/Posts/index.jsrenderメソッドを修正して、配列の代わりにオブジェクトを走査できるようにします。

// src/containers/Posts/index.js

render() {
    let posts = this.props.posts;

    if (this.props.loading) {
      return (
        <div>
          Loading...
        </div>
      );
    }

    return (
      <div className="Posts">
        { Object.keys(posts).map(function(key) {
            return (
              <div key={key}>
                { posts[key].title }
              </div>
            );
        })}
      </div>
    );
  }

localhost:3000/add-postにアクセスすると、新しいPostを追加できるようになっています。submitボタンをクリックすると、即座に新しいPostがPostページに表示されます。

ここまでのファイルをGithubにコミットしました。

投票機能を追加

ユーザーがPostに投票する機能を追加します。src/containers/App/index.jsrenderメソッドを修正します。

// src/containers/App/index.js

render() {
  return (
    <div className="App">
      {this.props.children && React.cloneElement(this.props.children, {
        // https://github.com/ReactTraining/react-router/blob/v3/examples/passing-props-to-children/app.js#L56-L58
        firebase: firebase.database(),
        posts: this.state.posts,
        loading: this.state.loading
      })}
    </div>
  );
}

firebaseプロップをfirebaseRef: firebase.database().ref('posts')からfirebase: firebase.database()に修正し、Firebaseのsetメソッドで投票数を更新します。Firebaseのrefが増えても、firebaseプロップで簡単に対応できます。

投票機能を実装する前に、src/containers/AddPost/index.jshandleSubmitメソッドを少し修正します。

// src/containers/AddPost/index.js

handleSubmit = (e) => {
  ...
  this.props.firebase.ref('posts').push({
    title: this.state.title,
    upvote: 0,
    downvote: 0
  });
  ...
}

firebaseRefプロップをfirebaseプロップに名称変更したので、this.props.firebaseRef.pushthis.props.firebase.ref('posts').pushに変更します。

src/containers/Posts/index.jsを修正して、投票機能を実装します。

renderメソッドを修正します。

// src/containers/Posts/index.js

render() {
  let posts = this.props.posts;
  let _this = this;

  if (!posts) {
    return false;
  }

  if (this.props.loading) {
    return (
      <div>
        Loading...
      </div>
    );
  }

  return (
    <div className="Posts">
      { Object.keys(posts).map(function(key) {
          return (
            <div key={key}>
              <div>Title: { posts[key].title }</div>
              <div>Upvotes: { posts[key].upvote }</div>
              <div>Downvotes: { posts[key].downvote }</div>
              <div>
                <button 
                  onClick={ _this.handleUpvote.bind(this, posts[key], key) }
                  type="button"
                >
                  Upvote
                </button>
                <button 
                  onClick={ _this.handleDownvote.bind(this, posts[key], key) }
                  type="button"
                >
                  Downvote
                </button>
              </div>
            </div>
          );
      })}
    </div>
  );
}

ボタンをクリックすると、Firebaseデータベースの賛成票か反対票の数が増えます。このロジックをhandleUpvote()メソッドとhandleDownvote()メソッドに記述します。

// src/containers/Posts/index.js

handleUpvote = (post, key) => {
  this.props.firebase.ref('posts/' + key).set({
    title: post.title,
    upvote: post.upvote + 1,
    downvote: post.downvote
  });
}

handleDownvote = (post, key) => {
  this.props.firebase.ref('posts/' + key).set({
    title: post.title,
    upvote: post.upvote,
    downvote: post.downvote + 1
  });
}

2つのメソッドは、ユーザーが投票ボタンをクリックしたときに、データベースの賛成票数か反対票数に1を加えて、即座にブラウザーへ反映します。

localhost:3000/postsを2つのタブで開いて投票ボタンをクリックすると、両方のタブがほぼ即時に更新されます。これがFirebaseをはじめ、リアルタイムデータベースの真骨頂です。

ここまでのファイルをGithubにコミットしました。

レポジトリーではデフォルトでPostをlocalhost:3000に表示するために、/posts RouteをアプリケーションのIndexRouteに追加します。ここまでのファイルもGithubにコミットしています。

最後に

最終形でもデザインをしていないので、殺風景なままです(デモはスタイルを追加しました)。プログラムが複雑になって記事が長くなるのを避けるために認証プロセスを実装しませんでしたが、実際のアプリケーションには必要でしょう。

Firebaseは、バックエンドアプリケーションの開発とメンテナンスが不要なので、API開発に多大な時間を費やすことなくリアルタイムデータを扱えます。FirebaseはReactと相性が良いことも記事から分かるでしょう。

さらに学びたい人へ

本記事はMichael Wanyoikeが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。

(原文:How to Create a Reddit Clone Using React and Firebase

[翻訳:内藤 夏樹/編集:Livit

Copyright © 2017, Nirmalya Ghosh All Rights Reserved.

Nirmalya Ghosh

Nirmalya Ghosh

インタラクティブデザインに強いフリーランスのWebデザイナー。Infismashの創立者で、WordPressのテーマ開発に力を入れています。オープンソースソフトウェアの大ファンです。

Loading...