20200918のReactに関する記事は10件です。

React×Reduxで初心者がカウンターを作るまで

概要

  • 最近ReactとReduxの組み合わせを耳にすることがあったので簡単なWebアプリを作ってみようと思いました
  • 僕自身ReactもReduxも初心者のため、何もわからないところからstep by stepで動くものが完成するまでを書いてみようと思います
  • 正確さよりもReactやReduxがどんな感じか理解しやすいように記述しようと思います
  • 公式ではNodejsを使用していますが、ReactとReduxに焦点を絞るためにNodejsは使用しないで進めます

使用ライブラリ

使用するライブラリと投稿時点のバージョンです
※ReactでJSXを使用するためbabelが必要です

library version
react 4.0.5
redux 16.13.1
react-redux 7.2.1
babel 7.11.6

1. 準備

ReactとReduxの役割

大雑把な役割は以下の通りです

  • React: UIの操作
  • Redux: データの管理

Reactだけでデータの管理もできるのですが、Reduxを併用することでデータの管理を楽にしようという思想(のはず)です
※詳細はこの後触れます

ということでReactだけでアプリは作れるので、
まずはReactだけでアプリを作ってみます。
そのあとReduxを使用してアプリをリファクタリングしていきます

作成するアプリ

ボタンをクリックするとカウントアップしていくカウンターを作成してみます

counter.png

ベースのHTML

以下のHTMLをベースに実装していきます

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">

    <script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>

    <script src="https://unpkg.com/react@latest/umd/react.development.js"></script>
    <script src="https://unpkg.com/react-dom@latest/umd/react-dom.development.js"></script>
    <!--<script src="https://unpkg.com/react@latest/umd/react.production.min.js"></script>-->
    <!--<script src="https://unpkg.com/react-dom@latest/umd/react-dom.production.min.js"></script>-->

    <script src="https://unpkg.com/react-redux@7.2.1/dist/react-redux.js"></script>

    <!--JSXのブラウザでの単独実行のために必要-->
    <script src="https://unpkg.com/babel-standalone@latest/babel.min.js"></script>

    <title>Counter</title>
</head>
<body>

<h1>Counter</h1>
<div id="app"></div>

<script type="text/babel">
    // 以降、コードはこの部分のみを編集していきます
</script>
</body>
</html>
  • Reactのライブラリはdevelopmentproductionの2つがあり、開発中はdevelopmentの方を使用するとエラーがより詳細に表示されるためやりやすいと思います
  • scriptを記述していくことで<div id="app"></div>の部分にコンポーネントがレンダリングされます
  • scriptタグにはtype="text/babel"が必要です(なぜかはこのあと)

2. Reactで実装してみる

2-1. Hello World

まずはReactを使用してHello Worldを表示してみます

ReactDOM.render(    // ①
    <span>Hello World</span>,  // 
    document.getElementById('app')  // ③
)

ポイント

  • レンダリングはReactDOM.renderを実行することで実施されます(①)
  • renderの第一引数でHTMLを指定します(②)
    • ここがJSXとよばれる記法(?)で以下の2つが必要です
    • babelを使用すること(他のライブラリでも可)
    • scriptタグにtype="text/babelを指定すること
  • renderの第二引数でレンダリング対象のElementを指定します(③)
    • ここではid="app"が指定されたElementを指定しています

2-2. Reactのコンポーネントを使ってみる

ReactではUIの各要素をコンポーネントと呼ばれる単位で定義していきます。
先ほどのHello Worldを表示するHTMLをコンポーネントに定義しなおしてみます

<script type="text/babel">
class CounterApp extends React.Component { // ①
    render() { // ②
        return (
            <span>Hello World</span>
        )
    }
}

ReactDOM.render(
    <CounterApp/>,  // ③
    document.getElementById('app')
)
</script>

ポイント

  • コンポーネントはReact.Componentを実装したクラスにより定義します(①)
  • コンポーネントには「引数なし」で「HTMLを返す」renderメソッドを実装します(②)
  • 作成したコンポーネントはHTMLタグのように用いることができます(③)
    • renderで返却されたHTMLが埋め込まれるイメージです

2-3. 変数を埋め込んでみる

ここで完成形のUIとなるように修正を加えます。
同時にUIでボタンを押すごとに更新されるカウンタ情報について変数であつかってみます

<script type="text/babel">
class CounterApp extends React.Component {
    constructor(props) {  // ①
        super(props)      // ②
        this.state = {    // ③
            count: 0
        }
    }

    render() {
        return (
            <button>{this.state.count}</button>  // 
        )
    }
}

ReactDOM.render(
    <CounterApp/>,
    document.getElementById('app')
)
</script>

ポイント

  • 変数を扱うためにstateというフィールドを使用する(③)
  • stateの初期化はpropsを引数に取るコンストラクタ内で定義する(①)
  • コンストラクタでは必ずsuperpropsを引き渡す(②)
  • 変数は{}で囲ってあげることで展開される(④)

2-4. クリック時の動作を追加する

クリックしたときにボタンに表示されている数字が1ずつカウントアップするようにしてみます。
コンポーネントにカウントアップ用の関数countUpを追加し、buttonタグにonClickを追加します。

<script type="text/babel">
class CounterApp extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            count: 0
        }
        this.countUp = this.countUp.bind(this)  // ①
    }

    countUp() {
        let count = this.state.count + 1   // ②
        this.setState({                    // ③
            count
        })
    }

    render() {
        return (
            <button onClick={this.countUp}>{this.state.count}</button>  // 
        )
    }
}

ReactDOM.render(
    <CounterApp/>,
    document.getElementById('app')
)
</script>

ポイント

  • stateの更新はthis.setStateにより行う(③)
  • setStateの中でthis.stateは行わず、外で実施(②)
    • setStateが非同期でstateを更新するためのようです(詳細まで理解できてません…)
    • どうしてもな場合はthis.setState(state => ({count: state.count + 1}))というような記述も可能です(が複雑なのでここでは扱いません)
  • 独自の関数(countUp)でthis.statethis.setStateを使用する場合はコンストラクタ内でbindしておく(①)
    • ここはあまりJavascriptに詳しくないのでおまじないだと思っています
  • onClick時の動作をonClick={関数名}というように指定します(④)
    • (関数も変数も同じように引き渡せるんですね)

ここまででいったん目標としていたWebアプリは完成しました!!!
ここからいろいろリファクタリングしていきます

2-5. コンポーネントの分離

ここはReduxを使用することを想定したリファクタリングになります
React×Reduxを使用するにあたって、コンポーネントは以下の2つに分類します。

  • Presentational Components ※以後コンポーネント
    • UIに関わることに特化したコンポーネント
    • propsによってデータ(count)や関数(countUp)を受け取る
    • 基本的には関数で記述する(※このあと)
  • Container Components ※以後コンテナ
    • 動作やデータを他のコンポーネントに提供するコンポーネント

ということで先ほどのCounterAppをコンテナとし、その中からUIに関することをCounterコンポーネントとして分離してみます

<script type="text/babel">
class Counter extends React.Component {
    render() {
        return (
            <button onClick={this.props.handleClick}>{this.props.count}</button>
        )
    }
}

class CounterApp extends React.Component {
    ...

    render() {
        return (
            <Counter handleClick={this.countUp} count={this.state.count} />
        )
    }
}
...

ポイント

  • コンテナCounterAppはコンポーネントCounterに関数や変数を渡すためにHTMLでいう属性を使用します
  • コンポーネントCounterでは関数や変数をpropsから受け取ります
    • propsはconstructorで登場したやつです

さらに、Counterを関数型に変更してみます
propsを引数にとり、HTMLを返す関数を定義することでクラスを定義せずともコンポーネントを記述可能です(先ほども記載した通りこちらが推奨です)

//class Counter extends React.Component {
//    render() {
//        return (
//            <button onClick={this.props.handleClick}>{this.props.count}</button>
//        )
//    }
//}
function Counter(props) {
    return <button onClick={props.handleClick}>{props.count}</button>
}

注意点としては、propsがクラスのフィールドからメソッドの引数に変わったため、thisが不要になります
またアロー関数を使うことでさらに記述を簡略化することができます

const Counter = ({handleClick, count}) => <button onClick={handleClick}>{count}</button>

いったんここまでで以下のようのscriptとなりました

<script type="text/babel">
const Counter = ({handleClick, count}) => <button onClick={handleClick}>{count}</button>

class CounterApp extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            count: 0
        }
        this.countUp = this.countUp.bind(this)
    }

    countUp() {
        let count = this.state.count + 1
        this.setState({
            count
        })
    }

    render() {
        return (
            <Counter handleClick={this.countUp} count={this.state.count} />  // 
        )
    }
}

ReactDOM.render(
    <CounterApp/>,
    document.getElementById('app')
)
</script>

3. なぜRedux?

いったんReactのみで実装してみましたが、Reactのみで十分な実装ができているように思えます。
しかし、より複雑なUIを実装しようとすると課題が出てきます。
それはコンポーネントからコンポーネントを何層も呼ぶ場合で、
以下のような実装になりえます。

const Container1 = ({a}) => <Container2 b={a.b}/>
const Container2 = ({b}) => <Container3 b={b.c}/>
const Container3 = ({c}) => <Container4 b={c.d}/>
...

このようにpropsで子コンポーネントに変数を引き渡していくと

  • どこでどんな値が引き渡されているのか追いにくい
  • そもそも毎回propsで引き渡すのがめんどう

という課題がでてきます。
そこで、データを一元管理して、
データが必要な場合はその一元管理されたものから取得できるようにしたものがRedux(のはず)です。
なので冒頭でも述べた通り、Reduxはデータの管理に特化したものです。

4. Reduxの仕組み

Reduxでは以下の3つの要素を実装していきます

要素 説明
action UIからの情報をstoreに伝える役割
※動作自体は定義しない
action creatorによって生成
reducer actionを元にstateを変更する役割
※正確には古いstateをもとに新しいstateを生成
store データ(state)を一元管理する役割

シーケンスで書くとこんな感じです
※あくまで大雑把な理解のためのものです

image.png

storeはreducerから自動で生成するため、actionとreducerを実装していきます

5. Reduxを組み込む

5-1. actionを実装

actionはUIでのイベントをpropertyに定義したシンプルなオブジェクトです。
typeが必須で関数を持ちません

typeはreducerでstateを更新する際にどのような処理を行うのか判断するために使用します。

今回は、typeに加えincrementalという増分を表すpropertyも定義してみます。
actionは任意引数の関数の返り値として定義します。
※この関数を総称してaction creatorと呼ぶそうです

const COUNT_UP = 'count up'

// action creator
function countUp(incremental) {
    return {
        type: COUNT_UP,
        incremental
    }
}

incremental = 1のとき{incremental}と記載すると{incremental: 1}と同等ということを今回知りました

actionはUIで起こったイベントに対して1対1で定義していきます

5-2. Reducerを実装

reducerは「古いstate」と「action」を受け取り、「新しいstate」を生成する役割を持ちます。
ドキュメントで繰り返し書かれていたので念押しですが、
「stateの更新ではなく、古いstateから新しいstateを生成する」必要があります。

stateの更新時にはObject.assignという関数を使用して、stateをコピーしてから操作します

使い方サンプル
// 空のプロパティにstateの内容をコピーする
Objct.assign({}, state)
// 空のプロパティにstateの内容をコピーした後にcountプロパティの値を1に更新する
Objct.assign({}, state, {count: 1})

reducerの実装はstateとactionを引数に、stateを返す関数を定義します。

const initialState = {
    count: 0
}

// reducer
function counterApp(state = initialState, action) {
    switch (action.type) {
        case COUNT_UP:
            return Object.assign({}, state, {
                count: state.count + action.incremental
            })
        default:
            return state
    }
}

ポイント

  • 先ほどactionで説明した通り、actionにはtypeプロパティが存在するため、typeによって処理を分岐させます。
  • 定義していないtypeの場合には引数で受け取ったstateをそのまま返します
  • counterApp(state = initialState)という書き方をしておくことでstateが未定義の場合にinitialStateで置き換えてくれます

今回は1つだけreducerを作成しましたが、複数のreducerを定義して親のreducerの子として使用することも可能です。

5-3. storeを生成

reducerを実装できたところでstoreを生成します。
先ほど実装したreducerを引数にRedux.createStoreを実行するだけです!

const store = Redux.createStore(counterApp)

1つ注意点をあげるとするとstoreはデータの一元管理を目的としているため、1つのアプリに対して1つのみ生成することが基本です。

storeの関数は以下の3つを把握しておけば基礎は大丈夫かと思います

関数 サンプル 説明
getState getState() stateの取得
dispatch dispatch(actionCreator()) actionの送信(stateの更新)
subscribe subscribe(() => console.log('dispatched')) listenerの登録

5-4. Reactアプリに組み込む!

Reduxの準備がこれで整ったので、Reactのみで実装したアプリに組み込んでいきます

まず、CounterAppのstateについてstoreから取得するようにします。
また、dispatchで更新されたstateを反映するためにlistenerを登録しておきます。

class CounterApp extends React.Component {
    constructor(props) {
        super(props)
        this.state = store.getState()
        store.subscribe(() => this.setState(store.getState()))
        this.countUp = this.countUp.bind(this)
    }
...
}

次にもともと定義してあったcountUp関数についてはstore.dispatchで置き換えます。

