React + Lambda + S3を使って簡易掲示板を作ってみた②〜フロントエンド実装〜

Reactで簡易掲示板を作ってみました。その時の手順を解説します。

Ishiguro Suguru

前回の記事の続きです。

React + Lambda + S3を使って簡易掲示板を作ってみた①〜バックエンド実装〜

前回の記事で掲示板のバックエンド処理をAWSのAPI Gateway, Lambda, S3を使って実装しました。 今回はフロントエンド処理をReactを使って実装します。

  1. バックエンド実装
  2. フロントエンド実装
  3. デプロイ・CloudFront設定

今回作成する掲示板をサンプルとして以下で公開しています。 なおバックエンドのAPI呼び出しは本サンプルでは出来ないようにしているため投稿の送信はできません。

React掲示板サンプル

React実装準備

まずは以下のコマンドを実行してアプリ作成の準備を行います。

# 新規アプリ作成
create-react-app board
# HTTP通信を扱うライブラリの追加。前回作成したAPIを呼び出すために利用。
yarn add axios --save
# UIコンポーネントライブラリの追加。フォームの作成に利用。
yarn add @material-ui/core

Material UIはReactでよく使用されるUIコンポーネントライブラリです。 詳しい使い方は以下の公式サイトを参照ください。

Material-UI: A popular React UI framework

Reactコード作成

今回作成するReact掲示板では以下のようにコンポーネントを分けています。

  • App.js:Appコンポーネント(ListとFormの親コンポーネント)
  • List.js:Listコンポーネント(投稿データ表示)
  • Form.js:Formコンポーネント(フォーム表示)

Appコンポーネント作成

まずはAppコンポーネントについて解説します。 コードは以下のとおりです。

import React from 'react';
import List from './List';
import Form from './Form';
import axios from 'axios';
import './App.css';


/**
 * 投稿データを取得するAPIのパスを定義
 * @type {string}
 * */
const URL = "/comments/friend?sort=desc";

class App extends React.Component {

  constructor(props) {
    console.log('@App.js___constructor----->');

    super(props);
    // ①投稿データと現在の表示状態を保持するstate
    this.state = {
      posts: [],
      views: [],
      currentPage: 1,
      postsPerPage: 10,
      total: 0,
      offset: 0,
      viewState: 'LOADING'
    };
  }// end constructor


  /**
   * ②ComponentがMountされた後に実行されるメソッド(renderメソッドによるDOM挿入直後)。
   * Ajaxを使ったデータフェッチなどの処理を記述。
   * @memberof App
   */
  componentDidMount() {
    console.log("@App.js___componentDidMount------->");

    axios.get(URL).then(res => {
      console.log("@App.js___componentDidMount_axios_success------->");

      if(res.data.existsFile === 'none') {
        this.setState({
          viewState: 'EMPTY'
        });
      }else{
        this.setState({
          posts: res.data,
          views: res.data.slice(0, 10),
          total: Math.ceil(res.data.length/10),
          viewState: 'READY'
        });
      }

    }).catch(error => {
      console.log("@App.js___componentDidMount_axios_failure------->", error);

      this.setState({
        viewState: 'ERROR'
      })
    });
  }// end componentDidMount


  /**
   * ③親フレームへ高さ情報を送信。
   * @memberof App
   */  
  componentDidUpdate() {
    console.log("@App.js___componentDidUpdate------->");

    let h = document.documentElement.scrollHeight;
    window.parent.postMessage(h, "*");
  }


  /**
   * フォームで入力した投稿を最初に取得した投稿一覧に追加するメソッド。
   * 関数コンポーネントFormに本メソッドを渡す。
   *
   * @param {*} post Formコンポーネントで入力した投稿データ
   * @memberof App
   */
  onSubmitPost(post) {
    console.log("@App.js___onSubmitPost------->", post);

    // state内の投稿一覧にFormで入力した投稿データを加え投稿リストの初期値をリセット
    let tmp = [post, ...this.state.posts];
    this.setState({
      posts: tmp,
      views: tmp.slice(0, 10),
      currentPage: 1,
      offset: 0,
    });
  }// end onSubmitPost


  /**
   * 投稿リストの状態を設定するメソッド。
   * 関数コンポーネントListに本メソッドを渡して投稿リストに表示する内容を処理する。
   *
   * @param {*} state 投稿リストに表示するデータや現在のページ番号などの状態
   * @memberof App
   */
  onSubmitViews(state) {
    console.log("@App.js___onSubmitViews----->");
    this.setState({
      views: state.views,
      currentPage: state.currentPage,
      offset: state.offset
    });
  }


