20200528のReactに関する記事は8件です。

Reactでchartjsを使ったときにY軸のMAX値の設定ではまった話

はじめに

Reactでchart.jsを使って積み上げ棒グラフ表示したときに、Y軸のMAX値の設定ではまったことがあったので、まとめてみます。

使ったもの

  • react(v16.13.1)
  • react-chartjs-2(v2.9.0)
  • chart.js(v2.9.3)
  • lodash(v4.17.15)

作りたかったもの

  • 積み上げ棒グラフ
  • ボタンクリックで実数表示⇔割合表示を切り替える
  • 実数表示のY軸の最大値はauto(kg)にしたい(単位は適当)
  • 割合表示のY軸の最大値は100(%)にしたい

イメージはボタンクリックでstateで管理してるフラグを切り替えて、chart.jsのコンポーネントに渡すdataoptionsを変化させるイメージです。

プロジェクト作成

create-react-appでReactのプロジェクトを作成して、上記のとおり必要なパッケージをインストールします。(react以外)

create-react-app app
npm install --save react-chartjs-2 chart.js lodash

データの準備

とりあえず適当に準備する。

App.js
    this.dataset = {
      labels: ["A", "B", "C", "D", "E"],
      datasets: [
        {
          label: "label1",
          data: [36.1, 41.2, 50.4, 32.9, 29.4],
          backgroundColor: "red",
        },
        {
          label: "label2",
          data: [51.9, 60.5, 45.6, 55.5, 75.5],
          backgroundColor: "blue",
        },
        {
          label: "label3",
          data: [80.1, 88.2, 75.7, 69.4, 100.2],
          backgroundColor: "green",
        },
        {
          label: "label4",
          data: [120.5, 110.5, 128.9, 130.2, 150.5],
          backgroundColor: "purple",
        },
        {
          label: "label5",
          data: [80.5, 85.9, 77.7, 68.5, 60.9],
          backgroundColor: "yellow",
        },
      ],
    };

オプションの準備

積み上げ棒グラフのオプションを準備する。

とりあえず、実数表示用にscales.yAxes[0].scaleLabel.labelStringに実数(kg)を入れておく。
後でレンダー時に割合表示なら書き換える。

App.js
    this.options = {
      scales: {
        xAxes: [
          {
            stacked: true,
            display: true,
            scaleLabel: {
              display: true,
            },
          },
        ],
        yAxes: [
          {
            stacked: true,
            display: true,
            scaleLabel: {
              display: true,
              labelString: "実数(kg)",
            },
            ticks: {
              min: 0,
            },
          },
        ],
      },
      maintainAspectRatio: false,
    };

レンダー部分

データとオプションをBarコンポーネントに渡せばグラフが描画できる。
データとオプションは実数表示か割合表示かを変更するように関数を呼んで生成

App.js
render() {
    const data = this.changeDataSet();
    const options = this.changeOptions();
    console.log(options);
    return (
      <div>
        <input type="button" value="変更" onClick={this.onClickButton} />
        <Bar data={data} options={options} height={400} />
      </div>
    );
  }

this.changeDataSet部分

this.state.isProbで実数表示か割合表示かを切り替える。
lodashcloneDeepで値だけコピーして、そちらを加工する。

割合表示時は小数点以下第二位を四捨五入する。

App.js
  changeDataSet() {
    let dataset = _.cloneDeep(this.dataset);

    // 割合表示の時にデータを変更
    let sum = 0;
    if (this.state.isProb === true) {
      for (let j = 0; j < dataset.datasets[0].data.length; j++) {
        sum = 0;
        for (let i = 0; i < dataset.datasets.length; i++) {
          sum += dataset.datasets[i].data[j];
        }

        for (let i = 0; i < dataset.datasets.length; i++) {
          if (sum > 0) {
            dataset.datasets[i].data[j] = _.round(
              (dataset.datasets[i].data[j] / sum) * 100, 1 );
          } else {
            dataset.datesets[i].data[j] = 0;
          }
        }
      }
    }
    return dataset;
  }

this.changeOptions部分

同じくthis.state.isProbで実数表示か割合表示かを切り替える。
lodashcloneDeepで値だけコピーして、そちらを加工する。

とりあえずscales.yAxes[0].scaleLabel.labelStringだけ変更する。

App.js
  changeOptions(data) {
    let options = _.cloneDeep(this.options);

    if (this.state.isProb === true) {
      options.scales.yAxes[0].scaleLabel.labelString = "割合(%)";
    }
    return options;
  }

確認してみる

実数表示時(初期表示時)

scales.yAxes[0].ticks.maxを設定していないので、Y軸の最大値は自動で調整される。

2020-05-28_20h46_11.png

割合表示時

scales.yAxes[0].ticks.maxを設定していないので、Y軸の最大値は自動で調整される。

しかし四捨五入しているので、全部加算した時に100を超えることがあるので、Y軸の最大値が100ではなく120となってしまう。

なので、割合表示時だけY軸の最大値を100にしたい。

2020-05-28_20h49_37.png

this.changeOptionsに追記

割合表示の時だけoptions.scales.yAxes[0].ticks.max = 100;を追記して、Y軸の最大値を100にしてみる。

App.js
  changeOptions(data) {
    let options = _.cloneDeep(this.options);

    if (this.state.isProb === true) {
      options.scales.yAxes[0].scaleLabel.labelString = "割合(%)";
      options.scales.yAxes[0].ticks.max = 100;
    }
    return options;
  }

再度確認してみる

割合表示時

うまくいった!!Y軸の最大値が100になっている!!

2020-05-28_20h54_15.png

しかし、もう一度変更ボタン押して、実数表示に戻すと・・・

2020-05-28_20h55_29.png

Y軸の最大値が100のまま戻らなくなった・・

Barコンポーネントに一度渡したオプションが残るっぽいです。

実数表示時にも最大値を入れてみる

実数表示時にもoptions.scales.yAxes[0].ticks.maxを書き換えれば問題とおもったのでやろうとしたのですが、
合計値がどれくらいの大きさになるかわからない場合にすべてをif文とかで書くのも厳しいし、そもそもグラフのY軸のメモリもいい感じに自動で調整されるので、そのあたりが無理と気づく・・・

で結果どう対応したのか・・・

roundで四捨五入しているところをfloorで切り捨ててとりあえず対応した・・・
合計値が100を超えないのでY軸の最大値も自動調整のままでいけた。

微妙ですが、見た目もそんなにおかしくないような気がするのでまあいいでしょう。(ダメかもしれない・・・)

App.js
dataset.datasets[i].data[j] = _.floor(
  (dataset.datasets[i].data[j] / sum) * 100, 1 );

[コメントより追記]

undefinedを設定すれば、最大値の設定が消えるみたいです。
なので、こちらがベストプラクティスだと思います。

App.js
    if (this.state.isProb === true) {
      options.scales.yAxes[0].scaleLabel.labelString = "割合(%)";
      options.scales.yAxes[0].ticks.max = 100;
    } else {
      options.scales.yAxes[0].ticks.max = undefined;
    }

まとめ

Reactでchart.jsを使うときに、state管理でグラフ描画を切り替えるときにはまった話を書きました。

結局、納得できる解決はできていないので、こうしたらいいんじゃない?とかありましたらコメントお願いします!!

解決はできたが、もっとこうしたらいいんじゃない?とかありましたらコメントお願いします!!

ソースはこちらにあります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

日本一わかりやすいReact入門【実践編】#6~9 学習備忘録