class CounterApp extends React.Component {
    constructor(props) {
        super(props)
        this.state = store.getState()
        store.subscribe(() => this.setState(store.getState()))
    }

    render() {
        return (
            <Counter handleClick={(incremental) => store.dispatch(countUp(incremental))} count={this.state.count} />
        )
    }
}

最後にCounterコンポーネントからincremental=1を渡すように修正してみます

const Counter = ({handleClick, count}) => <button onClick={() => handleClick(1)}>{count}</button>

ここまででscript全体はこのようになりました!

<script type="text/babel">

const COUNT_UP = 'count up'

// action creator
function countUp(incremental) {
    return {
        type: COUNT_UP,
        incremental
    }
}

const initialState = {
    count: 0
}

// reducer
function counterApp(state = initialState, action) {
    switch (action.type) {
        case COUNT_UP:
            return Object.assign({}, state, {
                count: state.count + action.incremental
            })
        default:
            return state
    }
}

// storeの生成
const store = Redux.createStore(counterApp)

const Counter = ({handleClick, count}) => <button onClick={() => handleClick(1)}>{count}</button>

class CounterApp extends React.Component {
    constructor(props) {
        super(props)
        this.state = store.getState()
        store.subscribe(() => this.setState(store.getState()))
    }

    render() {
        return (
            <Counter handleClick={(incremental => store.dispatch(countUp(incremental)))} count={this.state.count} />
        )
    }
}

ReactDOM.render(
    <CounterApp/>,
    document.getElementById('app')
)
</script>

5-5. react-reduxでコンテナ生成を自動化

react-reduxのconnectを使用するとCounterAppの実装を省略することができます。
また、実装内容もより合理的なものになるためconnectの利用が推奨されています。

ということで最後の最後にconnectを使用した形にCounterAppを書き換えていきます。

改めてCounterAppを確認すると

class CounterApp extends React.Component {
    constructor(props) {
        super(props)
        this.state = store.getState()
        store.subscribe(() => this.setState(store.getState()))
    }

    render() {
        return (
            <Counter handleClick={(incremental => store.dispatch(countUp(incremental)))} count={this.state.count} />
        )
    }
}
  • コンポーネントは何か?
    • Counter
  • stateをどのようにpropsに変換するか?
    • count={this.state.count}
  • dispatchをどのように実行するか?
    • handleClick={(incremental => store.dispatch(countUp(incremental)))}

上記の3つが動的に変わる部分になりそうです。

そのため、connectを使用するにあたって、「stateをどうやってコンポーネントに伝えるか?」と「dispatchをどのように実行するか?」を関数として定義していきます。

それぞれ、mapStateToPropsmapDispatchToPropsという関数で実装していきます。

// stateをどうやってコンポーネントに伝えるか?
const mapStateToProps = state => ({
    count: state.count
})
// dispatchをどのように実行するか?
const mapDispatchToProps = dispatch => ({
    handleClick: incremental => store.dispatch(countUp(incremental))
})

そして、以下のようにconnectを実行すると自動でCounterAppが生成されます。

const CounterApp = ReactRedux.connect(mapStateToProps, mapDispatchToProps)(Counter)

最後にひさびさにレンダリングの部分を変更します

const Provider = ReactRedux.Provider;
ReactDOM.render(
    <Provider store={store}>
        <CounterApp/>
    </Provider>,
    document.getElementById('app')
)

ポイント

  • Providorの子要素としてCounterAppを記述する
  • Providorにはstoreを引き渡す

これで完了!!!

6. 完成コード

ReactとReduxを使って最終的に以下のようなコードでカウンターが完成しました!!

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">

    <script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>

    <script src="https://unpkg.com/react@latest/umd/react.development.js"></script>
    <script src="https://unpkg.com/react-dom@latest/umd/react-dom.development.js"></script>
    <!--<script src="https://unpkg.com/react@latest/umd/react.production.min.js"></script>-->
    <!--<script src="https://unpkg.com/react-dom@latest/umd/react-dom.production.min.js"></script>-->

    <script src="https://unpkg.com/react-redux@7.2.1/dist/react-redux.js"></script>

    <!--JSXのブラウザでの単独実行のために必要-->
    <script src="https://unpkg.com/babel-standalone@latest/babel.min.js"></script>

    <title>Counter</title>
</head>
<body>

<h1>Counter</h1>
<div id="app"></div>

<script type="text/babel">

const COUNT_UP = 'count up'

// action creator
function countUp(incremental) {
    return {
        type: COUNT_UP,
        incremental
    }
}

const initialState = {
    count: 0
}

// reducer
function counterApp(state = initialState, action) {
    switch (action.type) {
        case COUNT_UP:
            return Object.assign({}, state, {
                count: state.count + action.incremental
            })
        default:
            return state
    }
}

// storeの生成
const store = Redux.createStore(counterApp)

const Counter = ({handleClick, count}) => <button onClick={() => handleClick(1)}>{count}</button>

// stateをどうやってコンポーネントに伝えるか?
const mapStateToProps = state => ({
    count: state.count
})
// dispatchをどのように実行するか?
const mapDispatchToProps = dispatch => ({
    handleClick: incremental => store.dispatch(countUp(incremental))
})

const CounterApp = ReactRedux.connect(mapStateToProps, mapDispatchToProps)(Counter)

const Provider = ReactRedux.Provider;

ReactDOM.render(
    <Provider store={store}>
        <CounterApp/>
    </Provider>,
    document.getElementById('app')
)
</script>
</body>
</html>

7. 次は

次はToDoListを作ってみたいなーと思っています。
むしろもともとはこの記事でToDoListを作る予定だったのですが、

  • jsを外部ファイルに適切に分離していかないと見通しが悪そう
  • 外部ファイルに分離するとJSX周りでうまく動かない

ということで断念しました。
webpackを使用してコードを自動生成すればできるような記事も見つけたのですが、
npmを使っていろいろした方がよさそうな印象を受けました。

なのでnpmを使って(ということはNodejsベースで?)ToDoListを作る記事も書けたらなと思います。

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

WebpackとReact.jsとdotenvで.envの環境変数をbuildされるコード上でprocess.envとして使いたい

はじめに
react dotenvで検索すると「前提、create-react-appを使う」と指定されていて
「うわぁあああ」となったので書いてみた。

※本記事はReact.jsである必要はありません。react-create-app系記事へのプロパガンダです。

もっといい方法があれば教えて下さい。

結論

webpack.config.js
const webpack = require('webpack');
const dotenv = require('dotenv');

module.exports = () => {
  const env = dotenv.config().parsed;

  return {
    // ---------- 全て省略 ----------
    plugins: [
      new webpack.DefinePlugin({
        'process.env': JSON.stringify(env)
      })
    ],
  };
};

前提

  • create-react-appを使わずに構築
  • webpackを使っている

プラグインのインストール

今回はdotenvを採用
https://github.com/motdotla/dotenv

yarn add -D dotenv

dotenvやdotenv-webpackと選択肢がある

下記の様に選択肢があり、dotenvはサーバー上でしか動かない、つまりビルドする時に.envファイルから取得してwebpackを介して環境変数を渡して上げなければならないので、dotenv-webpackなどを使う方法もありだと思います。

https://www.npmtrends.com/config-vs-dotenv-vs-dotenv-webpack-vs-rc

スクリーンショット 2020-09-18 19.38.54.png

.env作成

package.jsonなどと同じ階層に.envファイルを作ってください。
rootに無いとdotenvでpathを指定しないといけなくなります。(逆に言えばpathさえ指定できればどこでもいい)

.env
SERVICE_URL="https://hogehoge.com"
API_URL="https://hoge-api.com"
NODE_ENV="development"

webpack.config.jsの編集

  1. dotenv.envファイルを読み込む
  2. dotenv.config().parsedで設定値を取得
  3. webpack.DefinePluginを使って注入する
  4. JSON.stringify()で変換して上げないと正しく値を渡せないので変換

DefinePluginは、webpack.config.js内からモジュールに対し、設定値等の値を注入できるプラグインです。

webpack.config.js
const webpack = require('webpack');
const dotenv = require('dotenv');

module.exports = () => {
  const env = dotenv.config().parsed;

  return {
    // ---------- 全て省略 ----------
    plugins: [
      new webpack.DefinePlugin({
        'process.env': JSON.stringify(env)
      })
    ],
  };
};

process.envの取得方法

home.tsx
import React from 'react';

const Home = (): JSX.Element => {
  console.log(process.env.NODE_ENV); // -> development
  return (
    <div>
      <p>this is Home</p>
    </div>
  );
};

export default Home;

参考

Environment Variables : Webpack config using DefinePlugin
Using environment variables in React
Webpackを使ってJSでも.envしたい

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

Twitter投稿時のevent、callbackについて(JS)

とあるお客様から依頼があり、調査した時の備忘録です。
Twiiter投稿時、投稿完了後に〇〇がしたい
ということがありました。

こちらの記事では実装工数が掛からない想定で、htmlとJavascriptだけで解決を試みたときの調査内容となります。(Twitter Javascript APIのみの話)
調査過程を載せてから結果書いているので、結果だけ見たい方は下の方見に行ってください。

現状の実装と分析

React 16.8.6
react-share使用(詳しくはこちら)
ボタンデザインはオリジナルです。
Twitterの画面開くときに小ウィンドウを出しています。

依頼内容がreact-shareで実現できるのか、軽く調べたらTwiiterのシェアボタンでは投稿後のcallbackは実装されてなかったので、結局Twiiterのdocumentを見に行くことに。

document確認

公式
日本語版

見るとそれっぽい内容が書いてある

tweet
このイベントは、ユーザーがツイートウェブインテントを使ってツイートを投稿した時(新規投稿かリプライ)に発生します。

埋め込み型ツイートへのリプライツイートや、埋め込み型タイムライン内に表示されるツイートを含みます。

twttr.events.bind(
  'tweet',
  function (event) {
    // Do something there
  }
);

これ使えそう、というかこれではないか。

よく見る記事で実装だけしてみる

https://teratail.com/questions/141755
https://qiita.com/HieroglypH/items/23252479cda93f88c227
こちらの記事を読んで参考にしてReactに入れてみました。

const TwitterShareButton = memo((props) => {
  const { url, via, hashtags } = props

  const onClick = (e) => {
    console.log('Twiiterまで移動した!!!', e)
  }

  const onShare = useCallback((e) => {
    console.log('shared!!!',e)
  }, [])

  useEffect(() => {
    twttr.widgets.load()
    twttr.ready(function(twttr) {
      twttr.events.bind('click', onClick);
      twttr.events.bind('tweet', onShare);
    })
  }, [])

  return (
    <a href='https://twitter.com/intent/tweet' className='twitterShareButton' data-url='https://twitter.com/' target="_black">
      { props.children }
    </a>
  )
})

html上に

window.twttr = (function (d,s,id) {
 var t, js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) return; js=d.createElement(s); js.id=id;
js.src="https://platform.twitter.com/widgets.js"; fjs.parentNode.insertBefore(js, fjs);
return window.twttr || (t = { _e: [], ready: function(f){ t._e.push(f) } });
}(document, "script", "twitter-wjs"));

こちらをscriptタグに追加しています。

target='_black'にしているのは、現実装で使っているwindow.open()ですとeventが取得できないためです。

結果

結果的に以下のような動きを起こしました。
私が取ったアクションとしては

Twitterシェアボタン(オリジナルボタン)をクリック
┗ twttr.events.bind('click', onClick);が動く想定

別タブが開き、Twitter画面になり、シェアテキストが入力できるようになる

ツイートする
┗ twttr.events.bind('tweet', onShare);が動く想定

というアクションを取っていきましたが、

Twitter画面に行く時点で、
Twiiterシェア.jpg
このようなlogを吐き出すこととなりました。
つまりtwttr.events.bind('tweet', onShare);は理想通りの動きをしてなかったということになります。

何故機能しないか

Twitterの公式フォーラムでは2015年に以下のような修正が入ったと書いてます。
https://twittercommunity.com/t/forthcoming-change-to-web-intent-events/54718

以前はTwitterシェアの投稿ボタンを押し、投稿された時点でcallbackが返ってきていたそうですが、今はindentが呼び出された時点でcallbackするように変更されていたようです。
日本語訳で抜粋すると

TwitterをWebサイトで使用する場合。アクションが完了した後ではなく、ユーザーがページでアクションを呼び出したときにトリガーされるようになりました。

この変更を行っているのは、過去数年の間にモバイルへの移行により、これらのイベントがページへのやり取りを伝えるための信頼性の低い代表的な手段になったためです。

2015年のフォーラムなので情報が古いだけか?と感じてますが、以降フォーラムで理想としている挙動と出会うことはありませんでしたので、実装はできないということなのでしょう。

終わりに

そしたらTwitterのdocument直してくれないかな~~~~って少し思ってしまいました。
紛らわしいなと。。

何か間違いがあればご指摘お願いしたいです。
読んで頂きありがとうございました。

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

React + TypeScript + vte.cxで簡単なWebアプリを作ってみた② ページネーション編

はじめに

前回作ったReact + TypeScript + vte.cxで簡単なWebアプリを作ってみた①基本的なCRUDアプリ
vte.cxのページネーション機能を使って今回はReactでページネーションを機能を実装しました。

今回作ったアプリ

image.png