  /**
   * 画面の処理ステータスを設定するメソッド。
   * 関数コンポーネントFormに本メソッドを渡す。
   *
   * @param {*} status 画面処理ステータス(ERROR, EMPTY, LOADING, READY)
   * @memberof App
   */
  setViewState(status) {
    console.log("@App.js___setViewState------->");
    this.setState({viewState: status});
  }
  

  /**
   * ④画面処理ステータスに応じて表示する内容を切り替えるメソッド。
   * 投稿データの取得に成功した場合はListコンポーネントを表示する。
   * renderメソッド内で使用。
   *
   * @param {*} status 画面処理ステータス(ERROR, EMPTY, LOADING, READY)
   * @return {*} 投稿リスト
   * @memberof App
   */
  renderSwitch(status) {
    switch(status) {
      case 'ERROR':
        return (<div>エラーが発生しました時間をおいて再度ページをリロードしてください</div>);
      case 'EMPTY':
        return (<div>投稿はありません</div>);
      case 'LOADING':
          return (<div className='loader'>Now Loading</div>);
      default:
        return (
          <List
            state={this.state}
            onSubmitViews={(state) => this.onSubmitViews(state)}
          />
        );
    }
  }


  render() {
    console.log("@App.js___render----->");

    return (
      <div className="App">
        <Form
          state={this.state}
          setViewState={(status) => this.setViewState(status)}
          onSubmitPost={(post) => this.onSubmitPost(post)}
        />
        <hr />
        {/* ④ステータスに応じて表示内容を切り替え */}
        {this.renderSwitch(this.state.viewState)}
      </div>
    );
  }// end render

}// end App Class Component

export default App;

コード内のコメントに番号を振っている部分について簡単に説明します。

①stateについて

Appコンポーネントでは以下の値をstateで管理しています。

  • posts: 投稿データ。APIで取得して保存しておく。
  • views: 表示する投稿データ。Listコンポーネントの「次へ・前へ」ボタンクリック時にデータを入れ替える。
  • currentPage: 投稿リスト一覧の現在のページ。
  • postPerPage: 投稿リスト1ページあたりに表示する投稿データ数を定義。
  • total: 投稿リストの合計ページ数。
  • offset: 投稿データの表示開始位置。
  • viewState: 投稿データ表示ステータス。

単純に表示するだけであれば上記postsのみで動作しますが、表示数をページ送り機能で成業するためにviews, currentPage…などを用意しています。

また、API呼び出しに失敗した場合や投稿データがまだ存在指定なし場合、データロード中といった状態に応じて表示する内容を変更するためにviewStateを用意しています。

Reactでアプリを実装する場合にはこのstateをどこで管理するのか?といった問題が出てきますが、これくらいの規模であればReduxは使用しなくて良いかと思います。 (私自身がReduxよくわかっていないのでおいおい学習していきたいです…)

②componentDidMountで投稿データ取得

アプリの初回表示時に投稿データを取得するためAPI呼び出しを行います。 ここで投稿データの値をstateに設定するとともに、投稿データ表示ステータスviewStateの値をAPIの取得成功or失敗に応じて変更します。

③componentDidUpdateでiframeの高さ自動設定対応

このアプリはiframeに埋め込んで使用することを想定しています。 そのためReact側でレンダリング完了後、iframe呼び出し元へ現在高さ情報を送る処理を記述しています。

iframeを使用するつもりはないよって人は、③の記述は削除してOKです。

④投稿データ表示ステータスviewStateに応じた表示内容の切り替え

投稿データ表示切り替えはrenderSwitchメソッドで実施します。 データが問題なく取得できていればListコンポーネントを表示、それ以外の場合は状態に応じたメッセージを表示するように設定しておきます。

Listコンポーネント作成

次にListコンポーネントについて解説します。 コードは以下のとおりです。

import React from 'react';
import './App.css';
import Button from '@material-ui/core/Button';


/**
 * 投稿リストコンポーネント
 *
 * @param {*} props
 * @return {*} 
 */