概要

この記事は、Youtubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact入門【実践編】』の自分用学習備忘録です。

#1~5までの記事はこちら

#6...条件分岐とコールバック関数のbind

続いて、チャット部分のコンポーネントを作成します。

  • Chats
    • Chat
    • Chat
    • Chat
    • ...

AnswersList -> Answer と同じ構造。

これと並行して、Answerコンポーネントをクリックすることで、次の回答に遷移するよう、App.jsxで定義した selectAnswer()関数を、bindによってAnswerコンポーネントへ渡していきます。

修正ファイルは、
- src/App.jsx
- components/Chats.jsx
- components/Chat.jsx
- components/AnswersList.jsx
- components/Answer.jsx
- components/index.js

src/App.jsx
import React from 'react';
import './assets/styles/style.css';
import defaultDataset from "./dataset"
import {AnswersList, Chats} from "./components/index"

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      answers: [],
      chats: [],
      currentId: "init",
      dataset: defaultDataset,
      open: false
    }
    this.selectAnswer = this.selectAnswer.bind(this)
  }

  displayNextQuestion = (nextQuestionId) => {
    const chats = this.state.chats
    console.log(nextQuestionId)
    chats.push({
      text: this.state.dataset[nextQuestionId].question,
      type: 'question'
    })

    this.setState({
      answers: this.state.dataset[nextQuestionId].answers,
      chats: chats,
      currentId: nextQuestionId
    })
  }

  selectAnswer = (selectedAnswer, nextQuestionId) => {
    switch(true) {
      case (nextQuestionId === 'init'):
        this.displayNextQuestion(nextQuestionId)
        break;
      default:
        const chats = this.state.chats;
        chats.push({
          text: selectedAnswer,
          type: 'answer'
        })

        this.setState({
          chats: chats
        })

        this.displayNextQuestion(nextQuestionId)
        break;
    }
  }

  componentDidMount() {
    const initAnswer = "";
    this.selectAnswer(initAnswer, this.state.currentId)
  }

  render() {
    return (
      <section className="c-section">
        <div className="c-box">
          <Chats chats={this.state.chats}/>
          <AnswersList
            answers={this.state.answers}
            select={this.selectAnswer}
          />
        </div>
      </section>
    );
  }
}

ここは実行順序が少し複雑なので、文字で書き起こして整理してみる。

  • 初回render時の挙動

    • componentDidMount()により、selectAnswer()に初期の引数が渡される。第1引数は空の文字列、第2引数は'init'
    • selectAnswer()において、case (nextQuestionId === 'init'):が実行され、this.displayNextQuestion()'init'が渡される。
    • displayNextQuestion()で、this.state.dataset['init'].questionが呼び出され、空っぽのchatsに最初のchatが入る(typeはquestion)。
    • answers対しても、nextQuestionId='init'に対応する値が、datasetから呼び出され代入される。
    • currentIdの更新がこのタイミングで入る。今回はcurrentId='init'が代入される。
    • これらのchatsanswersが、それぞれChatsコンポーネントとAnswersコンポーネントに渡される。
  • チャット欄が更新されていく流れ

    • AnswersListコンポーネントに渡しているselectAnswer()関数は、最終的にAnswerコンポーネントに渡り、onClickイベントにより実行されることになる。その際、Answerコンポーネントが保有しているprops.contentprops.nextIdが、selectAnswer(props.content,props.nextId)の形で渡される
    • selectAnswer()のswitch分岐のうち、defaultの方が実行される。まず、selectedAnswer引数に渡されたprops.contentがchatsに追加される(typeはanswer)。その後、nextQuestionId引数に渡されたprops.nextIdがdisplayNextQuestion()に渡り、answers、currentIdが更新される
    • 更新されたchatsanswersが、それぞれChatsコンポーネントとAnswersコンポーネントに渡される。
component/Chats.jsx
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import List from '@material-ui/core/List';
import {Chat} from "./index"

const useStyles = makeStyles((theme) => ({
  root: {
    width: '100%',
    maxWidth: '36ch',
    backgroundColor: theme.palette.background.paper,
  },
}));

const Chats = (props) => {
  const classes = useStyles();

  return (
    <List className={classes.root}>
      {props.chats.map((chat,index) => {
        return <Chat text={chat.text} type={chat.type} key={index.toString()} />
      })}
    </List>
  );
}

export default Chats
  • Material-UIのList React Componentを一部使用。
  • App.jsxよりもらったprops.chatsは、AnswersListコンポーネントと同様に、map関数で繰り返し処理。
component/Chat.jsx
import React from 'react';
import ListItem from '@material-ui/core/ListItem';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import Avatar from '@material-ui/core/Avatar';
import UserIcon from '../assets/img/no-profile.png'
import BotIcon from '../assets/img/ai_computer_sousa_robot.png'

const Chat = (props) => {
  const isQuestion = (props.type === 'question');
  const classes = isQuestion ? 'p-chat__row' : 'p-chat__reverse';

  return (
    <ListItem className={classes}>
      <ListItemAvatar>
        {isQuestion ? (
          <Avatar alt="icon" src={BotIcon} />
          ) : (
          <Avatar alt="icon" src={UserIcon} />
        )}
      </ListItemAvatar>
      <div className="p-chat__bubble">{props.text}</div>
    </ListItem>
  )
}

export default Chat

  • Material-UIのList React Componentを一部使用。
  • chatの種類(type)が「question」か「answer」かで条件分岐を行っている。
component/AnswersList.jsx
import React from 'react'
import {Answer} from './index'

const AnswersList = (props) => {
  return (
    <div className="c-grid__answer">
      {props.answers.map((value, index) => {
        return <Answer content={value.content} nextId={value.nextId} key={index.toString()} select={props.select} />
      })}
    </div>
  )
}

export default AnswersList
  • 新たにnextIdとselect(selectAnswer関数のこと)を、Answerコンポーネントに渡している
component/Answer.jsx
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const useStyles = makeStyles((theme) => ({
  root: {
  },
}));

const Answer = (props) => {
  return (
    <Button variant="contained" color="primary" onClick={() => props.select(props.content,props.nextId)} >
      {props.content}
    </Button>
  )
}

export default Answer
  • onClickにより、select(=selectAnswer関数)が実行される。
components/index.js
export {default as AnswersList} from './AnswersList'
export {default as Answer} from './Answer'
export {default as Chats} from './Chats'
export {default as Chat} from './Chat'
  • コンポーネントが増え次第、ここに追記する。

上手く行けば、質問をクリックするたびチャット欄が更新され、また新たな質問が表示されます。
image.png

ここまでで、チャットボットとしての最低限の機能は実装できました!

#7...Material-UIのスタイルをカスタマイズしよう

Hook APIを用いて、Material-UIのスタイルを変更する。まずはHook APIをインストールする。

terminal
$ npm install --save @material-ui/styles

Answerコンポーネント、Chatsコンポーネントにstyleを定義します。