前回作ったアプリを基にページネーション機能の実装を行いました。

ページネーション is 何

ざっくり言うとブログとかでページ数を区切るためのものです。

こんな感じのですがほとんどの人がネット上でみたことがあると思います。

実装する以前はただ情報を見つける時の労力を減らすためだけのものだと思っていたのですが今回実装してみてそれ以外の良い効果もあるのだと初めて気づくことができました。

前回のアプリを見てみましょう。
登録した情報をページに分けることなくそのままずらっと表示させています。

前回のページネーション実装前の情報一覧
image.png

でもこれってデータが少ない時は問題ないですけどアプリによっては登録件数1000件,2000件下手したら1万件とか全然ザラだと思います。

それをそのまま全件表示してしまうと読み込みにもめちゃめちゃ時間がかかりますし、1万件のデータの中から7434件目のデータを見るといった時にページ分けしてないと見つけるのにめちゃめちゃ時間が掛かっちゃったりして精神的にもデータの負荷的にも時間的にもかなり地獄なわけです。

と言うことでいろいろな負荷を減らすためのページネーションを実装しました。

余談ですが
ページ = pageなので
ページネーション = pag「e」nation
だと思って途中までずっとそう書いていたのですが正しくは
pag「i」nationらしいです笑

Pagination.tsx(ページの切り替えを担当するコンポーネント)

今回は前回作成したUserInfo.tsxコンポーネントをいじるのに加えて、Pagination.tsxと言うコンポーネントを作りました。

まずページネーションのロジックを管理しているPagination.tsxコンポーネントの全体図です

Pagination.tsx
import * as React from 'react'
import { useState, useEffect, useRef } from 'react'

interface Props {
    sum: number
    per: number
    onChange: (e: { page: number }) => void
}

const Pagination = (props: Props) => {
    const [currentPage, setPage] = useState(1)
    const isFirstRender = useRef(true)

    // 初期にはuseEffectでprops.onChangeを実行しないようにしている
    useEffect(() => {
        if (isFirstRender.current) {
            isFirstRender.current = false
            return
        }
        // currentPageに変化があったときに親コンポーネントにcurrentpage番号を渡す
        props.onChange({ page: currentPage });
    }, [currentPage])

    const totalPage: number = Math.ceil(props.sum / props.per)
    // 切り上げをしていてtotalPageが4.3などだった場合5にしている
    const totalPageArray: number[] = Array.from(new Array(totalPage)).map((_, i) => i++);
    // 「前へ」ボタンを押したときの処理
    const handleBack = (): void => {
        //1ページ目の場合の処理
        if (currentPage === 1) {
            return
        }

        setPage(currentPage - 1)
    }

    // 「次へ」ボタンを押したときの処理
    const handleForward = (): void => {
        //最後のページで押したときの処理
        if (currentPage === totalPage) {
            return
        }

        setPage(currentPage + 1)
    }

    // 「最初」ボタンを押したときの処理
    const handleToFirstPage = (): void => {
        setPage(1)
    }

    // 「最後」ボタンを押したときの処理
    const handleToLastPage = (): void => {
        setPage(totalPage)
    }

    //ページボタンを押したときの処理
    const handleMove = (page: number): void => {
        setPage(page)
    }

    return (
        <>
            <button onClick={handleToFirstPage}>最初へ</button>
            <button onClick={handleBack}>&laquo;前へ</button>
            {totalPageArray.map(page => {
                // 配列に格納されている番号は0スタートなので最初に+1している
                page++
                return page === currentPage ? (
                    <span style={{ cursor: 'pointer', margin: '0 5px' }} key={page} onClick={() => handleMove(page)}>
                        {page}
                    </span>
                ) : (
                        <span style={{ cursor: 'pointer', margin: '0 5px' }} key={page} onClick={() => handleMove(page)}>{page}</span>
                    )
            })}
            <button onClick={handleForward}>次へ&raquo;</button>
            <button onClick={handleToLastPage}>最後へ</button>
        </>
    )
}

export default Pagination

ページネーションでは上記のサイトを参考にしました。
React + Hooksで作るページネーションコンポーネント

ページネーションコンポーネントで必要な情報は、

ページ件数と1ページあたりどのくらいの件数を載せるか

この2点だけです。

今回は1ページあたり5件、総件数20件数未満で取得していますがもしこの総件数が300件でも1万件でもちゃんと動作されるようになっています。

例えば総件数30件、1ページには8件のせたいとします。

こうするとページ数は何ページになると思いますか?

30(総件数) ÷ 8(1ページあたりの件数)

と最初は計算しましたがこれだと答えが3.75となってしまいます。
小数点が出てしまうと、ページ数がおかしいことになってしまいます。

そこで今回は
ceilメソッドを使って切り上げています。

Math.ceil( ) Javascript公式サイト

const totalPage: number = Math.ceil(props.sum / props.per)

こうすることで

30(総件数) ÷ 8(1ページあたりの件数)

とした時、3.75を切り上げて4ページとしてくれます。
これで正しいページ表示ができます。

データを登録や消去した時に総件数が変わります。

これでページ数が導けました。

でもこれだけではダメで、ページネーションは
1,2,3,4,5このように連番に表示される必要があります。

どうするかと言うとArray.form()を使います。

// 連番の生成
// 配列はそれぞれの場所が `undefined` で初期化されるため、
// 以下の `v` のの値は `undefined` になる
Array.from({length: 5}, (v, i) => i);
// [0, 1, 2, 3, 4]

Array.from( ) Javascript公式サイト

公式を参考にしました。

このコンポーネントでは

const totalPageArray: number[] = Array.from(new Array(totalPage))
.map((_, i) => i++);

とすることで

const totalPageArray = [1,2,3.....n]

上記のような連番の数字が入った配列を作ることができています。

これを

return (
        <>
            {totalPageArray.map(page => {
                // 配列に格納されている番号は0スタートなので最初に+1している
                page++
                return page === currentPage ? (
                    <span style={{ cursor: 'pointer', margin: '0 5px' }} key={page} onClick={() => handleMove(page)}>
                        {page}
                    </span>
                ) : (
                        <span style={{ cursor: 'pointer', margin: '0 5px' }} key={page} onClick={() => handleMove(page)}>{page}</span>
                    )
            })}
    )

として
map関数を使い、totalPageArrayに入っている数字の数だけ

<span onClick={() => handleMove(page)}>1</span>
<span onClick={() => handleMove(page)>2</span>
<span onClick={() => handleMove(page)>3</span>

といったタグを作っています。このタグにはonClickイベントが入っており、押下するとそのタグが持っている数字(ページ)を引数にしたページネーション操作をすることができます。

UserInfo.tsx(情報の一覧表示をするコンポーネント)

UserInfo.tsx
import * as React from 'react'
import { useState, useEffect, useContext, useRef } from 'react'
import axios from 'axios'
import UserList from './UserList'
import { Store } from './App'
import Pagination from './Pagination'

const UserInfo = () => {
    const [users, setUsers] = useState([])
    const [sumPageNumber, setSumPageNumber] = useState(0)
    const [currentPage, setCurrentPage] = useState(1)
    const { dispatch } = useContext(Store)

    // 1ページに表示させる件数
    const displayPage = 5

    // コンポーネントマウント後に以下のページインデックスを作成する関数が実行される
    // 初期描画の実行
    useEffect(() => {
        getTotalFeedNumber()
    }, [])

    // 初期描画後
    // 最初にページインデックスを作成終了後、handlePaginateで1ページを指定している
    const mounted = useRef(false)
    useEffect(() => {
        if (mounted.current) {
            handlePaginate(1)
            console.log('useEffect作動sumPageNumber')
        } else {
            mounted.current = true
        }
    }, [sumPageNumber])


    //ページの取得処理
    let retryCount = 0
    // この処理をgetTotalFeedNumberを処理したときに実行したい
    //page番号を使ってAPIを叩く処理
    const handlePaginate = async (page: number) => {
        const LIMIT_RETRY_COUNT = 10
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'

            await axios.get(`/d/users?n=${page}&l=${displayPage}`).then((res) => {
                if (res && res.data && res.data.length) {
                    setUsers(res.data)
                }
                setCurrentPage(page)
            }).then(() => {
                retryCount = 0
                dispatch({ type: 'HIDE_INDICATOR' })
            })

        } catch (e) {
            if (e.response.data.feed.title === 'This process is still in progress. Please wait.') {
                retryCount++
                console.log(retryCount)
                if (retryCount < LIMIT_RETRY_COUNT) {
                    handlePaginate(page)
                } else {
                    dispatch({ type: 'HIDE_INDICATOR' })
                    alert('error:' + e)
                    alert('Process error')
                }
            }

            if (e.response.data.feed.title === 'Please make a pagination index in advance.') {
                retryCount++
                console.log(retryCount)
                if (retryCount < LIMIT_RETRY_COUNT) {
                    handlePaginate(page)
                } else {
                    dispatch({ type: 'HIDE_INDICATOR' })
                    alert('error:' + e)
                    alert('Not create pagination index')
                }
            }
        }
    }

    // paginationIndexを作成する処理
    const getTotalFeedNumber = async () => {
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'

            await axios.get(`/d/users?_pagination=1,4&l=${displayPage}`).then((res) => {
                setSumPageNumber(res.data.feed.subtitle)
            }).then(() => {
                dispatch({ type: 'HIDE_INDICATOR' })
            })
        } catch (e) {
            alert('error:' + e)
        }
    }

    return (
        <>
            <p>総件数:<span style={{ color: 'blue' }}>{sumPageNumber}</span>件</p>
            <p>現在<span style={{ color: 'blue' }}>{currentPage}</span>ページ目</p>
            <UserList info={users} getFormData={getTotalFeedNumber} />
            <Pagination sum={sumPageNumber} per={displayPage} onChange={e => handlePaginate(e.page)} />
        </>
    )
}

export default UserInfo

今回はページを分けての表示になります。
コンポーネントのレンダリング時に少し細工が必要になります。

Ajax通信をする際にページを分けて情報を取得する必要があります。

この情報の取得方法は公式ドキュメントに記載されています。

ページを分けて取得するには2回の通信が必要になります。

1回目の通信

await axios.get(`/d/users?_pagination=1,4&l=5`)

ここではインデックスを貼ると言う作業をします。

このインデックスを貼るという表現がAPIを叩くという表現と同じくらい初心者からするとイメージできない言葉になっているので調べてみました。

INDEXを貼る is 何

まずINDEXという言葉からです。

作るとデータ参照が速くなるやつ
大量のレコードが入っているテーブルから1行のレコードを検索するのに
頭から順番に検索したら時間がかかります。
INDEXを作成すると、データテーブルとは別に検索用に最適化された状態でデータが保存されます。
このINDEXを使うことで、目的のレコードを迅速に見つけて取り出すことが可能になります。

引用元:MySQLでインデックスを貼る時に読みたいページまとめ(初心者向け)

このINDEXを作ることをINDEXを貼ると言います。

例えばめちゃめちゃ分厚い辞書の中から必要な情報にアクセスするにはかなり時間がかかるし疲れます。
でももしそれが自分に必要そうな情報だけがのっているポケットサイズの辞書だとするとかなり見つかりやすくなります。

右がインデックスを貼らずのそのままのデータ
左がインデックス

ちなみにこのインデックスは1個だけでなく、何個も作ることができます。

2回目の通信

await axios.get(`/d/users?n=1&l=5`)

nにはインデックスのうち欲しいページの数字を
lにはページの件数をそれぞれ記入します。

※1回目と2回目のlの数字が違うとエラーを起こしてしまうので必ず統一しましょう。

UserInfo.tsxでは

1回目のAjax通信

UserInfo.tsx
await axios.get(`/d/users?_pagination=1,4&l=${displayPage}`).then((res) => {
                setSumPageNumber(res.data.feed.subtitle)
            })

await axios.get(`/d/users?_pagination=1,4&l=${displayPage}`)によりそのインデックスの件数を取得することができます。今回thenを使ってresという引数で渡したあとにsumPageNumberというstateに格納しています。

2回目のAjax通信

UserInfo.tsx
await axios.get(`/d/users?n=${page}&l=${displayPage}`).then((res) => {
                if (res && res.data && res.data.length) {
                    setUsers(res.data)
                }
                setCurrentPage(page)
            })

await axios.get(`/d/users?n=${page}&l=${displayPage}`)により、今度はそのインデックスにあるページのデータを取得することができています。
先ほどのようにthenを使ってresという引数で渡したあとにusersというstateに格納しています。

として1回目と2回目の1ページあたりの件数を

const displayPage = 5 

とすることで共通の数字に必ずなるようにしています。

ではインデックス周りの説明ができたので、ページネーションコンポーネントとどのように組み合わせて作動させているのか説明していきます。

UserInfoコンポーネントでは

UserInfo.tsx
const [users, setUsers] = useState([])
const [sumPageNumber, setSumPageNumber] = useState(0)
const [currentPage, setCurrentPage] = useState(1)

として3つのstateを管理しています。
1つめは前回でも使ったusersのデータを格納しているstate
2つめは総件数
3つめは現在何ページにいるのかを管理しています。

まずuseEffectで

    useEffect(() => {
        getTotalFeedNumber()
    }, [])

getTotalFeedNumber()というメソッドを初回レンダリング時のみ作動させています。