const List = (props) => {

    console.log('@List.js___----->');

    const { state } = props;

    /**
     * ①投稿データの描画
     *
     * @param {*} post 投稿データ
     * @return {*}
     */
    const renderPost = (post) => {
        return (
            <React.Fragment>
                <div className='board__comment-title'>
                    <span className='board__comment-title_id'>{post.id}</span>
                    <span className='board__comment-title_name'>{post.name}</span>
                    <span className='board__comment-title_date'>{post.date}</span>
                </div>
                <div className='board__comment-message'>{post.message}</div>
            </React.Fragment>
        );
    }


    /**
     * ②前へボタンクリック時の処理
     */
    const handleClickPreview = () => {
        console.log('@List.js___handleClickPreview----->');
        const { posts } = state;
        let currentPage = state.currentPage;
        let offset = state.offset;
        if(currentPage > 1) {
            offset = offset - state.postsPerPage;
            currentPage = currentPage - 1;
        }
        props.onSubmitViews({
            ...state,
            views: posts.slice(offset, offset + state.postsPerPage),
            currentPage: currentPage,
            offset: offset
        });
    }

    /**
     * ②次へボタンクリック時の処理
     */
    const handleClickNext = () => {
        console.log('@List.js___handleClickNext----->');
        const { posts } = state;
        let currentPage = state.currentPage;
        let offset = state.offset;
        if(currentPage < state.total) {
            offset = offset + state.postsPerPage;
            currentPage = currentPage + 1;
        }
        props.onSubmitViews({
            ...state,
            views: posts.slice(offset, offset + state.postsPerPage),
            currentPage: currentPage,
            offset: offset
        });
    }

    return (
        <div className="board__main">
            <div className="board__pager">
                <Button
                    variant="contained"
                    onClick={handleClickPreview}
                    disabled={state.currentPage === 1}
                >
                    前へ
                </Button>
                <div className="board__pager-count">{state.currentPage}<span className="board__pager-slash">/</span>{state.total}</div>
                <Button
                    variant="contained"
                    onClick={handleClickNext}
                    disabled={state.currentPage === state.total || state.total === 0}
                >
                    次へ
                </Button>
            </div>
            {/* ①投稿データの描画 */}
            {
                state.views.map((post, index) => {
                    return (
                        <div className='board__comment' key={post.id}>{renderPost(post)}</div>
                    );
                })
            }
        </div>
    )
}

export default List;

①投稿データの描画

投稿データの描画はrenderPostメソッドで実行します。 投稿データは配列で受け取りmap関数を使って1データ分を表示します。

②次へ・前へボタンクリック時の処理

次へ・前へボタンクリック時にAppコンポーネントから受け取ったstateを読み込みを行い、投稿データの表示内容と位置を設定します。

Formコンポーネント作成

次にFormコンポーネントについて解説します。 コードは以下のとおりです。

import React from 'react';
import axios from 'axios';
import { makeStyles } from '@material-ui/core/styles';
import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button';


/**
 * 投稿データを登録するAPIのパスを定義
 * @type {string}
 * */
const URL = "/comments/";

/**
 * APIで使用するパラメータ
 * @type {string}
 * */
const PATH = 'friend';


const useStyles = makeStyles((theme) => ({
    root: {
        display: 'flex',
        flexWrap: 'wrap',

    },
    textField: {
        marginTop: theme.spacing(1),
        marginLeft: theme.spacing(1),
        marginRight: theme.spacing(1),
        '& label.Mui-focused': {
            color: 'green',
        },
        '& .MuiOutlinedInput-root': {
            '&.Mui-focused fieldset': {
                borderColor: 'green',
            },
        },
    },
    button: {
        marginTop: theme.spacing(1),
    }
}));


/**
 * 投稿フォームコンポーネント
 *
 * @param {*} props
 * @return {*}
 */
const Form = (props) => {

    console.log('@Form.js___----->');

    const classes = useStyles();

    // ①フォームのデータをstateとして定義
    const [post, setPost] = React.useState(props.post);

    // ②フォームの内容が更新されたらstateを更新
    const handleChangeName = (event) => {
        setPost({...post, name: event.target.value});
        console.log('@Form.js___handleChangeName----->', post);
    };

    // ②フォームの内容が更新されたらstateを更新
    const handleChangeMessage = (event) => {
        setPost({...post, message: event.target.value});
        console.log('@Form.js___handleChangeMessage----->', post);
    };

    // ③送信ボタンクリック時の処理
    const handleSubmit = (event) => {
        console.log('@Form.js___handleSubmit----->');

        // 再描画を防ぐため記述
        event.preventDefault();

        if(post.name === '') {
            post.name = '名無しさん'
        }

        let body = {
            name: post.name,
            message: post.message,
            path: PATH
        }

        props.setViewState('LOADING');

        axios.post(URL, body).then(res => {
            console.log("@Form.js___handleSubmit_axios_success------->");
            props.onSubmitPost({
                id: res.data.id,
                date: res.data.date,
                name: res.data.name,
                message: res.data.message,
            });

            props.setViewState('READY');

            setPost({
                ...post,
                name: '名無しさん',
                message: '',
            });
        }).catch(error => {
            console.log("@Form.js___handleSubmit_axios_failure------->", error);
            props.setViewState('ERROR');
        });

    };

    return (

        <form noValidate autoComplete="off" onSubmit={handleSubmit}>
            <div className={classes.root}>
            <TextField
                id="standard-basic"
                label="名前"
                variant="outlined"
                fullWidth
                className={classes.textField}
                value={post.name}
                onChange={handleChangeName}
                disabled={props.state.viewState === 'LOADING'}
            />
            <TextField
                id="standard-multiline-flexible"
                label="本文"
                variant="outlined"
                fullWidth
                className={classes.textField}
                multiline
                rowsMax={10}
                value={post.message}
                onChange={handleChangeMessage}
                disabled={props.state.viewState === 'LOADING'}
            />
            </div>
            <div className={classes.button}>
                <Button
                    type="submit"
                    variant="contained"
                    disabled={props.state.viewState === 'LOADING' || post.message === ''}
                >
                    送信
                </Button>
            </div>
        </form>
    );
}