components/Answers.jsx
import React from 'react';
import { makeStyles, createStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const useStyles = makeStyles(() => (
  createStyles({
    "button": {
      borderColor: '#FFB549',
      color: '#FFB549',
      fontWeight: 600,
      marginBottom: '8px',
      "&:hover": {
        backgroundColor: '#FFB549',
        color: '#fff'
      }
    }
  })
));

const Answer = (props) => {
  const classes = useStyles();

  return (
    <Button className={classes.button} variant="outlined" onClick={() => props.select(props.content,props.nextId)} >
      {props.content}
    </Button>
  )
}

export default Answer
  • @material-ui/core/stylesからcreateStylesをimportする。
  • Material-UIでは、このcreateStyles()の中にstyleを定義する
  • const classes = useStyles();で定義したclassを、styleを適用させたいタグに入れる
components/Chats.jsx
import React from 'react';
import { makeStyles, createStyles } from '@material-ui/core/styles';
import List from '@material-ui/core/List';
import {Chat} from "./index"

const useStyles = makeStyles(() => (
  createStyles({
    "chats": {
      height: 400,
      padding: '0',
      overflow: 'auto'
    }
  })
));

const Chats = (props) => {
  const classes = useStyles();

  return (
    <List className={classes.chats}>
      {props.chats.map((chat,index) => {
        return <Chat text={chat.text} type={chat.type} key={index.toString()} />
      })}
    </List>
  );
}

export default Chats
  • overflow: 'auto'により、<List>タグの子要素がheight:400を超えると、スクロールバーが表示される

image.png

styleが上手く当てられていると、
- 選択肢ボタンについて、通常時は白背景の黄色文字、マウスホバー時は黄色背景の白文字に。
- チャットを何度か繰り返すと、スクロールバーが表示される
のようになっているはず。

#8...ライフサイクルの活用

自動スクロール機能

チャットが更新されたときに、スクロールが自動で下まで移動する仕様を実装する。ライフサイクルメソッドのうち、componentDidUpdate()を用いる。

components/Chats.jsx
...

<List className={classes.chats} id={"scroll-area"} >

...

スクロールをさせる<List>タグにidを持たせる。

component/App.jsx
...

componentDidUpdate(){
  const scrollArea = document.getElementById("scroll-area")
  if (scrollArea) {
    scrollArea.scrollTop = scrollArea.scrollHeight
  }
}

...

Chatsコンポーネントの値が更新されるたびにcomponentDidUpdate()が実行され、自動スクロールが実行される。

ディレイ機能

チャットボット側の返信を意図的に遅らせる。setTimeout()関数を使用する。

src/App.jsx
...

  selectAnswer = (selectedAnswer, nextQuestionId) => {
    switch(true) {
      case (nextQuestionId === 'init'):
        // this.displayNextQuestion(nextQuestionId)
        setTimeout(() => this.displayNextQuestion(nextQuestionId), 500);
        break;
      default:
        const chats = this.state.chats;
        chats.push({
          text: selectedAnswer,
          type: 'answer'
        })

        this.setState({
          chats: chats
        })
        // this.displayNextQuestion(nextQuestionId)
        setTimeout(() => this.displayNextQuestion(nextQuestionId), 1000);
        break;
    }
  }

...

displayNextQuestion()の実行にディレイをかける。setTimeoutの単位はms(ミリセカンド)。・

外部サイトへの転送

selectedAnswer()に渡されたnextQuestionIdがhttp:~から始まるURL文字列の時、datasetから値を取り出すのではなく、該当するURLへページを転送させるように実装します。

src/App.jsx
selectAnswer = (selectedAnswer, nextQuestionId) => {
    switch(true) {
      case (nextQuestionId === 'init'):
        setTimeout(() => this.displayNextQuestion(nextQuestionId), 500);
        break;

      // ここから追記
      case (/^https:*/.test(nextQuestionId)):
        const a = document.createElement('a');
        a.href = nextQuestionId;
        a.target = '_blank';
        a.click();
        break;
      // ここまで追記

      default:
      ...

    }
  }
  • 正規表現の/^https:*/を用いて、nextQuestionIdの先頭文字列がhttp:~で始めるかどうかをチェック
  • <a>タグを生成し、クリック処理をすることで転送させる

ここまでで「問い合わせる」(nextQuestionId=‘contact’)以外の選択肢については、エラーを出さなくなりました

#9...問い合わせ用モーダルとSlack通知を実装しよう

 問い合わせ用モーダル

FormDialogコンポーネントと、TextInputコンポーネントを新たに作ります。

FormDialogコンポーネントが問合せモーダル自体の表し、その中に入力スペースの部分のみを、TextInputコンポーネントが担当するイメージです。

新規作成or修正ファイルは以下の通り。

  • src/App.jsx
  • component/Form/FormDialog.jsx
  • component/Form/TextInput/jsx
  • component/index.js
src/App.jsx
import React from 'react';
import './assets/styles/style.css';
import defaultDataset from "./dataset"
// FormDialogコンポーネントを追加
import {AnswersList, Chats, FormDialog} from "./components/index"

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      answers: [],
      chats: [],
      currentId: "init",
      dataset: defaultDataset,
      open: false
    }
    this.selectAnswer = this.selectAnswer.bind(this)

    // FormDialogコンポーネントに渡す関数をバインド
    this.handleClickOpen = this.handleClickOpen.bind(this)
    this.handleClose = this.handleClose.bind(this)
  }

  displayNextQuestion = (nextQuestionId) => {
    const chats = this.state.chats
    chats.push({
      text: this.state.dataset[nextQuestionId].question,
      type: 'question'
    })

    this.setState({
      answers: this.state.dataset[nextQuestionId].answers,
      chats: chats,
      currentId: nextQuestionId
    })
  }

  selectAnswer = (selectedAnswer, nextQuestionId) => {
    switch(true) {
      case (nextQuestionId === 'init'):
        setTimeout(() => this.displayNextQuestion(nextQuestionId), 500);
        break;

      // ユーザーが'問合せたい'をクリックした時に、this.handleClickOpen()が実行される
      case (nextQuestionId === 'contact'):
        this.handleClickOpen()
        break;

      case (/^https:*/.test(nextQuestionId)):
        const a = document.createElement('a');
        a.href = nextQuestionId;
        a.target = '_blank';
        a.click();
        break;

      default:
        const chats = this.state.chats;
        chats.push({
          text: selectedAnswer,
          type: 'answer'
        })

        this.setState({
          chats: chats
        })
        setTimeout(() => this.displayNextQuestion(nextQuestionId), 1000);
        break;
    }
  }

  // openの値でモーダルの開閉を制御
  handleClickOpen = () => {
    this.setState({
      open: true
    });
  };

  // openの値でモーダルの開閉を制御
  handleClose = () => {
    this.setState({
      open: false
    });
  };

  componentDidMount() {
    const initAnswer = "";
    this.selectAnswer(initAnswer, this.state.currentId)
  }


  componentDidUpdate(){
    const scrollArea = document.getElementById("scroll-area")
    if (scrollArea) {
      scrollArea.scrollTop = scrollArea.scrollHeight
    }
  }

  render() {
    return (
      <section className="c-section">
        <div className="c-box">
          <Chats chats={this.state.chats}/>
          <AnswersList answers={this.state.answers} select={this.selectAnswer} />

          // openの値と、openの値を変更する関数を渡す
          <FormDialog open={this.state.open} handleClose={this.handleClose} />
        </div>
      </section>
    );
  }
}

  • 問合せモーダルを開くタイミングは、ユーザーが「問い合わせる」の選択肢を選んだときのみなので、handleClickOpen関数はApp.jsxのみで使用する
  • 一方、問合せモーダルを閉じるタイミングは、①情報が送信されたとき、と②問合せモーダル内の「キャンセル」を押したときであり、②を実装するためには、handleClose関数をFormDialogコンポーネントに渡しておく必要がある。
components/Forms/FormDialog.jsx
import React from 'react'
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import {TextInput} from "../index"