const getTotalFeedNumber = async () => {
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'

            await axios.get(`/d/users?_pagination=1,4&l=${displayPage}`).then((res) => {
                setSumPageNumber(res.data.feed.subtitle)
            }).then(() => {
                dispatch({ type: 'HIDE_INDICATOR' })
            })
        } catch (e) {
            alert('error:' + e)
        }
    }

これは先ほど説明したインデックスを貼るメソッドあり、総件数を取得しています。

次に

    const mounted = useRef(false)
    useEffect(() => {
        if (mounted.current) {
            handlePaginate(1)
            console.log('useEffect作動sumPageNumber')
        } else {
            mounted.current = true
        }
    }, [sumPageNumber])

useEffectでsumPageNumberに変化があった時にhandlePaginateメソッドが作動するようになっています。

こちらのuseEffectは初回レンダリング時に作動しないようにuseRefというhookを使って制御しています。
この制御をしないと初期レンダリングの際にgetTotalFeedNumberが作動し、sumPageNumberの数値が変わり、最初のページ表示時の挙動がおかしくなってしまいます。

ではsumPageNumberに変化があった時に作動するhandlePaginateメソッドを見てみましょう。

const handlePaginate = async (page: number) => {
        const LIMIT_RETRY_COUNT = 10
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'

            await axios.get(`/d/users?n=${page}&l=${displayPage}`).then((res) => {
                if (res && res.data && res.data.length) {
                    setUsers(res.data)
                }
                setCurrentPage(page)
            }).then(() => {
                retryCount = 0
                dispatch({ type: 'HIDE_INDICATOR' })
            })

        } catch (e) {
            if (e.response.data.feed.title === 'This process is still in progress. Please wait.') {
                retryCount++
                console.log(retryCount)
                if (retryCount < LIMIT_RETRY_COUNT) {
                    handlePaginate(page)
                } else {
                    dispatch({ type: 'HIDE_INDICATOR' })
                    alert('error:' + e)
                    alert('Process error')
                }
            }

            if (e.response.data.feed.title === 'Please make a pagination index in advance.') {
                retryCount++
                console.log(retryCount)
                if (retryCount < LIMIT_RETRY_COUNT) {
                    handlePaginate(page)
                } else {
                    dispatch({ type: 'HIDE_INDICATOR' })
                    alert('error:' + e)
                    alert('Not create pagination index')
                }
            }
        }
    }

このメソッドはページネーションをするものなのですが、もしページネーションをするときにインデックスが貼られていなかった場合、最大10回のAjax通信のリトライをするようになっています。

handlePaginateの引数は

await axios.get(`/d/users?n=${page}&l=${displayPage}`)

この${page}部分に入ります。
こうすることで欲しいページの情報をusersに格納することができます。

return (
        <>
            <Pagination sum={sumPageNumber} per={displayPage} onChange={e => handlePaginate(e.page)} />
        </>
    )

PaginationコンポーネントにはpropsでhandlePaginate(e.page)を渡しており、Paginationコンポーネントの持っているstateであるcurrentPageの値が変わるごとにuseEffectでprops.onChange({page:currentPage})
として引数である現在のページ番号を親に渡しています。

const Pagination = (props: Props) => {
    const [currentPage, setPage] = useState(1)

    const isFirstRender = useRef(true)

    // 初期にはuseEffectでprops.onChangeを実行しないようにしている
    useEffect(() => {
        if (isFirstRender.current) {
            isFirstRender.current = false
            return
        }
        // currentPageに変化があったときに親コンポーネントにcurrentpage番号を渡す
        props.onChange({ page: currentPage });
    }, [currentPage])

このようにすることで現在のページネーションの番号のデータが逐一取得できるようになっています。

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

【gatsby】React-dndを追加したらbuild時にエラーがでた

巷で難しい難しいと言われていた react-dnd が hookでかなり楽に実装できるようになっていたので、自作アプリにDnD機能を追加したところ、gatsbyのビルド時にエラーが出て止まってしまいました

試行環境

ソフトウェア バージョン
MacOS 10.15.6
React 16.13.1
Gatsby 2.24.2
React-dnd 11.1.3
React-dnd-html5-backend 11.1.3

エラー内容

failed Building static HTML for pages - 24.502s

 ERROR #95313 

Building static HTML failed for path "/dashboard/"

See our docs page for more info on this error: https://gatsby.dev/debug-html


  27 |     } else {
  28 |       var argIndex = 0;
> 29 |       error = new Error(format.replace(/%s/g, function () {
     | ^
  30 |         return args[argIndex++];
  31 |       }));
  32 |       error.name = 'Invariant Violation';


  WebpackError: Invariant Violation: Expected drag drop context

  - invariant.esm.js:29 
    node_modules/@react-dnd/invariant/dist/invariant.esm.js:29:1

  - useDragDropManager.js:12 
    node_modules/react-dnd/dist/esm/hooks/useDragDropManager.js:12:12

  - drag.js:23 
    node_modules/react-dnd/dist/esm/hooks/internal/drag.js:23:35

  - useDrag.js:30 
    node_modules/react-dnd/dist/esm/hooks/useDrag.js:30:51

  - DraggableCard.js:22 
    src/components/cards/DraggableCard.js:22:41

対応

エラーメッセージにある通り、https://gatsby.dev/debug-html に書かれている方法で対応できました
useDragやuseDropを呼び出しているところに (typeof window === `undefined`) による処理を追加しました

    const [{ isDragging }, drag] = (typeof window === `undefined`) ? [{}, null]
        : useDrag({
            item: { type: ItemTypes.TYPE },
            collect: monitor => ({
                isDragging: !!monitor.isDragging(),
            }),
        })

原因

DndProviderの読み込みと配置を gatsby-browser.js で行っていたためでした

gatsby-browser.js
const DndProvider = require('react-dnd').DndProvider 
const HTML5Backend = require('react-dnd-html5-backend').HTML5Backend 

exports.wrapRootElement = ({ element }) => {
    return (
        <DndProvider backend={HTML5Backend}>
            {element}
        </DndProvider>
    )
}

全ページで使用しているラッパーコンポーネントの <Layout /> 内に配置することでビルドエラーが出なくなりました
ただ、この方法にすると、別の場所でCannot have two HTML5 backends at the same time.が出てしまいウェブアプリがクラッシュしてしまいます

さいごに

コンポーネントを再起的に呼び出したりしているためか、エラーの根本的な原因は不明でした
ビルドを通すためだけの処理を追加したような感じでスッキリしませんが、とりあえず解決

React-dndの実装方法はこちらが完結で分かりやすかったです
https://qiita.com/hotchpotch/items/39da4b84da615b24a11f

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

React + TypeScript + vte.cxで簡単なWebアプリを作ってみた①基本的なCRUDアプリ

はじめに

この記事はvte.cx(ブイテックス)というBaaSを使って初心者エンジニアがサーバ構築なしで
登録、編集、削除、一覧といったWebアプリケーションの基礎機能を備えたアプリを作ってみた記事です。

今回作ったアプリ

登録した情報が一覧として表示されるアプリを作りました。

会員情報一覧(UserInfo.tsx)

image.png

新規登録画面(Form.tsx)

image.png

情報編集画面(UserInfoEdit.tsx)

image.png

一般的なCRUDアプリケーションです。
ただこちらはvte.cxというBaasを使って、画面のリロードなしにサーバーレスに非同期処理で表示を行うシングルページアプリケーション(SPA)となっています。

シングルページアプリケーション is 何

単一のWebページでコンテンツ切り替えを行うことで、ページ遷移の必要がなく、ブラウザの挙動に縛られないWeb表現を可能にするのです。

引用元サイト

普通のWebページだとサーバーに情報を送った後にサーバーが欲しい情報を返してくれるのですが、その際に一旦全ての情報を書き換えないといけません。
でもこれって書き換える必要のある情報以外の情報も書き換える必要があり、そのせいでページを遷移するごとに時間がかかるので時間のない現代人にとってあまり得策ではありません。
じゃあどうするかというと、Javascriptを使ってAjax(非同期通信)という方法で必要な情報だけ画面の後ろ側で処理してもらいます。

Ajax(非同期通信) is 何

更新の前にサーバと通信を行う。
それによりページ遷移の画面真っ白を待つことなく、createアクション、destroyアクション、updateアクションなどなどの操作ができるんです!
もっと具体的に言うと、ページ遷移をすることなく、コメントができたり、いいねができたり、ユーザ編集ができたりするんです!

もしこのような技術がなかったらTwitterでいいねをした瞬間に毎回リロードが行われてしまい、イライラしてしまいます。

そして今回はfirebaseのようなBaaSのvte.cxを利用して情報を保存したり、実際にホスティングしています。

vte.cx is 何

vte.cx公式サイト

vte.cx(ブイテックス)はReactなどのJavaScriptフレームワークを利用して
Webサービスを作成することができるバックエンドサービス(BaaS)です。
サーバ構築は一切不要で、開発コストや運用コストを削減できます。

自分のようなフロントエンド専門でやってきたプログラマーにぴったりです!
「でもfirebaseも同じようなことができるけど何が違うん?」
そんな声がどこかから聞こえてきそうですのでvte.cxの利点を説明します!
firebaseではnpm run buildした後にfirebase deployをしてやっとデプロイができますがこちらのvte.cxはnpm run serveするだけで自動的にデプロイされます!なのでいちいち手動デプロイしなくて良いので時間のない現代人にもぴったりのBaaSになっています!

ではこのvte.cxを使ったプロジェクトの作り方ですがすでにチュートリアルがあるのでこちらをご参照ください

vte.cxによるバックエンドを不要にする開発(1.Getting Started)

アプリケーション機能

今回作るものはデータを登録、編集、削除、一覧表示ができるアプリケーションです。
新規登録でユーザーを登録してその項目に名前、メールアドレス、趣味などといったデータを持たせます。
ではさっそく登録したいデータをvte.cxのスキーマに登録してみます。

  1. vte.cxにログイン
  2. サービス管理から新規サービスを作成(名前は適当に)
  3. 作成したサービスから管理画面へ移動
  4. エンドポイント管理から任意のエンドポイントを作成(ここにデータは登録されていく今回はfoo)
  5. エントリスキーマ管理から新規エントリ項目追加していく(今回10項目)

このあたりは
vte.cxによるバックエンドを不要にする開発(2.データの登録と取得)
vte.cxによるバックエンドを不要にする開発(3.スキーマ定義と型の利用)
の記事が参考になります。

最終的にはエントリ項目一覧はこんな感じになりました。
image.png

データ構造は下記のような構造になっています。

[
    users: {
        name: string,
        gender:string,
        age:int,
        address:string,
        password:string,
        email:string,
        post_number:string,
        like_residence_type:string,
        position:string,
        language:string
    }
]

とりあえずこのエントリー項目の型を開発環境に反映させましょう。

npm run download:typings

をターミナルで打つことにより/typingsフォルダの下に、index.d.tsファイルが作成されます。
これはfirebaseにはない機能で、チーム開発や堅牢なシステムを作るためにTypescriptは必須だと思うのでかなり強みだと思いました。

index.d.ts
export = VtecxApp
export as namespace VtecxApp

declare namespace VtecxApp {
    interface Request {
        feed: Feed
    }
    interface Feed {
        entry: Entry[]
    }
    interface Entry {
        id?: string,
        title?: string,
        subtitle?: string,
        rights?: string,
        summary?: string,
        content?: Content[],
        link?: Link[],
        contributor?: Contributor[],
        users?:Users
    }
    interface Content {
        ______text: string
    }
    interface Link {
        ___href: string,
        ___rel: string,
    }

    interface Contributor {
        uri?: string,
        email?: string
    }

    interface Users {
        name?:string,
        gender?:string,
        age?:number,
        address?:string,
        password?:string,
        email?:string,
        post_number?:string,
        like_residence_type?:string,
        position?:string,
        language?:string
    }
}

中身はこのようになっていて、これを使うことで型を合わせることができます。

Reactを使った開発

app.tsx
import * as React from 'react'
import { useReducer, createContext } from 'react'
import { Router, Route, Switch, Link, useHistory } from 'react-router-dom'
import axios from 'axios'

import Index from './index'
import Form from './Form'
import UserInfo from './UserInfo'
import UserInfoEdit from './UserInfoEdit'

const initialState = {
    isShow: false,
}

type ContextType = {
    state: stateType
    dispatch: React.Dispatch<actionType>
}

type stateType = {
    isShow: boolean
}

type actionType = {
    type: string
}

// objをcase文の中で2回returnすることができないので、dispatchを時間差で渡している

const reducer = (state: stateType, action: actionType) => {
    const obj = { ...state }
    const SHOW_INDICATOR = 'SHOW_INDICATOR'
    const HIDE_INDICATOR = 'HIDE_INDICATOR'

    switch (action.type) {
        case SHOW_INDICATOR:
            console.log('SHOW_INDICATORが作動')
            obj.isShow = true
            console.log(obj)
            return obj
        case HIDE_INDICATOR:
            console.log('HIDE_INDICATORが作動')
            obj.isShow = false
            return obj
        default: throw new TypeError(`Illegal type of action: ${action.type}`);
    }
}

export const Store = createContext({} as ContextType)
//dispatchを渡したいがStoreには{isShow:boolean}が渡されているため、この中にはdispatchを入れることはできない

const app = () => {
    const [state, dispatch] = useReducer(reducer, initialState)
    const history = useHistory()

    const postFormData = async (name: string, gender: string, age: number, address: string, password: string, email: string, postNumber: string, likeResidenceType: string, position: string, language: string) => {
        const req: VtecxApp.Entry[] = [
            {
                // ここにはFormで入力した値が入ってくる
                "users": {
                    "name": name,
                    "gender": gender,
                    "age": age,
                    "address": address,
                    "password": password,
                    "email": email,
                    "post_number": postNumber,
                    "like_residence_type": likeResidenceType,
                    "position": position,
                    "language": language,
                },
            }
        ]
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            await axios.post('/d/users', req).then(() => {
                dispatch({ type: 'HIDE_INDICATOR' })
            })
            history.push('/')
        } catch (e) {
            alert('error:' + e)
        }
    }

    return (
        <Router history={history}>
            <Switch>
                <Store.Provider value={{ state, dispatch }}>
                    <Index />
                    <Link to="/"><button>登録情報一覧へ</button></Link>
                    <Link to="/form"><button>新規登録画面へ</button></Link>
                    <Route exact path='/form' render={() => <Form click={postFormData} />} />
                    <Route exact path='/' component={UserInfo} />
                    <Route exact path='/user-info-edit' component={UserInfoEdit} />
                </Store.Provider>
            </Switch>
        </Router>
    )
}