// ①フォームのデータのデフォルト値
Form.defaultProps = {
    post: {
        name: '名無しさん',
        message: '',
    }
}

export default Form;

①フォームのデータをstateとして定義

フォームデータを管理するために①でstateを定義しておきます。 また、デフォルト値はForm.defaultPropsで定義しています。

②フォームの内容が更新されたらstateを更新

フォームのテキストボックスに値が入力された際に本メソッドが呼び出されます。 ここで①で定義したstateを更新します。

③送信ボタンクリック時の処理

送信ボタンクリック時にAPIを呼び出してフォームデータを送信します。 変数bodyに送信するデータを設定してaxiosでデータ送信を行います。

Appコンポーネントで処理していたように、APIの処理成功/失敗に応じて投稿データの表示ステータスを変更します。

なお今回作成したアプリにおいて、投稿データの取得はAppコンポーネント内のcomponentDidMountでのみ行っています。 (初回1回だけ取得。APIの呼び出し負荷を下げるため。)

CSSの記述

参考までにCSS記述について以下に載せておきます。

  • App.css
.App {
  text-align: center;
}

.board__main {
  text-align: left;
  padding: 8px;
  font-size: 14px;
}

.board__comment {
  padding: 5px 0 5px;
  border-bottom: 1px #e2e2e2 solid;
}

.board__comment-title_id,
.board__comment-title_name {
  font-size: 12px;
  padding-right: 10px;
}

.board__comment-title_name {
  font-weight: bold;
}

.board__comment-title_date {
  font-size: 11px;
  color: #686868;
}

.board__comment-message {
  padding: 6px 0 6px;
  white-space: pre-wrap;
}

.board__pager {
  text-align: center;
  padding: 0 0 10px;
}

.board__pager-count {
  display: inline;
  padding: 0 11px;
}

.board__pager-slash {
  padding: 0 10px;
}

.loader,
.loader:after {
  border-radius: 50%;
  width: 10em;
  height: 10em;
}
.loader {
  margin: 60px auto;
  font-size: 10px;
  position: relative;
  text-indent: -9999em;
  border-top: 1.1em solid rgba(0,148,6, 0.2);
  border-right: 1.1em solid rgba(0,148,6, 0.2);
  border-bottom: 1.1em solid rgba(0,148,6, 0.2);
  border-left: 1.1em solid #009406;
  -webkit-transform: translateZ(0);
  -ms-transform: translateZ(0);
  transform: translateZ(0);
  -webkit-animation: load-animation 1.1s infinite linear;
  animation: load-animation 1.1s infinite linear;
}
@-webkit-keyframes load-animation {
  0% {
    -webkit-transform: rotate(0deg);
    transform: rotate(0deg);
  }
  100% {
    -webkit-transform: rotate(360deg);
    transform: rotate(360deg);
  }
}
@keyframes load-animation {
  0% {
    -webkit-transform: rotate(0deg);
    transform: rotate(0deg);
  }
  100% {
    -webkit-transform: rotate(360deg);
    transform: rotate(360deg);
  }
}

ビルド時のパス変更

Reactの処理の他にビルド時のパスの変更について補足しておきます。

create-react-appコマンドで作成したアプリは、ビルド時のパスがデフォルトで絶対パスになっています。

アプリを作成したディレクトリ直下にあるpackage.json"homepage": "."を追加すると相対パスに変更ができるため、デプロイする環境によって必要であれば追加してください。

  • package.json
{
  "name": "board",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@material-ui/core": "^4.11.0",
    "@material-ui/icons": "^4.9.1",
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.3.2",
    "@testing-library/user-event": "^7.1.2",
    "axios": "^0.20.0",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "react-scripts": "3.4.3"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "homepage": "."
}

最後に

長くなりましたがこれでフロントエンドの実装は完了です!

次回はAWSにおけるデプロイについて紹介します!!!

comments powered by Disqus