export default class FormDialog extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      name: "",
      email: "",
      description: ""
    }
    this.inputName = this.inputName.bind(this)
    this.inputEmail = this.inputEmail.bind(this)
    this.inputDescription = this.inputDescription.bind(this)
  }

  inputName = (event) => {
    this.setState({ name: event.target.value })
  }

  inputEmail = (event) => {
    this.setState({ email: event.target.value })
  }
  inputDescription = (event) => {
    this.setState({ description: event.target.value })
  }

  render() {
    return (
      <Dialog
        open={this.props.open}
        onClose={this.props.handleClose}
        aria-labelledby="alert-dialog-title"
        aria-describedby="alert-dialog-description"
      >
        <DialogTitle id="alert-dialog-title">{"問い合せフォーム"}</DialogTitle>
        <DialogContent>

          <TextInput
            label={"お名前"}
            multiline={false}
            rows={1}
            value={this.state.name}
            type={"text"}
            onChange={this.inputName}
          />

          <TextInput
            label={"メールアドレス"}
            multiline={false}
            rows={1}
            value={this.state.email}
            type={"email"}
            onChange={this.inputEmail}
          />

          <TextInput
            label={"お問い合わせ内容"}
            multiline={true}
            rows={5}
            value={this.state.description}
            type={"text"}
            onChange={this.inputDescription}
          />

        </DialogContent>
        <DialogActions>
          <Button onClick={this.props.handleClose} color="primary">
            キャンセル
          </Button>
          <Button onClick={this.props.handleClose} color="primary" autoFocus>
            送信する
          </Button>
        </DialogActions>
      </Dialog>
    )
  }
}

  • reactでは、inputで送信する値に関しても、stateとして定義・管理する必要がある。
  • inputName()、inputEmail()、inputDescription()を定義して、テキストフィールドに入力された内容を逐次stateとして保存する処理を行う。
  • この時点では、「送信する」ボタンを押しても、情報は送信されずに問合せモーダルが閉じる処理だけが行われる
components/Forms/TextInput.jsx
import React from 'react';
import TextField from '@material-ui/core/TextField';

const TextInput = (props) => {
  console.log(props.multiline)
  console.log(props.rows)
  return (
    <TextField
      fullWidth={true}
      label={props.label}
      margin={"dense"}
      multiline={props.multiline}
      rows={props.rows}
      value={props.value}
      type={props.type}
      onChange={props.onChange}
    />
  )
}

export default TextInput
components/index.js
export {default as AnswersList} from './AnswersList'
export {default as Answer} from './Answer'
export {default as Chats} from './Chats'
export {default as Chat} from './Chat'
export {default as FormDialog} from './Forms/FormDialog'
export {default as TextInput} from './Forms/TextInput'
  • FormDialigコンポーネント、TextInputコンポーネントを作成したので、追記。

Slack通知機能

モーダルに入力された内容を、Slackまで通知させます。まず、Slack側の設定を行い、WebhookURLを取得します。流れとしては、

  1. (未作成なら)自分一人だけのワークスペースを新たに作る
  2. 作成したワークスペースに対して、Slackアプリを作成する
  3. Incoming Webhooksを有効にし、WebhookURLを取得する

詳細は『slackのIncoming webhookが新しくなっていたのでまとめてみた』というQiita記事が参考になりました。

WebhookURLを取得できたら、FormDialog.jsxに、Slackに通知させる関数を定義します。

components/Forms/FormDialog.jsx
...

submitForm = () => {
    const name = this.state.name
    const email = this.state.email
    const description = this.state.description

    const payload = {
      text: 'お問い合わせがありました\n' +
            'お名前: ' + name + '\n' +
            'Email: ' + email + '\n' +
            'お問い合わせ内容:\n' + description
    }

    const url = 'ここにWebhookURLを記述'

    fetch(url, {
      method: 'POST',
      body: JSON.stringify(payload)
    }).then(() => {
      alert('送信が完了しました!しばらくお待ちください')
      this.setState({
        name: "",
        email: "",
        description: ""
      })
      return this.props.handleClose()
    })
  }

...

// <Button onClick={this.props.handleClose} color="primary" autoFocus>
<Button onClick={this.submitForm} color="primary" autoFocus>
  送信する
</Button>

...

「送信する」ボタンのonClickイベントに、定義したsubmitForm()関数を設定します。

image.png

お問い合わせフォームに適切な値を入力し送信すると、、、

image.png

無事、通知が届きました!

Slackへの通知は初実装でしたが、意外と簡単にできました!

記事が長くなったのでいったんここまで!続きはこちら

参考URL

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

function式とarrow式の違いをサッカー選手を呼び出しながら説明するよ

概要

function式とarrow式の違いをサッカー選手を使いながら軽く説明するよ

問題

早速、問題を出します。
aとbの違いわかりますか?

const a = function() {
  console.log('neymar')
}

const b = () => {
  console.log('neymar')
}

この場合だとbのほうが合計文字数がすくなくなったくらいですかね。

// let, const であればトップレベルで宣言してもwindowオブジェクトのプロパティを生成しませんが今回は問題の都合上varでいきます
// よほどのことがない限りこんな書き方をしないけどグローバルのnameに代入するために記載
// var宣言が怖い理由の一つもこれですね。

var name = '??global-messhi??'

const printName = function() {
  console.log('printName', this.name)
}

const arrowPrintName = () => {
  console.log('arrowPrintName', this.name)
}

const obj1 = {
  name: 'nagatomo',
  printName
}

const obj2 = {
  name: 'king-kazu',
  arrowPrintName
}

// 出力される名前はなんでしょう
obj1.printName()
obj2.arrowPrintName()


答え
obj1.printName() // nagatomo
obj2.arrowPrintName() // ??global-messhi??


アロー関数式とfunction式の違い

先程の問題の結果を見て、文字数が短くなっただけではないことはすでにわかったと思います。

thisの指している場所が違います。

通常のfunction式の中でthisを使うと、その呼び出し元のオブジェクトを指します。

var name = '??global-messhi??'

const printName = function() {
  console.log('printName', this.name)
}

const obj1 = {
  name: 'nagatomo',
  printName
}

const obj2 = {
  name: 'king-kazu',
  printName
}

obj1.printName() // 'nagatomo'
obj2.printName() // 'king-kazu'

なのでこの場合printName関数内で使っているthis.nameの値は呼び出し元がobj1とobj2なのかで値が異なってきます。
function式だとthisが呼ばれたタイミングで決定されちゃんと自立できていない人のような振る舞いです。
なのでfunction式は直感でわかりにくいので苦労します。

アロー関数は結論から言うとthisは宣言された時点で、thisを確定します。

var name = '??global-messhi??'

const arrowPrintName = () => {
  console.log('arrowPrintName', this.name)  // この時点でのthisはグローバルになる
}

const obj1 = {
  name: 'nagatomo',
  arrowPrintName
}

const obj2 = {
  name: 'king-kazu',
  arrowPrintName
}

obj1.arrowPrintName() // '??global-messhi??'
obj2.arrowPrintName() // '??global-messhi??'

なので、呼び出し元がobj1だろうが、obj2だろうが、関係なくて直感的にthisの指している場所がわかります。

今学んだことを再確認するために、もっかい問題出します。

class Person {
  constructor(name) {
    this.name = name
  }
  printName = function () {console.log('printName', this.name)}
  arrowPrintName = () => {console.log('arrowPrintName', this.name)}
}