export default app


SPAでの画面遷移のためにreact-router-domを採用しています。
react-router-domの説明がめちゃめちゃわかりやすいサイト
上記のサイトを参考にしながら実装していきました。
コンポーネントの役割は

  • IndexコンポーネントはAjax通信をしている際のローディングインジケーターの表示
  • Formコンポーネントは新規登録画面
  • UserInfoコンポーネントは一覧表示、削除機能
  • UserInfoEditは編集画面
※注意! vte.cxでreact-router-domを使うときindex.htmlのheadに以下の記述が必要
index.html
<script src="https://unpkg.com/react-router-dom/umd/react-router-dom.min.js"></script>

通常はnpm installで追加されるのですが、reactやreact-router-dom、axiosといった共通コンポーネントについてはHTMLの方に追加する必要があります。

理由は、ビルド時にこれらのコンポーネントを除外することでパフォーマンスを向上させる目的からです。つまり、ブラウザで常に共通コンポーネントがキャッシュされるので、ビルド時に読む込みをするよりも速くなります。
すべてのコンポーネントをHTMLに追加しているわけではなく、上記3つの代表的な共通コンポーネントのみです。

Indexコンポーネント(ローディングインジケーター)

index.tsx
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { useContext } from 'react'
import CircularProgress from '@material-ui/core/CircularProgress'
import App, { Store } from './App'
import { HashRouter as Router, } from 'react-router-dom'



const Index = () => {
    const { state } = useContext(Store)
    const isShow = state.isShow

    if (isShow) {
        return <CircularProgress />
    } else {
        return (
            <></>
        )
    }
}

export default Index

ReactDOM.render(<Router><App /></Router>, document.getElementById('container'))

このコンポーネントではapp.tsxで操作しているisShowというstateを監視してisShowがtrueの時にローディングインジケータを表示するコンポーネントです。

ここで使っているのは

index.tsx
//useContext
import { useContext } from 'react' 
// マテリアルUIのローディングインジケータ
import CircularProgress from '@material-ui/core/CircularProgress'
// Appコンポーネントとstateとdispatch情報が格納されているグローバルな情報
import App, { Store } from './App'
// ルーティングする対象を決めるHashRouter。BrowserRouterが一般的ですが、HashRouterの方がvte.cxと相性が良いのでHashRouterを使います
import { HashRouter as Router, } from 'react-router-dom'

ローディングインジケータ is 何

image.png
こんな感じで通信中にクルクル回っているものです。

地味なやつですがこれがあるのとないのとではSPAノ使いやすさがかなり違ってきます。
Ajaxを使用するにあたってローディングインジケータがないと通信が終わったのか終わってないのかわからないので使います。

マテリアルUIからimportすることですぐに使うことができます。
またvte.cxのプロジェクトには最初からmaterail-ui/coreがインストールされているため、新たにインストールする必要はありません。

またapp.tsxにあるstateを違うコンポーネントから参照するためにここではpropsではなく、useContextを使って実装しています

useContext is 何

useContextを利用することで様々なコンポーネントから参照できるステートを作成することができます。

createContext()を利用することで、ステートの状態管理が可能なProviderコンポーネントが作成でき、value属性で管理したいステートを指定することができ、Provider内の子孫コンポーネントではuseContextを利用して管理しているステートにアクセスできます。
useContextの説明がめちゃめちゃわかりやすいサイト

stateを渡す時にpropsを使うと親から子に渡す分には良いのですが、親=>子=>孫=>ひ孫
渡す対象を深くしてしまうとバケツリレーのようになってしまい、可読性や拡張性がかなり悪いアプリになってしまいます。

useContextを使うことで親=>ひ孫のように途中に必要なステップをすっ飛ばして実装することができます。

useContextを使うための流れ

step1. 親コンポーネントで渡したいstateをcreateContextというものを使って定数として設定します。また、設定した定数のタグ + .Providerで子孫コンポーネントをラップします。またタグ内のvalueに子孫に渡したい値を入れます。今回はstateとdispatchです(この中で出てくるuseReducerは後述します)

app.tsx
import { useReducer,createContext } from 'react'

const initialState = {
    isShow: false,
}

type ContextType = {
    state: stateType
    dispatch: React.Dispatch<actionType>
}

type stateType = {
    isShow: boolean
}

export const Store = createContext({} as ContextType)

const app = () => {
  const [state, dispatch] = useReducer(reducer, initialState)
  return (
        <Router history={history}>
            <Switch>
                <Store.Provider value={{ state, dispatch }}
                    <Index />
                </Store.Provider>
            </Switch>
        </Router>
    )
}

step2. 子孫コンポーネントの中で先ほどのstep1で設定した定数をimportしてuseContextを使って定数として設定します。

step3. useContextで設定した定数にアクセスすることで参照することができます

Index.tsx
import { useContext } from
//親でcreateContextしてexportした値。この値をuseContextすることで参照する定数が作れる。
import { Store } from './App' 

const Index = () => {
    const { state } = useContext(Store)
    const isShow = state.isShow

//もしisShowがtrueならローディングインジケータを表示させfalseの場合何も表示しないようにする
    if (isShow) {
        return <CircularProgress />
    } else {
        return (
            <></>
        )
    }
}

またisShowをtrueやfalseにするスイッチとして今回のアプリではuseContextとuseReducerを組み合わせて、どこからでもdispatchをすることができるようにしています。

useReducer is 何

useContext単体だと子孫コンポーネントで状態を参照することはできますが、状態を「更新」することができません。

ここでめちゃめちゃ役に立つのがuseReducerです。

useReducerとは
useStateと似たような機能であり、dispatch(配達員のようなイメージ)した後にreducerに値を渡してそこから値を取り出すhooks

値だけでなく、値を変えるdispatchというものを持っています。
useStateと比べると

useState useReducer
扱えるstateのtype 数値、文字列、論理値 オブジェクト、配列
関連するstateの取り扱い 複数を同時に取り扱うことができる
ローカルorグローバル ローカル グローバルuseContextと一緒に取り扱う

使い方は下記の通りで
必要なものは初期値(ここではinitialState)とreducer(ここではreducer)

物語的な流れとしては

reducerは値を変える門番のようなもので、dispatchは配達員です。配達員はacitons.typeという届け物を門番に渡します。
門番は届け物が正しいものか確認してあっていたら値を変えることができます。

今回のアプリではSHOW_INDICATORとHIDE_INDICATORという届け物があります。
配達員がどちらかの届け物を門番に渡すのですが上記の届け物のどちらにも該当しなかった時に
門番「この届け物は合ってない!!!」
として「throw new TypeError(Illegal type of action: ${action.type})」というエラーを出します

  1. dispatch({type:'SHOW_INDITATOR'})
  2. reducerにdispatchが渡ってきてcase文で判断
  3. case文に該当したら値が変更される
app.tsx
import { useReducer } from 'react'

const initialState = {
    isShow: false,
}

const reducer = (state: stateType, action: actionType) => {
    const obj = { ...state }
    const SHOW_INDICATOR = 'SHOW_INDICATOR'
    const HIDE_INDICATOR = 'HIDE_INDICATOR'

    switch (action.type) {
        case SHOW_INDICATOR:
// SHOW_INDICATORが届いたらisShowをtrueにする
            console.log('SHOW_INDICATORが作動')
            obj.isShow = true
            console.log(obj)
            return obj
        case HIDE_INDICATOR:
// HIDE_INDICATORが届いたらisShowをfalseにする
            console.log('HIDE_INDICATORが作動')
            obj.isShow = false
            return obj
// どちらでもなければエラーを出す
        default: throw new TypeError(`Illegal type of action: ${action.type}`);
    }
}

const [state,dispatch] = useReducer(reducer,initialState)

useReducerの説明がめちゃめちゃわかりやすいサイト①
useReducerの説明がめちゃめちゃわかりやすいサイト②
useReducerの説明がめちゃめちゃわかりやすいサイト③
上記のサイトを参考にしました。

またこのreducerは前述の通りuseContextと相性が良く、子に渡すことができるため、子からdispatchを親での渡し方と同じように渡すことができます。

UserInfo.tsx
import * as React from 'react'
import { useState, useEffect, useContext } from 'react'
import axios from 'axios'
import { Store } from './App'

const UserInfo = () => {
    const [users, setUsers] = useState([])
// useContextすることにより子孫コンポーネントからdispatchが使える
    const { dispatch } = useContext(Store)

    // データの取得
    const getFormData = async () => {
        try {
//ここのdispatchで先祖のstateを変更させている
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            await axios.get('/d/users?f').then((res) => {

                if (res && res.data && res.data.length) {
                    setUsers(res.data)
                }
            }).then(() => {
                dispatch({ type: 'HIDE_INDICATOR' })
            })
        } catch (e) {
            alert('error:' + e)
        }
    }
    )
}

export default UserInfo
これにより、Index.tsxではisShowの参照を、UserInfo.tsxではisShowの更新を

propsを一切使うことなく実現することができています。

SUGOI!!!

Formコンポーネント(情報の新規登録)

Form.tsx
import * as React from 'react'
import { useState } from 'react'
import TextInput from './TextInput'
import RadioInput from './RadioInput'
import FormButton from './FormButton'
import TextareaInput from './TextareaInput'
import CheckboxInput from './CheckboxInput'
import SelectboxInput from './SelectboxInput'

const Form = (props: any) => {

    const [state, setState] = useState({
        name: '',
        gender: '',
        age: 0,
        address: '',
        password: '',
        email: '',
        postNumber: '',
        likeResidenceType: '',
        position: '',
        language: [],
    })

    // 登録ボタンを謳歌するとalertで出力する名前・性別・年齢をそれぞれ下記の定数に代入している

    // 名前
    const formName = state.name
    // 性別
    const formGender = state.gender
    // 年齢
    const formAge = Number(state.age)
    // 住所
    const formAddress = state.address
    // パスワード
    const formPassword = state.password
    // メールアドレス
    const formEmail = state.email
    // 郵便番号
    const formPostNumber = state.postNumber
    // 好きな住居形態
    const formLikeResidenceType = state.likeResidenceType
    // 役職
    const formPosition = state.position
    // 使用言語
    const formLanguage = state.language.toString()

    // prevStateにはe.target.valueが入ってくる
    const changeState = (prevState: string | number, stateType: string) => {

        const obj: any = { ...state }
        // 変数objにstateの内容をそのままコピーする
        const key = stateType
        // objのkeyをここで格納する。setしたいkeyはstateTypeで、子コンポーネントのprops.onChage関数の第二引数から渡される
        obj[key] = prevState
        // objのkeyプロパティのvalueをprops.onChange関数の第一引数に渡される値であるprevStateにする
        setState({ ...obj })
    }

    const addArrayState = (prevState: string | undefined) => {
        const obj: any = { ...state }
        const array: any = [...obj.language]
        console.log(array)

        // arrayの中にすでに値があった時の処理
        // 値を非破壊的メソッドで削除したい
        if (array.some((value: string) => value === prevState)) {
            const newArray = array.filter((array: string) => array !== prevState)
            obj.language = [...newArray]
            console.log(obj.language)
        } else {
            obj.language = [...array, prevState]
            console.log(obj.language)
        }
        setState({ ...obj })
        console.log(state)
    }

    const clickButton = (event: React.ChangeEvent<HTMLInputElement>) => {
        //親への通知
        event.preventDefault();
        props.click(formName, formGender, formAge, formAddress, formPassword, formEmail, formPostNumber, formLikeResidenceType, formPosition, formLanguage,)
    }

    return (
        <form>
            <TextInput label={'名前'} onChange={changeState} name="name" />
            <label>性別:</label>
            <RadioInput label={'男性'} gender={''} name="gender" onChange={changeState} checked={state.gender === ''} />
            <RadioInput label={'女性'} gender={''} name="gender" onChange={changeState} checked={state.gender === ''} />
            <TextInput label={'年齢'} name="age" onChange={changeState} />
            <TextareaInput label={'住所'} name="address" onChange={changeState} />
            <TextInput label={'パスワード'} name="password" onChange={changeState} />
            <TextInput label={'メールアドレス'} name="email" onChange={changeState} />
            <TextInput label={'郵便番号'} name="postNumber" onChange={changeState} />
            <SelectboxInput label={'好きな住居形態'} choice={[{ name: '一軒家' }, { name: 'マンション' }, { name: '' }]} name={'likeResidenceType'} onChange={changeState} />
            <TextInput label={'役職'} name="position" onChange={changeState} />
            <CheckboxInput label={'日本語'} name={'日本語'} onChange={addArrayState} checked={state.language.some(value => value === '日本語')} />
            <CheckboxInput label={'英語'} name={'英語'} onChange={addArrayState} checked={state.language.some(value => value === '英語')} />
            <CheckboxInput label={'中国語'} name={'中国語'} onChange={addArrayState} checked={state.language.some(value => value === '中国語')} />
            <FormButton onClick={clickButton} submitName={'登録する'} />
        </form>
    )
}