const printName = function() {
  console.log('printName', this.name)
}

const arrowPrintName = () => {
  console.log('arrowPrintName', this.name)
}

class Person2 {
  constructor(name) {
      this.name = name
  }
  printName = printName
  arrowPrintName = arrowPrintName
}


const kakitani = new Person('kakitani')
const maezono = new Person2('maezono')

// 出力される名前はなんでしょう
kakitani.printName() 
kakitani.arrowPrintName()
maezono.printName()
maezono.arrowPrintName()


答え
kakitani.printName()  // kakitani
kakitani.arrowPrintName() // kakitani
maezono.printName() // maezono
maezono.arrowPrintName() // ??global-messhi??


const bindPrintName = function() {
  console.log(this.name)
}.bind(this) // function式をarrow関数と同じ感じでthisをつかえるようにするための魔法のコトバだよ

class Person3 {
  constructor(name) {
    this.name = name
  }
  bindPrintName = bindPrintName
}

// 出力される名前はなんでしょう
const zaccheroni = new Person3('zaccheroni')
zaccheroni.bindPrintName()


答え
const zaccheroni = new Person3('zaccheroni')
zaccheroni.bindPrintName() // ??global-messhi??


Reactでよくあるパターン

これだとボタンを押した時Uncaught TypeError: Cannot read property 'state' of undefinedが出ます。
理由わかりますか?

export default class Hoge extends Component {
  constructor(props) {
    super(props);
    this.state = {
      name: 'zico'
    };
  }

  handlePrintName() {
    const { name } = this.state;
    return name
  }

  render() {
    const { name } = this.state;

    return (
      <button onClick={this.handlePrintName}>
        print name
      </button>
    );
  }
}

解決方法

handlePrintName()をアロー関数にする

  // 変更前
  handlePrintName() { // これでthisが定まっていない
    const { name } = this.state;  
    return name
  }
  // 変更後
  handlePrintName = () => { // これでthisをHogeにbind
    const { name } = this.state;  
    return name
  }

ただReactの場合これには問題点があります。

レンダー内でアロー関数を利用するとコンポーネントがレンダーされるたびに新しい関数が作成されるため、子コンポーネントでReact.memoやPureComponentを使ってた場合に正しく比較されなくなる

ClassComponentではbind(this)を使う場合が多い

export default class Hoge extends Component {
  constructor(props) {
    super(props);
    this.state = {
      name: 'zico'
    };
  this.handlePrintName  = this.handlePrintName.bind(this)
  }

  handlePrintName() {
    const { name } = this.state;
    return name
  }

  render() {
    const { name } = this.state;

    return (
      <button onClick={this.handlePrintName}>
        print name
      </button>
    );
  }
}

参考記事

JavaScriptの「this」は「4種類」??
JavaScript の this を理解する多分一番分かりやすい説明

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[メモ] react-scriptsで環境毎にビルドできる様にする

概要

Create React Appで書いたフロントエンドのソースを環境毎に値を変更してビルドしたかったので対応しました。

やり方

環境変数を使います。
詳しくはこちらを参照してください。
Adding Custom Environment Variables

簡単に要点をまとめると

  • .envファイル内に環境変数を定義しておけばJavaScript内でprocess.env.XXXXXで取得可能
    • REACT_APP_ というプリフィックスにしておけば public/index.html 内でも使用可能
  • yarn start 時には .env.development 系、 yarn build 時には .env.production 系の環境変数が使用される
    • この辺りのルールは上記のページを参照する

複数の環境変数を切り替えられないか調べましたが特に情報が見つかりませんでした。
npmに何かありそうな気配でしたが、あまり依存するのもどうかと思ったので簡単な仕組みで実現することにしました。
ビルド時は .env.production.local に書かれている環境変数が最も優先されて使用されるため、このファイルをビルド時に入れ替えれば要件は満たせます。
簡易なshellスクリプトを作成しました。

set-env-file-for-react-script.sh

単に .env.production.xxxx という環境毎の.envファイルを作成しておくと、それをコピーするだけのshellです。
このファイルと package.json のスクリプトを組み合わせて以下の様なコマンドを用意しました。

"build:develop": "./set-env-file-for-react-script.sh develop && yarn build && rm ./.env.production.local"

これで build:develop を実行すると .env.production.develop.env.production.local に入れ替えてビルドするという事が実現できます。
最低限ですがこれでやりたい事は一旦実現できました。

よりよいやり方や標準的な手法があれば教えて下さい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[React]create-react-appを使わずに環境構築 ~設定ファイル編~

はじめに

この記事では、以前書いた[React][mac]create-react-appを使わずに環境構築で作成した設定ファイルの記述について、簡単に説明していきます。

.babelrc