export default Form

Formコンポーネントは特に特筆すべき点はありません。
親コンポーネントであるappコンポーネントに入力した値をコールバック関数で渡しています

Form.tsx
const clickButton = (event: React.ChangeEvent<HTMLInputElement>) => {
        //親への通知
        event.preventDefault();
        props.click(formName, formGender, formAge, formAddress, formPassword, formEmail, formPostNumber, formLikeResidenceType, formPosition, formLanguage,)
    }
app.tsx
const app = () => {
    const [state, dispatch] = useReducer(reducer, initialState)
    const history = useHistory()

    const postFormData = async (name: string, gender: string, age: number, address: string, password: string, email: string, postNumber: string, likeResidenceType: string, position: string, language: string) => {
        const req: VtecxApp.Entry[] = [
            {
                // ここにはFormで入力した値が入ってくる
                "users": {
                    "name": name,
                    "gender": gender,
                    "age": age,
                    "address": address,
                    "password": password,
                    "email": email,
                    "post_number": postNumber,
                    "like_residence_type": likeResidenceType,
                    "position": position,
                    "language": language,
                },
            }
        ]
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            await axios.post('/d/users', req).then(() => {
                dispatch({ type: 'HIDE_INDICATOR' })
            })
            history.push('/')
        } catch (e) {
            dispatch({type:'HIDE_INDICATOR'})
            alert('error:' + e)
        }
    }

        return (
        <Router history={history}>
            <Switch>
                <Store.Provider value={{ state, dispatch }}>
                    <Index />
                    <Link to="/"><button>登録情報一覧へ</button></Link>
                    <Link to="/form"><button>新規登録画面へ</button></Link>
                    <Route exact path='/form' render={() => <Form click={postFormData} />} />
// この部分でpostFormDataが発火する
// componentでコンポーネントを指定するとpropsが渡せないため、renderを使ってコンポーネントを指定している。こうすることでporpsで情報を渡すことができる。
// このpropsの渡す方法がなかなか見つからずハマりました
                    <Route exact path='/' component={UserInfo} />
                    <Route exact path='/user-info-edit' component={UserInfoEdit} />
                </Store.Provider>
            </Switch>
        </Router>
    )


app.tsxでtry、catch構文でajax通信をしています。tryの始まりにdispatch({type:SHOW_INDICATOR})を送り、ajax通信が終わり次第dispatch({type:HIDE_INDICATOR})を送っています

またajax通信をする際にaxiosを使っています。
データを登録する際にはPOSTメソッドを使います。

まずtryメソッドの中で
おまじないである

axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'

を記述した後に

axios.post('/d/users',req)

とすることでデータを登録することができます。
reqにはFormから値をとってくるのですがその時の型としてVtecxApp.Entry[]を宣言しています。
この宣言によってvtecxのサービスの中で設定した型以外の型が入る可能性がある場合、コンパイルエラーで知らせてくれます。
型ファイルはtypingsフォルダの中にあるindex.d.tsです。

const req: VtecxApp.Entry[] = [
            {
                // ここにはFormで入力した値が入ってくる
                "users": {
                    "name": name,
                    "gender": gender,
                    "age": age,
                    "address": address,
                    "password": password,
                    "email": email,
                    "post_number": postNumber,
                    "like_residence_type": likeResidenceType,
                    "position": position,
                    "language": language,
                },
            }
        ]

UserInfo.tsx(情報一覧)

UserInfoはvte.cxに登録されているデータを一覧表示するコンポーネントです。

流れとしては

  1. useEffectを使って、初期レンダリング時と登録されているデータが変わった際にajax通信してデータを引っ張ってくる
  2. データをusersというstateに保存する
  3. 保存したデータをUserListという子コンポーネントにpropsで渡す
  4. UserList内でテーブルを用意してその中に親(UserInfo)からデータが渡ってきていれば表示する

以上が一連の流れとなっています。

UserInfo.tsx
import * as React from 'react'
import { useState, useEffect, useContext } from 'react'
import axios from 'axios'
import UserList from './UserList'
import { Store } from './App'

const UserInfo = () => {
    const [users, setUsers] = useState([])
    const { dispatch } = useContext(Store)
    // getしたデータは「?f」で取得していることから「配列」として設定する
    // stateが変わったときにgetFormDataが作動する
    useEffect(() => {
        getFormData()
        console.log('useEffectが作動')
    }, [users.length])
    //消去した後にusers.lengthが変化していないのが作動しない原因
    // grouptdatadeleteをした後にsetUsersをしなければいけない

    // データの取得
    const getFormData = async () => {
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'

            // resのデータの中身については型チェックしなくても良いがlengthチェックはしないといけない
            // 204がデータが入っていないHTTPステータス
            // ajax通信がされている時はisShowをtrueにして、完了したときにfalseにする
            await axios.get('/d/users?f').then((res) => {

                if (res && res.data && res.data.length) {
                    setUsers(res.data)
                }
            }).then(() => {
                dispatch({ type: 'HIDE_INDICATOR' })
            })
        } catch (e) {
            alert('error:' + e)
        }
    }
    return (
        <>
            <UserList info={users} getFormData={getFormData} />
        </>
    )
}

export default UserInfo

useEffect is 何

いつ、どんな条件だったら〇〇な処理を実行をしてくれるものです。

useEffectの説明がめちゃめちゃわかりやすいサイト
useEffectの使い分けがめちゃめちゃわかりやすいサイト

上記のサイトを参考にしました。

基本的な使い方として

useEffect(() => {
//処理
})

これが最小セットです。
第二引数が結構重要で、ここの設定でいつ実行をするかを決めます。

useEffect(() => {
//処理
},[])

上記のように空の配列を渡すと、初回登場時のみ処理を実行します。

他にも、何かの値が変わった瞬間に処理を実行したいという時もあると思います。
そういう時は

useEffect(() => {
//処理
},[])

とします。

今回の使い方としてはデータが格納されているusersというstateの配列の中の数が変化した場合にgetFormDataメソッドを実行するようにしています。

const [ users, setUsers ] = useState([])

useEffect(() => {
  getFormData()
},[users.length])

ちなみにusersはデータが入ってくると

[
    {
    "users": {
        "name": "ゴリラ",
        "gender": "男",
        "age": 8,
        "address": "上野動物園",
        "password": "uhouho",
        "email": "uho@gorira.com",
        "post_number": "111",
        "like_residence_type": "森",
        "position": "部長",
        "language": ""
    },
    "author": [{ "uri": "urn:vte.cx:created:20152" }], "id": "/users/345,1",
    "link": [{ "___href": "/users/345", "___rel": "self" }],
    "published": "2020-09-17T09:24:39.004+09:00", "updated": "2020-09-17T09:24:39.004+09:00"
    },
]

こんな感じのデータ構造になります。

getFormメソッドの中身を見てみましょう。

// データの取得
    const getFormData = async () => {
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'

            // resのデータの中身については型チェックしなくても良いがlengthチェックはしないといけない
            // ajax通信がされている時はisShowをtrueにして、完了したときにfalseにする
            await axios.get('/d/users?f').then((res) => {

                if (res && res.data && res.data.length) {
                    setUsers(res.data)
                    console.log(res.data)
                    console.log('hoge')
                }
            }).then(() => {
                dispatch({ type: 'HIDE_INDICATOR' })
                console.log(users)
            })
        } catch (e) {
            alert('error:' + e)
        }

データを取得するには下記のように値が格納されているエンドポイントの後に?fをつけることで取得できます。

await axios.get('/d/users?f')

またgetをすると値が帰ってくるのでthenメソッドでresとして渡しています。

res.dataには先ほどのusersに入ったデータが渡されます。

ここで受け取るデータは配列なのでがuseStateでの初期も必ず配列に設定しましょう。

const [users,setUsers] = useState([])

resとして受け取った後はresにちゃんと値が入っているかif文でチェックする必要があります。

if (res && res.data && res.data.length) {
    setUsers(res.data)
}

としてusersにデータを格納します。

usersはUserListという子コンポーネントにpropsで渡されます。

    return (
        <>
            <UserList info={users} getFormData={getFormData} />
        </>
    )

テーブルを作成するUserListコンポーネント

UserList.tsx
import * as React from 'react'
import { useState, useContext } from 'react'
import axios from 'axios'
import { Link } from 'react-router-dom'
import CheckboxInput from './CheckboxInput'
import FormButton from './FormButton'
import { Store } from './App'

const UserList = (props: any) => {
    const [state, setState] = useState({
        list: [],
    })
    const { dispatch } = useContext(Store)

    const addDeleteList = (prevState: string | undefined) => {
        const obj: any = { ...state }
        const array: any = [...obj.list]
        console.log(array)

        // arrayの中にすでに値があった時の処理
        // 値を非破壊的メソッドで削除したい
        if (array.some((value: string) => value === prevState)) {
            const newArray = array.filter((array: string) => array !== prevState)
            obj.list = [...newArray]
            console.log(obj.list)
        } else {
            obj.list = [...array, prevState]
            console.log(obj.list)
        }
        setState({ ...obj })
        console.log(state)
    }

    const deleteUsersGroupData = async (event: React.ChangeEvent<HTMLInputElement>) => {
        try {
            event.preventDefault()
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            await state.list.map(async (item: any) => {
                await axios.delete('/d' + item).then(() => {
                    dispatch({ type: 'HIDE_INDICATOR' })
                })
            })
            await props.getFormData()
            // ここで画面を一新したい
            console.log('deleteGroup')
        } catch (e) {
            alert('error:' + e)
        }
    }

    const info = props.info
    const infoItems = info.map((item: any, key: string) => (
        <tr>
            <Link to={{ pathname: '/user-info-edit', state: { href: item.link[0].___href, rel: item.link[0].___rel, userInfo: item.users, } }}><td key={key}><button style={{ width: '100%' }}>編集</button></td></Link>
            <td key={key}><CheckboxInput onChange={addDeleteList} name={item.link[0].___href} key={key} /></td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.name ? item.users.name : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.gender ? item.users.gender : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.age ? item.users.age : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.address ? item.users.address : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.password ? item.users.password : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.email ? item.users.email : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.post_number ? item.users.post_number : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.like_residence_type ? item.users.like_residence_type : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.position ? item.users.position : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.language ? item.users.language : '未登録'}</td>
        </tr>
    ))

    const tableHeads = ['編集', '削除', '名前', '性別', '年齢', '住所', 'パスワード', 'メールアドレス', '郵便番号', '好きな住居形態', '役職', '使用言語']

    const tableHeadItems = tableHeads.map((item, index) => {
        return (
            <th style={{ border: '1px solid black' }} key={index}>{item}</th>
        )
    })

    return (
        <>
            <table style={{ border: '1px solid black', borderCollapse: 'collapse', width: '100%', textAlign: 'center' }}>
                <tr style={{ border: '1px solid black' }}>
                    {tableHeadItems}
                </tr>
                {infoItems}
            </table>
            <FormButton onClick={deleteUsersGroupData} submitName={'消去する'} />
        </>
    )
}

export default UserList


props.infoをmap関数を使ってデータがあればデータを表示、なければ未登録として設定しています。

const infoItems = info.map((item: any, key: string) => (
        <tr>
            <Link to={{ pathname: '/user-info-edit', state: { href: item.link[0].___href, rel: item.link[0].___rel, userInfo: item.users, } }}><td key={key}><button style={{ width: '100%' }}>編集</button></td></Link>
            <td key={key}><CheckboxInput onChange={addDeleteList} name={item.link[0].___href} key={key} /></td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.name ? item.users.name : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.gender ? item.users.gender : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.age ? item.users.age : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.address ? item.users.address : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.password ? item.users.password : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.email ? item.users.email : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.post_number ? item.users.post_number : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.like_residence_type ? item.users.like_residence_type : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.position ? item.users.position : '未登録'}</td>
            <td style={{ border: '1px solid black' }} key={key}>{item.users.language ? item.users.language : '未登録'}</td>
        </tr>
    ))

ここでは三項演算子を使って

{item.users.name ? item.users.name : '未登録'}

としているのですが
最近論理演算子というものを知りまして、どんなものかというと
a || b
この場合aがtrueならaをfalseならbを表示します。
なので上のコードだと以下のようにしても問題ありません

{item.users.name || '未登録'}

むしろこっちの方がいい!

論理演算子がめちゃめちゃわかりやすいサイト

そしてUserListの中での目玉がこちらです!