// Babelに関する設定
{
 // Presentsというプラグインのプリセット
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

.eslintrc.json

.eslintrc.json
{
  "parser": "babel-eslint",
  // ESLintがされる環境の指定 
  "env": {
    // ブラウザーでES6を使用する
    "browser": true,
    "es6": true
  },
  // パーサーの設定
  "parserOptions": {
    // ES6のmodulesを使用する
    "sourceType": "module",
    // JS言語の追加機能の指定
    "ecmaFeatures": {
      // 分割代入を有効にする
      "experimentalObjectRestSpread": true,
      // JSXを有効にする
      "jsx": true
    }
  },
  // ルールのデフォルトを指定
  "extends": ["eslint:recommended", "plugin:react/recommended"],
  // React用のプラグインを指定
  "plugins": ["react"],
  // ルールの設定(ルールのデフォルトと違う設定や、デフォルトにない項目を追加などをする)
  "rules": {
    // console.logを使えるようにする
    "no-console": "off"
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  }
}

webpack.config.js

webpack.config.js
module.exports = {
  // アプリ起動時に動作すべきJSのソースファイルを指定(複数のファイルを指定可能)
  entry: {
    app: "./src/index.js"
  },
  // 出力されるファイル名、パスを指定
  output: {
    // 絶対パスで指定する必要があるためカレントディレクトリを表す __dirname と連結している
    path: __dirname + '/public/js',
    // [name] と書くと上記 entry で付けたキー(app)が使われる
    filename: "[name].js"
  },
    // webpack-dev-serverの設定
    devServer: {
    // webで公開するディレクトリー(index.htmlがある)のパス
    contentBase: __dirname + '/public',
    // 開発サーバーが使用するポート番号
    port: 8080,
    // webpackが出力するJSのあるディレクトリーが contentBase と異なる場合に指定
    publicPath: '/js/'
  },
  // デバックできるようにmapファイルを作成する指示
  devtool: "eval-source-map",
  mode: 'development',
  // ファイルの種類ごとにloaderを指定
  // test: に書かれている正規表現とマッチする場合、そのオブジェクトに書かれた loader が動く
  module: {
    rules: [{
      // ESLint の loader を実行
      test: /\.js$/,
      enforce: "pre",
      exclude: /node_modules/,
      loader: "eslint-loader"
    }, {
      // css-loader , style-loader を実行
      test: /\.css$/,
      loader: ["style-loader","css-loader"]
    }, {
      // Babel の loader を実行
      test: /\.js$/,
      exclude: /node_modules/,
      loader: 'babel-loader'
     }]
  }
};

おわり

最後まで見て頂き、ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[React][mac]create-react-appを使わずに環境構築

はじめに

Reactを学習するために教材を購入したのですが、その開発環境の構築に時間を要したため忘備録として残したいと思います。
この記事では正常にインストール出来たかを確認するため画面にHello Worldと表示させるまでを行います。
create-react-appは使用しません。

また、途中作成する各設定ファイルについての記事を[React]create-react-appを使わずに環境構築 ~設定ファイル編~で書きました。

Node.jsのインストール(Homebrew)

// node.jsがインストールされているか確認(バージョンが表示されればインストールされている)
$ node -v

// バージョンが表示されなければ以下でインストール
$ brew update
$ brew install nodejs

npmコマンドのインストール

npmコマンドはNode.jsと一緒にインストールされていますが、バージョンが古いとアップデートの必要があります。

// バージョンを確認
$ npm -v

// 必要に応じてアップデート
$ npm install npm --global

プロジェクトの作成

プロジェクト用ディレクトリーを作成し、その中にパッケージの管理ファイル(package.json)を作成します。
今回は、hello_worldという名前でディレクトリーを作成します。

// プロジェクト用ディレクトリーを作成し移動
$ mkdir hello_world
$ cd hello_world

// パッケージの管理ファイル(package.json)を作成
$ npm init -y

パッケージの管理ファイル(package.json)の編集

以下のように編集します。

package.json
{
  "name": "hello_world",
  "version": "1.0.0",
  "description": "Hello React",  
  "private": true,
  "main": "index.js",
  "scripts": {
    "start": "webpack-dev-server",
    "webpack": "webpack -d"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

"description""scripts"の内容を編集しました。

  • description ・・・説明文
  • private ・・・github等に置かないプライベートなプロジェクトの指定
  • start ・・・開発ツールの起動スクリプト
  • webpack ・・・webpack実行様スクリプト

npmパッケージのインストール

以下のコマンドで必要なパッケージをインストールしていきます。
途中でnpm ERRと表示された場合はエラーが発生していますので、タイプミスなどを確認してみたり、必要に応じて調べてみてください。

$ npm install react react-dom
$ npm install webpack webpack-cli webpack-dev-server --save-dev
$ npm install @babel/core @babel/preset-env @babel/preset-react @babel/cli --save-dev
$ npm install eslint babel-eslint eslint-loader eslint-plugin-react --save-dev
$ npm install css-loader style-loader babel-loader --save-dev

インストール結果の確認用のコードを書くためのディレクトリーを作成

$ mkdir src
$ mkdir public

設定ファイルの作成

一部のファイル名は.から始まりますが、これらのファイルはファインダーには表示がされませんので注意してください。
以下のファイルを作成します。

No. 作成ファイル
1 .babelrc
2 .eslintrc.json
3 webpack.config.js
4 public/index.html
5 src/index.js

設定ファイルの編集

先ほど作成したファイルを編集していきます。

.babelrc

{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

.eslintrc.json

{
  "parser": "babel-eslint",
  "env": {
    "browser": true,
    "es6": true
  },
  "parserOptions": {
    "sourceType": "module",
    "ecmaFeatures": {
      "experimentalObjectRestSpread": true,
      "jsx": true
    }
  },
  "extends": ["eslint:recommended", "plugin:react/recommended"],
  "plugins": ["react"],
  "rules": {
    "no-console": "off"
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  }
}

webpack.config.js

module.exports = {
  entry: {
    app: "./src/index.js"
  },
  output: {
    path: __dirname + '/public/js',
    filename: "[name].js"
  },
    devServer: {
    contentBase: __dirname + '/public',
    port: 8080,
    publicPath: '/js/'
  },
  devtool: "eval-source-map",
  mode: 'development',
  module: {
    rules: [{
      test: /\.js$/,
      enforce: "pre",
      exclude: /node_modules/,
      loader: "eslint-loader"
    }, {
      test: /\.css$/,
      loader: ["style-loader","css-loader"]
    }, {
      test: /\.js$/,
      exclude: /node_modules/,
      loader: 'babel-loader'
     }]
  }
};

表示用ファイルの編集

public/index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <meta http-equiv="X-UA-Compatible" content="IE=Edge, chrome=1" />
  <title>Hello world.</title>
</head>
<body>
  <div id="root"></div>
  <script type="text/javascript" src="js/app.js" charset="utf-8"></script>
</body>
</html>
src/index.js
import React from 'react'
import ReactDOM from 'react-dom'

ReactDOM.render(
  <h1>Hello, world!!</h1>,
  document.getElementById('root')
)

以上で準備は整いました。

インストール結果の確認

では、webpack開発サーバーを実行して正常に表示されるか確認しましょう。
皆様のブラウザーに正常に表示されていることを祈ります!

$ npm start

// ブラウザーで http://localhost:8080 を開いてください

おわり

これで開発環境を構築し、Hello worldを表示することができました。
設定ファイルの記述に関しては、見づらくなってしまうため別の記事に書こうかと思います。
最後まで見ていただき、ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React ContextでuseReducerを使ってみる

はじめに

今回はContext APIを用いた既存のプロジェクトにHooksの一つであるuseReducerを用いてreducerを追加していきます。ドキュメントを読んでもいまいち理解できない方向けの記事にしましたので細かい説明は省いています。そのままコピペしてみても良いかもしれません。

useReducerとは何か

(ContextAPIを用いて作られたグローバルなstateに対して)reducerを設定できるReact Hooksの一つです。つまりContextでreducerを使えます。ただ、そもそもreducerとは何か。

reducerとは何か

もともとはReduxにおいて出てくる考え方で、グローバルなstateの状態を管理する場所です。ローカルなstateの更新はstateを設定したコンポーネント内で出来ますが(下の図参照)、reduxで作成するグローバルなstateはコンポーネント側が直接変更することは出来ません。各コンポーネントはreducerに対して、『この処理を行いたい』という申請を送ると、reducerが代わりにstateの値を更新します。

App.js
import React, { useState } from 'react';

const App () => {
    const = [data, setData] = useState('before');

    const handleClick = () => {
        setData('after');
    };

    return (
        <div className="app">
            <div>{ data }</div>
            <button onClick={handleClick}>Change state</button>
        </div>
    )
};

export default App;

useReducerの使い所

ContextAPIを用いて作られたグローバルなstateの状態を管理する場所としてreducerを設定します。reduxと違いContextはreducerを必要といないため、Context内でstateを更新する関数を定義しても良いのですが、大きなアプリケーションを作る際はstateとそれを更新する関数をreducerを使って分けた方が見やすいコードになり、何よりContextのコード量を減らせます。それでは実際にContextでreducerを設定しましょう。

Context × Reducer

useReducerを追加前のプロジェクト

まずはグローバルなstateであるContextを持つ簡単なReactプロジェクトを作成します。ファイル構成、コードは以下の通りです。以下のプロジェクトにuseReducerを用いてContextにreducerを追加します。
react-app-files.jpg

とりあえずはHooksのuseStateを用いてローカルなstateを作成し、Providerにstateを渡しグローバルなstateを作成します。

contexts/UserContext.js
import React, { createContext, useState } from 'react';

const initialUsers = [
    { name: 'Taro', age: 20, id: 1 },
    { name: 'Kai', age: 18, id: 2 },
    { name: 'Ryo', age: 23, id: 3 }
]

export const UserContext = createContext();

const UserContextProvider = ({children}) => {
    const [users, setUsers] = useState(initialUsers);

    // もちろん下のようにstateの状態を管理する関数もreducerなしで作れます。
    const deleteUser = (id) => {
        const newUsers = users.filter(user => user.id !== id)
        setUser([
            ...newUsers
        ])
    }
    return (
        <UserContext.Provider value={{users, deleteUser}}>
            {children}
        </UserContext.Provider>
    )
}

export default UserContextProvider;

子コンポーネント(UserList)をProviderで囲んであげます。

src/App.js
import React from 'react';
import UserContextProvider from './context/UserContext';
import UserList from './components/UserList';

const App = () => {
    return (
        <UserContextProvider>
            <UserList />
        </UserContextProvider>
    );
}

export default App;

stateの中身を簡単に表示させます。

components/UserList.js
import React, { useContext, Fragment } from 'react';
import { UserContext } from '../context/UserContext';

const UserList = () => {
    const { users, deleteUser } = useContext(UserContext);
    const userList = users.map(user => {
        return (
            <section key={user.id}>
                <h3>{ user.name }</h3>
                <div>年齢:{ user.age }</div>
                <button 
                type="button" 
                onClick={() => deleteUser(user.id)}
                >ユーザーを消去</button>
            </section>
        )
    })
    return (
        <Fragment>
            <h2>ユーザーリスト</h2>
            {userList}
        </Fragment>
    );
}

export default UserList

結果として、ブラウザ上ではこのように表示されます。(黒の枠線はありません。)
Untitled Diagram (10).jpg

useReducerを追加後のプロジェクト

それではuseReducerを用いてContextにreducerを追加します。useReducerを使う前に、reducerを作ります。まずはsrcの下にreducersフォルダを作成し、その中に実際のreducerとなるUserReducer.jsを作ります。dispatch関数を用いて送られてきたオブジェクトをもとにstateを操作します。返り値には更新後のstateを返しています。これだけでは掴みにくいと思うので下のコードで変更した部分を確認してみてください。

src/reducers/UserReducer.js
// 下のuserReducerの引数actionは
// dispatch関数を用いて送られてきたオブジェクトが入ります
export const userReducer = (state, action) => {
    switch (action.type) {
        case 'DELETE_USER':
            // 今回はありませんがusersを含む全てのstateを返し
            // その後usersの中身を更新しています
            return {
                ...state,
                users: [
                    ...action.payload
                ]
            }
        default:
            return state
    }
}
contexts/UserContext.js
import React, { createContext, useReducer } from 'react';
import { userReducer } from '../reducers/UserReducer';

// 変更部分。分かりやすくするためにオブジェクトとします。
const initialState = {
    users: [
        { name: 'Taro', age: 20, id: 1 },
        { name: 'Kai', age: 18, id: 2 },
        { name: 'Ryo', age: 23, id: 3 }
    ]
}

export const UserContext = createContext();

const UserContextProvider = ({children}) => {
    // 変更部分。useReducerに使いたいreducer、stateの初期値を入れます。
    // useStateと似ていてstateとdispatch関数を返します。
    const [state, dispatch] = useReducer(userReducer, initialState);

    // 変更部分。stateとdispatch関数をグローバルで使えるようにします。
    return (
        <UserContext.Provider value={{state, dispatch}}>
            {children}
        </UserContext.Provider>
    )
}

export default UserContextProvider;
components/UserList.js
import React, { useContext, Fragment } from 'react';
import { UserContext } from '../context/UserContext';

const UserList = () => {
    // 変更部分。
    const { state: { users }, dispatch } = useContext(UserContext);

    // 変更部分。引数のidを用いて、そのidのユーザーを除いたnewUsersを作成。
    // dispatch関数を呼び出し、typeを指定し、ペイロードとしてnewUsersを入れる。
    const deleteUser = (id) => {
        const newUsers = users.filter(user => user.id !== id);
        dispatch({
            type: 'DELETE_USER',
            payload: newUsers
        })
    }

    const userList = users.map(user => {
        return (
            <section key={user.id}>
                <h3>{ user.name }</h3>
                <div>年齢:{ user.age }</div>
                <button 
                type="button" 
                onClick={() => deleteUser(user.id)}
                >ユーザーを消去</button>
            </section>
        )
    })
    return (
        <Fragment>
            <h2>ユーザーリスト</h2>
            {userList}
        </Fragment>
    );
}

export default UserList

※App.jsの変更はありません。
Untitled Diagram (10).jpg

ご覧の通りuseReducer追加前のものと同じ機能を持つアプリができました。これで完成です!

最後に

ここまで読んでいただきありがとうございます。誤字、説明等の間違いがあった場合コメントください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

新人に負けない本棚管理サイト その4(データベース検索編①)

目次

新人に負けない本棚管理サイト その1(プロローグ)
新人に負けない本棚管理サイト その2(環境構築編)
新人に負けない本棚管理サイト その3(トップページ作成編)
新人に負けない本棚管理サイト その4(データベース検索編①)

目標

  • データベースに問い合わせしてデータを取得する
  • 取得したデータを画面に表示する ←今回はここまで
  • 検索できるようにする

データの準備

使用するデータベースはMariaDBです。
セットアップ方法はその2を参照してください。

テーブル作成

CREATE_TABLE_Books.sql
CREATE TABLE Books(
     title        VARCHAR(100) primary key
    ,author       VARCHAR(20)
    ,publisher    VARCHAR(20)
    ,finished__at DATE DEFAULT CURDATE()
)

仮データ登録

CREATE_TABLE_Books.sql
INSERT INTO Books
VALUES
('Python Django超入門', '掌田津耶乃', '秀和システム', '2019/08/31'),
('React.js&Next.js超入門', '掌田津耶乃', '秀和システム', NULL),
('PostgreSQL徹底入門 第4版', '近藤雄太 他', '翔泳社', NULL),
('PHPフレームワークLaravel入門 第2版', '掌田津耶乃', '秀和システム', '2019/10/31'),
('PostgreSQLから始めるデータベース生活', 'まぐろ', 'まぐろのみぞおち', '2019/11/03')

一緒にSearchResultComponentで仮に入れていた本データを削除します。

import React from 'react';
import { Container, Row, Col, Table } from 'react-bootstrap';

export default class SearchResult extends React.Component {
  render() {
    return (
      <Container>
        <Row>
          <Table striped bordered size="sm">
            <thead>
              <tr>
                <th>タイトル</th>
                <th>著者名</th>
                <th>出版社</th>
                <th>読了日</th>
              </tr>
            </thead>
            <tbody>
{/* 削除
              <tr>
                <td>Python Django超入門</td>
                <td>掌田津耶乃</td>
                <td>秀和システム</td>
                <td>2019/08/31</td>
              </tr>
             ...
              <tr>
                <td>PostgreSQLから始めるデータベース生活</td>
                <td>まぐろ</td>
                <td>まぐろのみぞおち</td>
                <td>2019/11/03</td>
              </tr>
*/}
            </tbody>
          </Table>
        </Row>
      </Container>
    );
  }
}

スタートアッププログラムを変更

Expressのインストール

リクエストの処理を定義するために、Node.jsのフレームワークのExpressをインストールします。

terminal
npm install express

スタートアッププログラム作成

まずプロジェクト直下にserver.jsを作ります。
引用:

server.js
const express = require('express')
const next = require('next')

const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()

app.prepare()
.then(() => {
  const server = express();

  server.get('*', (req, res) => {
    return handle(req, res);
  })

  server.listen(3000, (err) => {
    if(err) {
      throw err
    }
    console.log('> Ready on http://localhost:3000')
  })
})
.catch((ex) => {
  console.error(ex.stack)
  process.exit(1)
})

次にpackage.jsonのscript部を書き換えます。

"scripts": {
  "dev": "node server.js",
  "build": "next build",
  "start": "NODE_ENV=production node server.js"
}

そして再度npm run devすると、ターミナルに> Ready on http://localhost:3000が表示されると思います。
この段階で(開発用の)Webサーバーが立ち上がったということになります。

404を解決する

今のままでは、/bookmanagerにアクセスしたあとリロードすると404が表示されます。
これはserver.jsで/bookmanagerにアクセスしたときの処理内容を定義していないため、どのページを表示させるか分からなかったためです。

じゃあ定義しよう!!

server.jsに以下のように追記します。

server.js
// (略)

app.prepare()
.then(() => {
  const server = express();

  // 追記ここから
  server.get('/bookmanager', (req, res) => {
    return app.render(req, res, '/bookmanager', req.query);
  });
  // 追記ここまで

  server.get('*', (req, res) => {
    return handle(req, res);
  })

  server.listen(3000, (err) => {
    if(err) {
      throw err
    }
    console.log('> Ready on http://localhost:3000')
  })
})
.catch((ex) => {
  console.error(ex.stack)
  process.exit(1)
})

これにより、/bookmanagerにgetリクエストが来たらpages/bookmanager/index.jsを表示させるという定義ができました。
何度リロードしても404になりません!!

データベースへ問い合わせ

最初にページを開いたときに、データベースから本情報を全件取ってきて表示させます。
メインの内容です!!

MariaDBへ接続するためのモジュールを追加

terminal
npm install mariadb

データベース接続情報を定義する

プロジェクト直下にmariadbConnection.jsを作ります。

mariadbConnection.js
const mariadb = require('mariadb/callback');

const dbConfig = {
  host: '192.168.xxx.xxx',
  user: 'username',
  password: 'yourpassword',
  database: 'database-name'
};

const connection = mariadb.createConnection(dbConfig);

module.exports = connection;

データベースからデータ取得

bookmanagerページを開いたときにコンソールにデータを表示させてみましょう。
server.jsを書き換えます。

server.js
// (略)

app.prepare()
.then(() => {
  const server = express();

  // 変更ここから
  const connection = require('./mariadbConnection');

  server.get('/bookmanager', (req, res) => {
    let query = "SELECT title, author, publisher, DATE_FORMAT(finished_at, '%Y/%m/%d') AS finished_at FROM Books ORDER BY title";
    connection.query(query, (err, rows) => {
      if(err) throw err;
      console.log(rows);
    });
    return app.render(req, res, '/bookmanager', req.query);
  });
  // 変更ここまで

  server.get('*', (req, res) => {
    return handle(req, res);
  })

  server.listen(3000, (err) => {
    if(err) {
      throw err
    }
    console.log('> Ready on http://localhost:3000')
  })
})
.catch((ex) => {
  console.error(ex.stack)
  process.exit(1)
})

npm run devしてWebサーバーを再起動します。
その後bookmanagerページを開き、ターミナルを見てみると...

コメント 2020-05-28 011021.png

いいっすねぇ~(・∀・)

取得結果を画面にバインド(やることが多いです)

通常のWebアプリでは、以下のように検索する仕組みを作ると思います。

  • 検索ボタンを押したとき、入力値をサーバーへ送る
  • サーバーサイドのプログラムからデータベースへ、検索クエリを投げる
  • 結果を画面へ返す
  • 画面でイイカンジに表示する

つまり検索するたびにサーバーへリクエストが飛ぶため、ヒット件数が多かったりストアドプロシージャ/ファンクションを介したりする場合はそれなりに時間がかかります。
せっかくNextというエクストリームニュージェネレーションフレームワーク(言いたいだけ)を使っているのに、こんなクラシックなやり方で応答時間を増やすのはダメです!

そこで、
最初にbookmanagerページを開いたときに一度だけ全件取得して、検索するときはそのデータから絞り込む
ということをしてみます。
これならデータベースへの問い合わせは初めてページを開くときの一度のみになるので、圧倒的にレスポンスが短くなります。(ページをリロードしたときも初めて開いたとする)

ページオープン時に全件取得

NextにはgetInitialPropsという便利な関数があります。
カンタンに言うと「ページが表示されたとき一度だけ実行される非同期関数」って感じです。
やりたいことにもってこいの関数ですね。
この中でデータベースへ問い合わせをします。

まずbookmanagerページにgetInitialProps関数を追加します。

pages/bookmanager/index.js
import Layout from '../../components/layout';
import React from 'react';
import { Container, Row, Col, Table, Form } from 'react-bootstrap';

export default class Index extends React.Component {
  // 追記ここから
  static async getInitialProps() {
    const res = await fetch('http://192.168.130.69:3000/bookmanager/get');
    const allBooks = await res.json();
    return { allBooks };
  }
  // 追記ここまで

  render() {
    {/* 略 */}
  }

getInitialPropsの中でAPIを呼び結果を取得しています。
この呼び出しの定義をserver.jsで記述します。

server.js
app.prepare().then(() => {
  const connection = require('./mariadbConnection');

  // データベース呼び出し処理は削除してください。
  server.get('/bookmanager', (req, res) => {
    /*
    let query = "SELECT title, author, publisher, DATE_FORMAT(finished_at, '%Y/%m/%d') AS finished_at FROM Books ORDER BY title";
    connection.query(query, (err, rows) => {
      if(err) throw err;
      console.log(rows);
    });
    */
    return app.render(req, res, '/bookmanager', req.query);
  });

  // 追記ここから
  server.get('/bookmanager/get', async (req, res) => {
    let query = "SELECT title, author, publisher, DATE_FORMAT(finished_at, '%Y/%m/%d') AS finished_at FROM Books ORDER BY title";
    connection.query(query, (err, rows) => {
      if(err) throw err;
      res.json(rows);  // レスポンスに取得データを持たせる
    });
  });
  // 追記ここまで

  server.get('*', (req, res) => {
    return handle(req, res);
  });

  server.listen(3000, (err) => {
    if(err) {
      throw err;
    }
    console.log('> Ready on http://localhost:3000');
  });
})
.catch((ex) => {
  console.error(ex.stack);
  process.exit(1);
});

毎度のことながら、npm run devしてアクセスします。
データベースには以下のデータが入っています。

title author publisher finisher_at
PHPフレームワークLaravel入門 第2版 掌田津耶乃 秀和システム 2019/10/31
PostgreSQLから始めるデータベース生活 まぐろ まぐろのみぞおち 2019/11/03
PostgreSQL徹底入門 第4版 近藤雄太 他 翔泳社 NULL
Python Django超入門 掌田津耶乃 秀和システム 2019/08/31
React.js&Next.js超入門 掌田津耶乃 秀和システム NULL
プリンをもっと見守る技術 M5StickVで体験するAIの世界 aNo研 aNo研 2020/05/26

アクセスすると...

コメント 2020-05-28 013937.png

ちゃんと表示されました(;^ω^)

お疲れ様でした

盛りだくさんで書くのも大変でした。
日本語を書くのがへたっぴなので、分かりづらいところがあると思います。
小さい目標を1つずつ達成して、
「現段階で何ができているのか」「次に何ができてほしいか」「ゴールまで到達するにはあと何ができなければならないか」
をまとめるようにすると、ワカラナイパニックに陥ることは少ないと思います。

と、私の会社の先輩が言っていました。

次回

条件を入力して検索できるようにします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む