<Link to={{ pathname: '/user-info-edit', state: { href: item.link[0].___href, rel: item.link[0].___rel, userInfo: item.users, } }}>
 <td key={key}>
  <button style={{ width: '100%' }}>
編集
  </button>
 </td>
</Link>

こちら編集画面に遷移するためのタグなのですが、遷移先ではデータの編集をするためにデータを渡さなければいけません。
ググってもreact-router-domでの値の渡す方法を解説しているサイトがなく詰まっていたのですが、序盤に紹介したreact-router-domの説明がめちゃめちゃわかりやすいサイトで解説されていました。

Linkタグの解説は

<Link to="遷移したいリンクパス" />

だいたいこれしか書いてないのですが

<Link to={{pathname:"遷移したいリンクパス",state:{遷移先に渡したい情報}}}

とすることで渡すことができます。

今回はデータを編集するにあたってhref情報とrel情報とユーザの情報が必要なのでそれらの情報が格納されている

href: item.link[0].___href
rel: item.link[0].___rel
userInfo: item.users

を渡します

これらには

[
    {
    "users": {
        "name": "ゴリラ",
        "gender": "男",
        "age": 8,
        "address": "上野動物園",
        "password": "uhouho",
        "email": "uho@gorira.com",
        "post_number": "111",
        "like_residence_type": "森",
        "position": "部長",
        "language": ""
    },
    "link": [{ "___href": "/users/345", "___rel": "self" }],
]

こういった情報が格納されています。
遷移先の編集画面で
「どのような情報のデータか」
「どのようなhref,rel属性を持っているのか」
を知るために必要になります。

userInfoEdit.tsx(情報編集)

userInfoEditは登録されている情報を編集するためのコンポーネントです。

userInfoEdit.tsx
import * as React from 'react'
import { useState, useContext } from 'react'
import TextInput from './TextInput'
import RadioInput from './RadioInput'
import FormButton from './FormButton'
import TextareaInput from './TextareaInput'
import CheckboxInput from './CheckboxInput'
import SelectboxInput from './SelectboxInput'
import { useLocation, useHistory } from 'react-router-dom'
import axios from 'axios'
import { Store } from './App'

type userInfoType = {
    name: '',
    gender: '',
    age: 0,
    address: '',
    password: '',
    email: '',
    postNumber: '',
    likeResidenceType: '',
    position: '',
    language: '',
}


const UserInfoEdit = () => {
    const [state, setState] = useState({
        name: '',
        gender: '',
        age: 0,
        address: '',
        password: '',
        email: '',
        postNumber: '',
        likeResidenceType: '',
        position: '',
        language: [],
    })
    const { dispatch } = useContext(Store)

    const history = useHistory()
    const locationValue: {
        state: {
            href: string,
            rel: string,
            userInfo: userInfoType
        }
    } = useLocation()
    let linkValue: string = ''
    let relValue: string = ''
    let userInfoValue: userInfoType = {
        name: '',
        gender: '',
        age: 0,
        address: '',
        password: '',
        email: '',
        postNumber: '',
        likeResidenceType: '',
        position: '',
        language: ''
    }
    if (locationValue.state === null || locationValue.state === undefined) {
        alert('不正なアクセスです')
        history.push('/')
    } else {
        linkValue = locationValue.state.href
        relValue = locationValue.state.rel
        userInfoValue = locationValue.state.userInfo
    }

    // 名前
    const formName = state.name
    // 性別
    const formGender = state.gender
    // 年齢
    const formAge = Number(state.age)
    // 住所
    const formAddress = state.address
    // パスワード
    const formPassword = state.password
    // メールアドレス
    const formEmail = state.email
    // 郵便番号
    const formPostNumber = state.postNumber
    // 好きな住居形態
    const formLikeResidenceType = state.likeResidenceType
    // 役職
    const formPosition = state.position
    // 使用言語
    const formLanguage = state.language.toString()

    const req: VtecxApp.Entry[] = [
        {
            users: {
                name: formName,
                gender: formGender,
                age: formAge,
                address: formAddress,
                password: formPassword,
                email: formEmail,
                post_number: formPostNumber,
                like_residence_type: formLikeResidenceType,
                position: formPosition,
                language: formLanguage
            },
            link: [
                {
                    ___href: linkValue,
                    ___rel: relValue
                }
            ]
        }
    ]

    const putFormData = async (event: React.ChangeEvent<HTMLInputElement>) => {
        try {
            event.preventDefault()
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            await axios.put('/d/users', req).then(() => {
                dispatch({ type: 'HIDE_INDICATOR' })
            })
            history.push('/')
        } catch (e) {
            alert('error:' + e)
        }
    }

    const deleteFormData = async (event: React.ChangeEvent<HTMLInputElement>) => {
        try {
            event.preventDefault()
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            await axios.delete('/d' + linkValue)
            history.push('/')
        } catch (e) {
            alert('error')
            console.log(e)
        }
    }

    // prevStateにはe.target.valueが入ってくる
    const changeState = (prevState: string | number, stateType: string) => {

        const obj: any = { ...state }
        // 変数objにstateの内容をそのままコピーする
        const key = stateType
        // objのkeyをここで格納する。setしたいkeyはstateTypeで、子コンポーネントのprops.onChage関数の第二引数から渡される
        obj[key] = prevState
        // objのkeyプロパティのvalueをprops.onChange関数の第一引数に渡される値であるprevStateにする
        setState({ ...obj })
    }

    const addArrayState = (prevState: string | undefined) => {
        const obj: any = { ...state }
        const array: any = [...obj.language]

        // arrayの中にすでに値があった時の処理
        // 値を非破壊的メソッドで削除したい
        if (array.some((value: string) => value === prevState)) {
            const newArray = array.filter((array: string) => array !== prevState)
            obj.language = [...newArray]
            console.log(obj.language)
        } else {
            obj.language = [...array, prevState]
            console.log(obj.language)
        }
        setState({ ...obj })
    }

    return (
        <>
            <table style={{ border: '1px solid black', borderCollapse: 'collapse', textAlign: 'center' }}>
                <tr style={{ border: '1px solid black' }}><th style={{ border: '1px solid black' }}>名前</th><td style={{ border: '1px solid black' }}>{userInfoValue.name ? userInfoValue.name : '未登録'}</td></tr>
                <tr style={{ border: '1px solid black' }}><th style={{ border: '1px solid black' }}>性別</th><td style={{ border: '1px solid black' }}>{userInfoValue.gender ? userInfoValue.gender : '未登録'}</td></tr>
                <tr style={{ border: '1px solid black' }}><th style={{ border: '1px solid black' }}>年齢</th><td style={{ border: '1px solid black' }}>{userInfoValue.age ? userInfoValue.age : '未登録'}</td></tr>
                <tr style={{ border: '1px solid black' }}><th style={{ border: '1px solid black' }}>住所</th><td style={{ border: '1px solid black' }}>{userInfoValue.address ? userInfoValue.address : '未登録'}</td></tr>
                <tr style={{ border: '1px solid black' }}><th style={{ border: '1px solid black' }}>パスワード</th><td style={{ border: '1px solid black' }}>{userInfoValue.password ? userInfoValue.password : '未登録'}</td></tr>
                <tr style={{ border: '1px solid black' }}><th style={{ border: '1px solid black' }}>メールアドレス</th><td style={{ border: '1px solid black' }}>{userInfoValue.email ? userInfoValue.email : '未登録'}</td></tr>
                <tr style={{ border: '1px solid black' }}><th style={{ border: '1px solid black' }}>郵便番号</th><td style={{ border: '1px solid black' }}>{userInfoValue.postNumber ? userInfoValue.postNumber : '未登録'}</td></tr>
                <tr style={{ border: '1px solid black' }}><th style={{ border: '1px solid black' }}>好きな住居形態</th><td style={{ border: '1px solid black' }}>{userInfoValue.likeResidenceType ? userInfoValue.likeResidenceType : '未登録'}</td></tr>
                <tr style={{ border: '1px solid black' }}><th style={{ border: '1px solid black' }}>役職</th><td style={{ border: '1px solid black' }}>{userInfoValue.position ? userInfoValue.position : '未登録'}</td></tr>
                <tr style={{ border: '1px solid black' }}><th style={{ border: '1px solid black' }}>使用言語</th><td style={{ border: '1px solid black' }}>{userInfoValue.language ? userInfoValue.language : '未登録'}</td></tr>
            </table>
            <form>
                <TextInput label={'名前'} onChange={changeState} name="name" />
                <label>性別:</label>
                <RadioInput label={'男性'} gender={''} name="gender" onChange={changeState} checked={state.gender === ''} />
                <RadioInput label={'女性'} gender={''} name="gender" onChange={changeState} checked={state.gender === ''} />
                <TextInput label={'年齢'} name="age" onChange={changeState} />
                <TextareaInput label={'住所'} name="address" onChange={changeState} />
                <TextInput label={'パスワード'} name="password" onChange={changeState} />
                <TextInput label={'メールアドレス'} name="email" onChange={changeState} />
                <TextInput label={'郵便番号'} name="postNumber" onChange={changeState} />
                <SelectboxInput label={'好きな住居形態'} choice={[{ name: '一軒家' }, { name: 'マンション' }, { name: '' }]} name={'likeResidenceType'} onChange={changeState} />
                <TextInput label={'役職'} name="position" onChange={changeState} />
                <CheckboxInput label={'日本語'} name={'日本語'} onChange={addArrayState} checked={state.language.some(value => value === '日本語')} />
                <CheckboxInput label={'英語'} name={'英語'} onChange={addArrayState} checked={state.language.some(value => value === '英語')} />
                <CheckboxInput label={'中国語'} name={'中国語'} onChange={addArrayState} checked={state.language.some(value => value === '中国語')} />
                <br />
                <FormButton onClick={putFormData} submitName={'更新する'} />
                <FormButton onClick={deleteFormData} submitName={'このデータを削除する'} />
            </form>
        </>
    )
}
export default UserInfoEdit

先ほどのUserListコンポーネントからデータを受け取って情報を画面に表示しています。

情報の参照方法は

import { useLocation } from 'react-router-dom'

const locationValue: {
        state: {
            href: string,
            rel: string,
            userInfo: userInfoType
        }
    } = useLocation()

としてlocationValueのなかにオブジェクトとして設定しています

またurlを直打ちした時に
編集画面に入れないように

if (locationValue.state === null || locationValue.state === undefined) {
        alert('不正なアクセスです')
        history.push('/')
    } else {
        linkValue = locationValue.state.href
        relValue = locationValue.state.rel
        userInfoValue = locationValue.state.userInfo
    }

locationValueに値が格納されていない場合、alertが出てトップページに強制送還されます。

次にこのコンポーネントではデータの削除とデータの更新ができるのですが
削除は

axios.delete(リンクパス + 個別のhref)

ここではdeleteFormDataというメソッドを使って下記のように記述しています

const deleteFormData = async (event: React.ChangeEvent<HTMLInputElement>) => {
        try {
            event.preventDefault()
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            await axios.delete('/d' + linkValue)
            history.push('/')
        } catch (e) {
            alert('error')
            console.log(e)
        }
    }

更新は

axios.put(リンクパス , 更新したいデータ)

ここではputFormDataというメソッドを使って下記のように記述しています

const putFormData = async (event: React.ChangeEvent<HTMLInputElement>) => {
        try {
            event.preventDefault()
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            await axios.put('/d/users', req).then(() => {
                dispatch({ type: 'HIDE_INDICATOR' })
            })
            history.push('/')
        } catch (e) {
            alert('error:' + e)
        }
    }

これでVte.cxを使った簡単なCRUDアプリを作ることができました。

感想

vte.cxはもちろん、React hooksやreact-router-dom,axiosを使ったAjax通信など初めて学んだのでかなり時間がかかりましたがSPAでBaaSを使ったCRUDができることになったのでかなり成長することができたと我ながら思っています。

コンポーネント設計やコードが洗練されていないのでそこも勉強していけたらなと思っています。

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

VSCodeでgatsbyのデバッグをする

今回の目的

gatsbyで作成したサイトのデバッグをVSCodeで実行できるように、VSCodeを設定していきます。

まずはVSCodeを開く!

gatsbyのサイトを作成しているディレクトリにてVSCOdeを開きます。
2020-09-18_10h01_13.jpg

Debugger for Chromeのインストール

VSCodeの拡張機能アイコンをクリックます。
『Debugger for Chrome』と入力し、対象機能をインストールします。
2020-09-18_10h05_20.jpg
※インストール完了後にVSCodeの再起動が必要かもしれません。

launch.jsonの作成

実行アイコンクリック→『launch.jsonファイルを作成します』リンククリックを行うと、環境の選択が出てきます。
後ほど書き換えるのでどれを選択しても問題ないです。
2020-09-18_10h15_35.jpg
作成されたlaunch.jsonを以下に書き換えます。

.vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Gatsby develop",
      "type": "node",
      "request": "launch",
      "protocol": "inspector",
      "program": "${workspaceRoot}/node_modules/gatsby/dist/bin/gatsby",
      "args": ["develop"],
      "stopOnEntry": false,
      "runtimeArgs": ["--nolazy"],
      "sourceMaps": false
    },
    {
      "name": "Gatsby build",
      "type": "node",
      "request": "launch",
      "protocol": "inspector",
      "program": "${workspaceRoot}/node_modules/gatsby/dist/bin/gatsby",
      "args": ["build"],
      "stopOnEntry": false,
      "runtimeArgs": ["--nolazy"],
      "sourceMaps": false
    },
    {
      "name": "Launch Chrome",
      "type": "chrome",
      "request": "launch",
      "url": "http://localhost:8000",
      "webRoot": "${workspaceFolder}"
    }
  ]
}

2020-09-18_10h47_39.jpg
実行構成が追加されています。

  • Gatsby develop
    gatsby developをデバッグ実行します。デプロイ処理などのデバッグを行います。
  • Gatsby build
    gatsby buildをデバッグ実行します。
  • Launch Chrome
    実行時にChromeを起動しhttp://localhost:8000にアクセスします。 モジュールの表示処理時などのデバッグを行います。

起動

Gatsby developを選択し、再生ボタン(緑三角ボタン)を押します。
develop処理のデバッグが開始されます。
2020-09-18_10h31_42.jpg
Launch Chromeを選択し、再生ボタンを押下するとchromeが自動で開き対象のサイトが確認できます。
2020-09-18_10h35_21.jpg

これでgatsbyサイトのデバッグが可能になりました。
あとは適当な箇所にブレークポイントを入れ、好きなようにデバッグしちゃってください。

今回は以上です。

ありがとうございました。

参考サイト

How to debug Gatsby.js in VS Code (build process and client side)

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

gatsbyのブログテンプレートにMaterial-UI入れて見栄えを良くする -色の統一-

今回の目的

githubにあるgatsbyのブログテンプレートにMaterial-UIを入れて見栄えを良くしていきたいと思います。
まずは基本の色を決めていきましょう。

前提条件

nodeやgatsbyなどはインストールしておいてください。
Windows10の環境準備は以下で参照できます。

gatsby入門 チュートリアルをこなす 0.開発環境をセットアップする

ブログテンプレートをダウンロード

好きなディレクトリにて以下コマンドを実行します。

gatsby new my-blog※任意 https://github.com/gatsbyjs/gatsby-starter-blog

ダウンロード後に一度ブログを起動してみましょう
以下コマンドを実行します。

cd my-blog(ダウンロードすると上記で入力した任意の名前のディレクトリが出来ています。)
gatsby develop

http://localhost:8000
2020-09-17_23h22_48.jpg
スーパーシンブル!
これをカスタマイズしていきます。

Material-UIを導入

コマンドを使用して、カレントディレクトリ(ここでいうmy-blog)にて以下コマンドを実行します。

npm install -D @material-ui/core 
npm install -D gatsby-plugin-material-ui

gatsby-plugin-material-uiはMaterial-UIをGatsbyでいい感じに利用することが出来るプラグインと考えてください。
※build時にプラグインがないとエラーになるような事をどこかにありましたが、私は再現しませんでした。

インストール完了後、gatsby-config.jsに以下を追記します。
gatsby-config.jsはカレントディレクトリ直下にあります。

gatsby-config.js
    (中略)
    `gatsby-plugin-react-helmet`,
    {
      resolve: `gatsby-plugin-typography`,
      options: {
        pathToConfigModule: `src/utils/typography`,
      },
    },
    `gatsby-plugin-material-ui`,←これを追記

これでMaterial-UIの導入は完了です。

色の設定を統一する

現時点はシンプルですが、今後機能が増えるにつれ色の設定がばらばらになる可能性があるのでサイトで使用する色を制限するためthemeを作成します。

色決め

下記サイトでいい感じに色が決めれます。
https://material.io/resources/color/#!/
私はこんな感じにしました。
2020-09-18_01h46_09.jpg

src/styles/theme.jsを作成

src/styles/theme.jsを作成し以下を追記します。

src/styles/theme.js
import { createMuiTheme } from '@material-ui/core/styles';

export const theme = createMuiTheme({
  palette: {
    primary: {
      light: '#b6ffff',
      main: '#81d4fa',
      dark: '#4ba3c7',
      contrastText: '#FFFFFF',
    },
    secondary: {
      light: '#ff93c1',
      main: '#ef6091',
      dark: '#b92a64',
      contrastText: '#000000',
    },
  },
});

これでこのサイトで使用する色が決まりました。
上記はまだまだカスタマイズ可能なようですがとりあえず簡単にいきましょう。
当然ですが、個別のスタイルで色を設定することをルール決めしないといけません。
複数人で作成している場合は、話し合いましょう。

色の設定を修正

それでは画面に反映していきます。

src/components/layout.jsを編集します。

src/components/layout.js
import React from "react"
import { Link } from "gatsby"

import { rhythm, scale } from "../utils/typography"

import {theme} from "../styles/theme";

const Layout = ({ location, title, children }) => {
  const rootPath = `${__PATH_PREFIX__}/`
  let header

  if (location.pathname === rootPath) {
    header = (
      <h1
        style={{
          ...scale(1.5),
          marginBottom: rhythm(1.5),
          marginTop: 0,
          backgroundColor: theme.palette.primary.main,←追記
        }}
      >
        <Link
          style={{
            boxShadow: `none`,
            color: theme.palette.primary.contrastText,←修正
          }}
          to={`/`}
        >
(中略)

2020-09-18_02h00_41.jpg
画面に色が付きました。
これで、色を付けたい場合はthemeから選ぶ(または背景色白の場合は何も指定しない。)

次回は

今回は以上です。
次回はMaterial-UIを使用してヘッダを追加しようと思います。

ありがとうございました。

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

ReactでPDF帳票作ってみた (react-pdf vs. Puppeteer)

みなさんこんにちは帳票弱いマンです。

フロントエンドライブラリはやっぱりReactが好きです。帳票を作る時もReactを使いたかったので、色々やった結果を書いてみます。

出力方式

2つの方式を検討・利用しました。

React-PDF

  • https://react-pdf.org/ (※私が利用したバージョンは4.x)
  • 専用のReact要素 + react-pdf用レンダラーを用いることによってPDFのBlobが出る方式。
  <Document>
    <Page size="A4" style={styles.page}>
      <View style={styles.section}>
        <Text>Section #1</Text>
      </View>
      <View style={styles.section}>
        <Text>Section #2</Text>
      </View>
    </Page>
  </Document>
  • flexレイアウト的な要素の柔軟な配置が可能。
  • 複数ページにまたがる印刷も可能。
  • 日本語もワードラップの問題などありつつも、対策すればちゃんと使える。
  • クライアントサイド(ブラウザ)でも動作可。
    • ただし、PDF中で使うTrueTypeフォントについては、クライアントサイドでDLしないといけないので、通信的にそこそこ重い。
  • もちろんNode.js上で動作可。
    • ただ、巨大な表をレンダリングすると、結構重い。
      • ちょっとしょぼいサーバでレンダリングすると10秒ぐらいかかったりする…
        • 完全にCPUがボトルネックになっているので、純粋にサーバをスケールアップすれば解決するといえば解決する
      • これがネックで私は検討から外しました。
      • あとメモリリークも観測しました。これについては、定期的にプロセスを再起動させればいいので、そこまで問題だとは思いません。

Puppeteer

  • https://github.com/puppeteer/puppeteer
  • Node.jsによるChromeオートメーションライブラリ
    • Node.jsからChromeを立ち上げてIPC(プロセス間通信)で操作するだけなので、別にJSでChromeが動いているわけじゃない。
    • Seleniumみたいなものと思えばOK
  • これ自体は別にPDFを出すソフトウェアではないが、下記のようにして、Reactを用いてPDFを出力することができる。
const html = renderToString(<MyReactApp />);
await page.setContent(html);
const blob = await page.pdf();
  • 以下の流れでPuppeteerにPDFを出力させる想定。
    1. Reactで帳票となるHTMLをレンダリングする
    2. そのHTMLをPuppeteerで立ち上げたブラウザのページに送りつけて表示させる
    3. ブラウザのページにPDFを出力させる
  • レンダリングに用いられたフォントはちゃんとPDFに組み込まれる。
    • これ大事!
  • 基本的にHTML + CSSで出来る装飾を用いることができる。
    • SVGも利用可能。最悪SVG使えばなんでもレンダリング可能。
    • PDFに出さなくてもHTMLである程度出力をチェック可能。
    • ReactDOM用のコードを帳票に流用可能。
      • 個人的にはこれが結構デカかった。
  • Node.js 上のみで動く。
    • クライアントサイドで動作不可。
      • あまりPDFをクライアントで出したいというケースがないのでそんなに困ることはないかと。
    • ヘッドレス(画面なし)で動作可能。
    • Dockerでも動く。Fargateなどのミニマルな環境でも動作可。

結局、PuppeteerからPDFを出力する方式を採用

当初はReact-PDFで帳票を作っていたんですが、下記の理由でPuppeteerを選びました。

  • 複雑な帳票はHTMLで作ったほうが早い
  • HTMLとレンダリングコードを共用できる
  • レンダリングのトラブルシューティングはDevToolsを使って解決することができる
  • Puppeteerの方がパフォーマンスがいい

考えてみれば、Google Chromeという人類最強最大のレンダリングソフトウェアを使わない理由はないですね。

Puppeteerで帳票出力する際のあれこれ

基本的には Print CSS の世界

PDFの内容については(Google Chromeが対応している)Print CSS仕様を用いることができます。

ここらへんについては下記のエントリが詳しいので、一読したほうがいいです。

そろそろ真面目に、HTMLで帳票を描く話をしようか

用紙のサイズが混在できない

たとえば用紙サイズはPuppeteerでPDF出力時に直接指定できます。

await page.pdf({ format: "A4" });

ただ、これだとページによって用紙サイズが違う場合困りますね。

それなら、Print CSSを使えば仕様上は用紙サイズを複数指定できそうだと思ったのですが、私が確認した限りだとページごとに違う用紙サイズというのはサポートされてないように見えました。

なので結局は、用紙サイズごとに分割してPDFを出して、後から結合すればOKです。

JS上でPDF結合するなら、 pdf-lib がおすすめです。

フォントが小さくならない

Chromeには実は最小フォントサイズの設定があります。CSS上でいくら小さな文字サイズを指定しようが、ブラウザの設定以上には縮まりません。

Puppeteerでも同様なので、8ptぐらいがフォントサイズ最小になっていると思います。

私は zoom: 50% がついている <div> の中に帳票をレンダリングすることで回避しました。これだと小さい文字は出ます。

(ブラウザのプロファイルをいじれば、こういう小細工しなくてもいける気がする)

線が1px以上細くならない

あきらめましょう。

(ちなみにSVGでレンダリングされた線はきちんと細くなります。)

Docker化したい

Puppeteerは普通にDocker化できます。Fargateみたいな環境でもきちんと動きます。

ただ、その際フォントをDockerイメージに入れておかないとフォントが効かないので、インストールしましょう。 (~/.fontsにフォント入れて、fc-cacheでフォントキャッシュ更新すればOK)

最新バージョンのPuppeteerのPDFレンダリングが遅い

今(2020/09/18)のところ新しいバージョン(5.x)のPuppeteerはPDFの出力が何故かすごく遅いです。 issue にもあがっています。

私が試したところ、 1.11.0 など古いバージョンだとPDFレンダリングが遅い問題には当たりませんでした。バージョンを下げてみることをおすすめします。

PuppeteerのHTMLレンダリングを高速にしたい

PDF出力抜きのHTMLレンダリングまでの工程(ブラウザ立ち上げ → ページ作成 → HTML読み込み → レンダリング完了) でも時間を結構浪費するので、ブラウザ立ち上げ・ページ作成まで、実際のPDFレンダリングの事前に完了させておくと速いです。

一度帳票レンダリングに使ったページオブジェクトはキャッシュさせておいてもいいと思います。

その上で、ページへのHTML読み込みについては、.innerHTML = ... が一番速かった です。

await page.evaluate((html) => (document.body.innerHTML = html), html);

(もちろんこの辺の高速化Trickはセキュリティに配慮して行いましょう。)

まとめ

今のところPuppeteerで帳票出力という自由度の高い方式に満足しています。

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

Reactルータ

Reactのルーターについての基本!!

基本形

import React from "react";
import { Link } from "react-router-dom";

export default function App() {
  return (
    <div>
      <Link to="/">Home</Link>
      <Link to="/about">About</Link>
      <Link to="/users">Users</Link>
    </div>
  );
}

コンポーネント

:exclamation:全てreact-router-domからimportする必要がある:helmet_with_cross:

1. routers

:point_up_tone1: <BrowserRouter>: 通常のURLパスを使用
:point_up_tone1: <HashRouter>: URLにhashを含むパスを使用(e.g. http://example.com/#/your/page)

2. Route Matchers

:point_up_tone1:<Switch>の中で<Route>を使う。現在のページが<Route>に記載されているページと同じならページ遷移しない。

:anchor:ポイント:anchor:
途中まで同じURLの時はより詳細の方を先に書く
:warning:Route exact path=を使えば書く順番は問わない。

<Route path="/contact/:id">
  <Contact />
</Route>
<Route path="/contact">
  <AllContacts />
</Route>

3. Navigation

:point_up_tone1:<NavLink>: パスが変わらなくても押せばアクティブになる

Scroll

:point_up_tone1: <ScrollToTop>TOPにスクロール

export default function ScrollToTop() {
  const { pathname } = useLocation();

  useEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);

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