20191211のReactに関する記事は17件です。

Reactで簡単アプリ開発した話

この記事は?

Reactを使って簡単なアプリを開発しました。

  • 開発の中で感じたことを残すことで、他の人がReactで開発する際のヒントになれば。
  • 自身の備忘録のため。

何を作ったのか

商品の金額をxx%offしてくれる計算機
https://nouka.github.io/price-calculator/

ソースコード

https://github.com/nouka/price-calculator

なぜ作ったのか

  • 身近に困っている人がいた。
  • React x TypeScriptの学習のため。

何に配慮したのか

  • サーバコストをかけないこと。
  • オフラインでも動作すること。
  • 設定値を端末に保持しておけること。

利用したライブラリなど

ライブラリ名 説明 使用した理由
create-react-app コマンド1つでReactアプリの雛形を作ってくれるツール Reactのプロジェクト作成時には必須。
オフライン動作するためのServiceWorkerも梱包されている。(利用するか否かは選べる)
redux, redux-localstorage アプリのデータを管理するフレームワーク redux-localstorageを使うと、Storeをローカルストレージに保存できる。
material-ui マテリアルデザインに沿ったUIコンポーネントを提供してくれるライブラリ アプリっぽいデザインにしたかったため。
なるべくデザインに時間をかけたくなかったため。
gh-pages コマンド1つで特定のファイル/ディレクトリをgh-pagesブランチにpushしてくれるツール npm run deployでGitHub Pagesに公開するため。

Tips

今後、考慮した方が良いと感じたこと

  • CSS-in-JSを使ったが、スタイルの量が多くなる場合はCSS-Modulesを使った方が、IDEのサポートが得られるので良いと思った。
  • Reducerを1つにしたが、複雑なアプリであれば確実に分けることになるので、この辺りは将来性も考えてどうするか最初に考えた方が良い。

躓いたこと

  • 計算の際に小数点の精度の問題が起こるので注意必要。
  • inputのvalueをintでキャストすると、入力値を全て削除できなくなる(0が勝手に入る)ため、注意。
  • GitHub Pagesでreact-routerを使う場合は、<Router basename={process.env.PUBLIC_URL}>とする。

よかったと感じたこと

  • TypeScript使うとPropTypesから解放される。typeの継承やインポートが使えるので、再利用性が増す。

難しいと感じたこと

  • react-reduxのconnectが分かりづらい。dispatchを毎回呼んだ方がわかりやすかった。
  • atomic design本当に難しくて、一旦共通のコンポーネントはsharedに入れることに。

開発してみた感想

  • 主に嫁しか使っていないが、喜んでいるので良かった。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactコンポーネントのデザイン変更検出をVisualRegressionTestにて楽しよう

本記事は、品川 Advent Calenderの14日目です。

はじめに

最近の流行りにあやかって、我々もReactを利用し、SPA(Single Page Application)の形式でWebアプリケーション開発を実施しています。コンポーネント設計にはAtomic Designを採用。開発者ら全員でStorybookにてコンポーネント管理したりと、イマドキ風(モダン)な開発にチャレンジしてます。成功したとは言えないですが、、

さて、Reactコンポーネントのデザイン変更のレビューをどうするかはチーム内の議論として必ず通る道ですよね。
変更箇所部分のスクショをプルリクに添付したりしますよね。
面倒ですね。怠惰な私には苦痛です。
Visual Regression TestをCIに含めて楽になりましょう。

TL;DR

  • Visual Regression TestをCIに含めるとCSSの変更をスクリーンショット比較できるようになるよ
  • Storybook、storycap、reg-suitで構築するのがおすすめ
  • ブラウザ試験で最後は全チェックしないと悲しい目にあうよ

今回のスペック

githubやS3を利用した方法についてはよく書かれてるので今回はGitlab、GCSにてお送りします。
まぁ、ほとんど何も変わらないんですが。。。

Visual Regression Test環境の作り方

さて、以下の順に説明していきます

  1. Reactアプリのベースを作成する
  2. Storybookを導入し、React Componentsを図版化する
  3. スクリーンショットを取れるようにする
  4. ブランチごとの差分レポートを作成できるようにする
  5. Gitlab CIに組み込む

Reactアプリのベースを作成する

さくっと、Reactのサンプル環境をつくりましょう。
私が大好きなmaterial-uiも一緒に入れちゃいます。

npx create-react-app vrt
cd vrt
yarn add @material-ui/core

サンプルアプリの各コンポーネントは以下のようなディレクトリ構造にしてみました。

...
├── src
│  ...
│   ├── components
│   │   ├── atoms
│   │   ├── molecules
│   │   ├── organisms
│   │   └── template
...

Storybookを導入し、React Componentsを図版化する

次に、Storybookを導入しましょう

yarn add @storybook/react -D
mkdir .storybook

configファイルは以下のように一旦しておきます

// .storybook/config.js
import { configure } from "@storybook/react";

const req = require.context("../src", true, /.stories.js$/);

configure(() => {
  req.keys().forEach(filename => req(filename));
}, module);

package.jsonのscriptsに以下を追加。

  "scripts": {
    // 以下追加
    "storybook": "start-storybook -p 9001 -c .storybook"
  }

次に、コンポーネントを作ってみましょう。今回は、material-uiのButtonコンポーネントに対しwithStyleにてスタイル適用をしています。

// src/components/atoms/Button.js
import { withStyles } from "@material-ui/core/styles";
import { Button } from "@material-ui/core";
import { blue, red } from "@material-ui/core/colors";

export const PrimaryButton = withStyles(theme => ({
  root: {
    color: theme.palette.getContrastText(blue[500]),
    backgroundColor: blue[500],
    "&:hover": {
      backgroundColor: blue[700]
    }
  }
}))(Button);

export const SecondaryButton = withStyles(theme => ({
  root: {
    color: theme.palette.getContrastText(red[500]),
    backgroundColor: red[500],
    "&:hover": {
      backgroundColor: red[700]
    }
  }
}))(Button);

作成したコンポーネントをStorybookに図版化しましょう

// src/components/atoms/Button.stories.js
import React from "react";
import { storiesOf } from "@storybook/react";
import { PrimaryButton, SecondaryButton } from "./Button";

storiesOf("Atoms", module).add("Button", () => (
  <>
    <PrimaryButton>Primary</PrimaryButton>
    <SecondaryButton>Secondary</SecondaryButton>
  </>
));

では、storybookを実行して、動作確認

yarn run storybook

http://localhost:9001/ にアクセスし、以下のように表示されてればStoryBookの設定は完了です。
image.png

スクリーンショットを取れるようにする

storycap を利用してstorybookのコンポーネントのスクリーンショットを撮る環境を作っていきましょう。

導入はstorybookを導入済みであれば簡単です。
パッケージをインストールして

yarn add @storybook/addons storycap -D

addonsに以下一行を記載し、

// .storybook/addons.js
import "storycap/register";

config.jsに対し、以下の修正を行い、

// .storybook/config.js
import { configure, addDecorator, addParameters } from "@storybook/react";
import { withScreenshot } from "storycap";

addDecorator(withScreenshot);
addParameters({
  screenshot: {
    viewports: {
      large: {
        width: 1024,
        height: 768,
      },
      small: {
        width: 375,
        height: 668,
      },
      xsmall: {
        width: 320,
        height: 568,
      },
    },
    delay: 100
  }
});

const req = require.context("../src", true, /.stories.js$/);

configure(() => {
  req.keys().forEach(filename => req(filename));
}, module);

最後にpackage.jsonに以下を追記しましょう

  "scripts": {
    // 以下追加
    "screenshot": "storycap --serverTimeout 60000 --captureTimeout 10000 --serverCmd 'start-storybook --ci -p 9009' http://localhost:9009"
  }

そして、以下コマンドを実行しましょう。

yarn run screenshot

yarn run v1.19.2
$ storycap --serverTimeout 60000 --captureTimeout 10000 --serverCmd 'start-storybook --ci -p 9009' http://localhost:9009
info Wait for connecting storybook server http://localhost:9009.
info Storycap runs with managed mode
info Found 1 stories.
info Screenshot stored: __screenshots__/Atoms/Button_large.png in 957 msec.
info Screenshot stored: __screenshots__/Atoms/Button_small.png in 935 msec.
info Screenshot stored: __screenshots__/Atoms/Button_xsmall.png in 965 msec.
info Screenshot was ended successfully in 21285 msec capturing 3 PNGs.

3件のスクリーンショットがとれましたね!
詳細はかっ飛ばしましたが、viewpointでスクリーンショットの幅を変更できます。
また、delayは画面表示までのdelayを表現しています。画像のDLが走る場合などにdelayが必要となるので、環境ごとに微修正したほうがいいですね。

他にもオプションがあるので、使い込む際には確認したほうがいいです。

ブランチごとの差分レポートを作成できるようにする

reg-suit でスクリーンショットに対するレポーティングを行えるようにしましょう。

我々がGithub Flowを利用しているため、レポーティング対象の範囲はdevelop <--> topic branch間となります。

yarn add reg-suit -D

今回利用するプラグインは以下の3つ

  • reg-notify-gitlab-plugin
  • reg-publish-gcs-plugin
  • reg-simple-keygen-plugin

以下コマンドで初期化しちゃいましょう。

yarn run reg-suit init

yarn run v1.19.2
[reg-suit] info version: 0.8.6
? Plugin(s) to install (bold: recommended) (Press <space> to select, <a> to togg
le all, <i> to inverse selection)
 ◯  reg-keygen-git-hash-plugin : Detect the snapshot key to be compare with usin
g Git hash.
 ◯  reg-notify-github-plugin : Notify reg-suit result to GitHub repository
 ◯  reg-publish-s3-plugin : Fetch and publish snapshot images to AWS S3.
 ◉  reg-notify-gitlab-plugin : Notify reg-suit result to GitLab repository
 ◯  reg-notify-slack-plugin : Notify reg-suit result to Slack channel.
 ◉  reg-publish-gcs-plugin : Fetch and publish snapshot images to Google Cloud S
torage.
 ◉  reg-simple-keygen-plugin : Determine snapshot key with given values

// 応対形式で以下を入力
? Working directory of reg-suit. .reg
? Append ".reg" entry to your .gitignore file. Yes
? Directory contains actual images. __screenshots__
? Threshold, ranges from 0 to 1. Smaller value makes the comparison more sensitive. 0.05
? Create a new GCS bucket Yes
? Update configuration file Yes
? Copy sample images to working dir No

上記の設問に答えていけば、regconfig.jsonが自動生成されます。
GCSのbucketも自動生成されます。ほんと、簡単ですね。

自動生成されたものに対し、すこし手を加えます。
xxxxxxxxxxxxxxxxは各自環境のに変更してください。

// regconfig.json
{
  "core": {
    "workingDir": ".reg",
    "actualDir": "__screenshots__",
    "thresholdRate": 0.05,
    "addIgnore": false,
    "ximgdiff": {
      "invocationType": "client"
    }
  },
  "plugins": {
    "reg-simple-keygen-plugin": {
      "expectedKey": "${EXPECTED_KEY}",
      "actualKey": "${ACTUAL_KEY}"
    },
    "reg-notify-gitlab-plugin": {
      "privateToken": "xxxxxxxxxxxxxxxx"
    },
    "reg-publish-gcs-plugin": {
      "bucketName": "xxxxxxxxxxxxxxxx"
    }
  }
}

package.jsonに以下を追記しましょう
環境変数EXPECTED_KEYにはdevelop BranchのCommit SHA-1、ACTUAL_KEYにはHeadのSHA-1が入ります。

  "scripts": {
    ...
    "regression": "export EXPECTED_KEY=$(git rev-parse origin/develop) && export ACTUAL_KEY=$(git rev-parse HEAD) && reg-suit run"
  }

追加したコマンドを実行しましょう。

yarn run regression
yarn run v1.19.2
$ export EXPECTED_KEY=$(git rev-parse origin/develop) && export ACTUAL_KEY=$(git rev-parse HEAD) && reg-suit run
[reg-suit] info version: 0.8.6
[reg-suit] info Detected the previous snapshot key: '2d10f24dd423d2864c63e273b50329a35f5d290f'
[reg-suit] info Comparison Complete
[reg-suit] info    Changed items: 0
[reg-suit] info    New items: 3
[reg-suit] info    Deleted items: 0
[reg-suit] info    Passed items: 0
[reg-suit] info The current snapshot key: '2d10f24dd423d2864c63e273b50329a35f5d290f'
                                         ■ 0% | ETA: 0s | 0/7[reg-publish-gcs-plugin] info Upload 7 files to reg-publish-bucket-15d912e6-838e-4d95-bf22-e40de3e532c4.
 ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 100% | ETA: 1s | 7/7
[reg-suit] info Published snapshot '2d10f24dd423d2864c63e273b50329a35f5d290f' successfully.
[reg-suit] info Report URL: https://storage.googleapis.com/reg-publish-bucket-xxxxxxxxxxx/xxxxxxxxxxx.html

Report URLを確認してみましょう。
以下の様に表示されてればOKですね!
image.png

これでVisualRegressionTest環境は完成です。

Gitlab CIに組み込む

まずは、Docker上では日本語が文字化けしちゃうので、日本語フォントを含めたDockerイメージを作っておきましょう。

FROM node:12

RUN apt-get update -y && apt-get install -yq gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \
    libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \
    libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
    libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \
    ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget && \
    mkdir /note && \
    cd /note && \
    wget https://noto-website.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip && \
    unzip NotoSansCJKjp-hinted.zip && \
    mkdir -p /usr/share/fonts/noto && \
    cp *.otf /usr/share/fonts/noto && \
    chmod 644 -R /usr/share/fonts/noto/ && \
    /usr/bin/fc-cache -fv && \
    cd / && \
    rm -rf /noto

このイメージをGitLab Container Registryに登録し、GitLab CI/CDからアクセスできるようにします。
.gitlab-ci.yamlに以下を追記すれば完成ですね!

visualRegressionTest:
  stage: test
  image: xxxxxxxxxxxx/vrt:latest
  script:
    - export EXPECTED_KEY=$(git rev-parse origin/develop)
    - export ACTUAL_KEY=$CI_COMMIT_SHA
    - yarn install
    - yarn screenshot
    - yarn regression

おわりに

さて、最後、怠惰な人間なため、説明が駆け足になってしまいました。申し訳ないです。
Visual Regression Testの導入にあたり、以下のポイントでつまりましたので、参考になればとおもいます。

  • storycapにてscreenshotを取るときはdelayの調整が必要になります
  • 日本語フォント入ってないdocker imageでスクリーンショット取ると、豆腐文字化けする。
    • 必要なフォントをいれて動かしましょう。
  • スクリーンショットにて画面崩れしてなくても、実機で確認は必須。。。
    • 画面サイズやブラウザによってデザイン崩れは発生するので、確認は必要ですね・・・

いかがでしょうか?
すこしでも楽になればとおもいます。

来年は、各種ブラウザでのデザイン変更チェックを自動化できるようにしたいですね。

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

なぜカスタムフックを作るのか

はじめに

本エントリは React アドベントカレンダー11日目の記事です。
余談ですが Costom Hook をカタカナで書くと途端にかっこ悪くなりますね。

対象

  • 普段は Class Component を使っているが Hooks に興味のある方
  • Hooks を使ったことがある方
  • カスタムフックを知っているが使い慣れていない方

背景

Hooks を使うことによって関数コンポーネントでも状態を持つことができるようになり、ここ1年で React による開発は Class Component から Function Component 中心に移り変わっています。Hooks のおかげで少ない記述量でコンポーネントを作ることができたり、thisbind の煩わしさから脱却できたりして開発者の体験を向上させてきました。

一方で、1つのコンポーネントで多くの Hooks を利用すると段々肥大化、複雑化していくという問題 (Class Component でも近いことが言える) がありますが、これはカスタムフックを作ることによって解決できます。

カスタムフックとは

カスタムフックは「コンポーネントからロジックを抽出して再利用可能な関数」のことです。
本来コンポーネント上にそのまま書く Hooks のロジックを関数に切り分けることができると言えばイメージしやすいかもしれません。

React 公式では次のようなサンプルが紹介されています。

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

なぜカスタムフックを作るのか

ここからはコードを交えながら本題の「なぜカスタムフックを作るのか」について紹介します。

ロジックを明示的にグループ分けするため

1つのコンポーネントに多くの state が含まれていると、どの state がどんな目的で何に利用されているのかひと目でわからないことがあります。この問題は、state や state を利用する関数を利用目的に沿ってグループ分けしてカスタムフックに抽出することによって解決できます。

これは僕が個人で開発した Web アプリケーションで使っているイラスト検索用のカスタムフックを少し改変したものです。

export const useIllustSearch = () => {
  const [hasNext, setHasNext] = useState(true)
  const [isLoading, setIsLoading] = useState(false)
  const [offset, setOffset] = useState(0)
  const [searchedIllusts, setSearchedIllusts] = useState<Illust[]>([])
  const [word, setWord] = useState('')

  const search = useCallback(async (searchWord: string) => {
    setIsLoading(true)
    const { illusts, nextUrl } = await searchIllusts({ word: searchWord })
    setSearchedIllusts(illusts)
    setWord(searchWord)

    if (!nextUrl) {
      setHasNext(false)
      return
    }

    const match = nextUrl.match(/\d+$/)
    if (!match) return
    setOffset(Number(match[0]))
    setIsLoading(false)
  }, [])

  const searchNext = useCallback(async () => {
    const { illusts, nextUrl } = await searchIllusts({ word, offset })
    setSearchedIllusts([...searchedIllusts, ...illusts])

    if (!nextUrl) {
      setHasNext(false)
      return
    }

    const match = nextUrl.match(/\d+$/)
    if (!match) return
    setOffset(Number(match[0]))
    setIsLoading(false)
  }, [offset, searchedIllusts, word])

  return { searchedIllusts, search, searchNext, hasNext, isLoading }
}

このフックには4つのステートと2つの関数が含まれています。これらをすべてコンポーネントに直書きすると、かなり見通しが悪くなることが予想されます。しかし、抽出してカスタムフックにすると次にようにコンポーネントに1行書き足すだけですべてのロジックを使用できるようになります。

const SearchPage = () => {
  // スッキリ✨
  const { searchedIllusts, search, searchNext, hasNext, isLoading } = useIllustSearch()

  // 省略
}

Redux / Context とコンポーネントを繋ぐため

react-redux 7.x からは useDispatchuseSelector といった Hook が導入され、以前までのように Container から connect する必要がなくなりました。また、React Hooks の Context API と useContext を使用すると Redux のように global state を管理できます。

ただし、これらをコンポーネントに直書きすると途端に見通しが悪くなります。そこで、global state を利用するロジックはカスタムフックに抽出してしまいましょう。

export type CounterState = { value: number }

export type CounterAction = { type: 'INCREASE' } | { type: 'DECREASE' }

const initialState: State = { value: 0 }

export const counterReducer = (state = initialState, action: CounterAction): CounterState => {
  switch (action.type) {
    case 'INCREASE':
      return { value: state.value + 1 }
    case 'DECREASE':
      return { value: state.value - 1 }
    default:
      return state
  }
}
export const useCounter = () => {
  const count = useSelector<RootState, CounterState>(({ counter }) => counter.value)
  const dispatch = useDispatch<Dispatch<CounterAction>>()

  const increase = () => {
    dispatch({ type: 'INCREASE' })
  }

  const decrease = () => {
    dispatch({ type: 'DECREASE' })
  }

  return { count, increase, decrease }
}

また、非同期処理を伴う場合はカスタムフックの中で読み込み状態やエラー内容を管理することも選択肢として考えられます。これは Naturalclar さんの You may not need redux-thunk に詳しく書かれています。

僕は状態管理をするときにドメイン単位で分割する re-ducks というデザインパターンに近い手法を取ることが多いのですが、各ドメインに対応したカスタムフックを作っておくとドメイン層とプレゼンテーション層を繋ぐ役割としてわかりやすいのでオススメです。

複数のコンポーネントで使われているロジックを共通化するため

以前まで複数のコンポーネントでロジックを共通化するときは、高層コンポーネント (HOC) を利用していました。しかし、高層コンポーネントを使うとコード複雑になるだけではなく、React Devtools を開いたときに複数のラッパーに覆われていて見づらくなります。

カスタムフックを使えば、高層コンポーネントと同じことをより使いやすく見通しがよい形で実現できます。今回は、React 公式が紹介している高層コンポーネントをカスタムフックに書き換えてみました。

// This function takes a component...
function withSubscription(WrappedComponent, selectData) {
  // ...and returns another component...
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // ... that takes care of the subscription...
      DataSource.addChangeListener(this.handleChange);
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }

    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }

    render() {
      // ... and renders the wrapped component with the fresh data!
      // Notice that we pass through any additional props
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}
  const useSubscription = (props, selectData) => {
    const [data, setData] = useState(selectData(DataSource, props))

    const handleChange = () => {
      setData(selectData(DataSource, props))
    }

    useEffect(() => {
      DataSource.addChangeListener(handleChange)
      return () => {
        DataSource.removeChangeListener(handleChange)
      }
    }, [])

    return data
  }

記述量が減っていることが一目瞭然です。もちろんコンポーネントの量が増えるわけでもないので、React Devtools 上で見通しが悪くなることはありません。

おわりに

ここまでカスタムフックを作る3つの理由を紹介しました。まだカスタムフックに慣れていない方が本エントリを読んで、試してみるきっかけになれば嬉しいです。何より任意のユースケースに合わせてキレイに作れると気持ちがいいので、これはいけそうと感じたらぜひ実践してみてください。

間違っている箇所や疑問点があればコメントか僕の Twitter で教えていただけると助かります。
明日は @bebetaro さんが Hooks のテストについて書かれるのでお楽しみに!(偶然ですが作る話からテストの話になる流れいいですね)

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

React.useRef()を使って無駄なレンダリングを減らそう

はじめに

本記事は、ReactのuseRefについて紹介する入門的記事です。公式に書いてある内容の焼き直しみたいな物なので、ちょっとrefについて理解が怪しいかな?と思う方が読者対象になります。

React refの基本的な使い方・登場シーン

さて、refの主な登場シーンとしてはDOMに紐付けて使う場合が多いのではないでしょうか。

inputとref(CodeSandbox)

Focus Inputボタンを押すとinputエリアにフォーカスします。

const App = () => {
  const textInputRef = useRef();
  return (
    <div className="App">
      <h1>useRef with Text Input</h1>
      <button onClick={() => textInputRef.current.focus()}>Focus Input</button>
      <input ref={textInputRef} type="text" />
    </div>
  );
};

videoとref(CodeSandbox)

初回レンダリング時にvideo srcが設定され、videoはmp4ファイルを再生できる状態でレンダリングされます。

const App = () => {
  const videoRef = useRef();
  useEffect(() => {
    videoRef.current.src =
      'https://sample-videos.com/video123/mp4/240/big_buck_bunny_240p_10mb.mp4';
  }, []);
  return (
    <div className="App">
      <h1>useRef with Video</h1>
      <video ref={videoRef} controls width={360} height={240} />
    </div>
  );
};

DOMと組み合わせるだけがrefの全てではない(おおげさ)

ここからが、記事タイトルについての中身になります。
useState()はlocal stateを管理できるHooksですが、useState()で用意したstate変数の更新用関数を呼ぶと、その時点で再レンダリングが走ります。以下で言うところのsetState関数が更新用関数ですね。

const [state, setState] = useState();

そのため、再レンダリングが必要ない値の管理にuseState()を使うと無駄なレンダリングが走ることになってしまいます。(シンプルな処理だと実害は少ないかと思います)

そこでuseRef()です。
APIドキュメントFAQにもあるように、useRef()で生成したref変数は値を保持し続けるオブジェクトとして利用できます。

const ref = useRef();

生成されたref変数は{ current: ... }というオブジェクトを与えられ、このref.currentを自由に書き換えることが許されているのですが、これを書き換えた際には再レンダリングが走らないという特性を持ちます。(特性というか単なるJSのオブジェクト変数の書き換えなのですが)
なので、先に述べたように再レンダリングは必要なく値のみを変更して保持するために使えたり、コンポーネントの前後のレンダリング間で値を保持し続けたい場合に、useRef()が使えます。
ということが本記事の伝えたいことになります。なんと。まぁ、公式にも書いてあるんですが。

useState()とuseRef()の比較

最後に、似たような記述内容を書いてuseState()とuseRef()を見比べてみます。
まずはuseState()の場合です。
以下のコードは無限にレンダリングが起こります。
1回目のレンダリング処理でsetText('hoge');が呼ばれると、空文字からhogeへtext変数が更新されて再レンダリングが走ります。そして、それ以降もレンダリングの度にsetText('hoge');が呼ばれるので無限レンダリングとなります。

const Text = () => {
  const [text, setText] = useState('');
  setText('hoge');
  return <div>{text}</div>;
}

次に、useRef()を同じような記述構成で書いてみます。
結果は、1回のレンダリングのみでhogeが表示されます。

const Text = () => {
  const textRef = useRef('');
  textRef.current = 'hoge';
  return <div>{textRef.current}</div>;
}

特に意味のないコードですが、理解のための例でした。

おわりに

公式ドキュメントのおさらいでしたが、APIの用途を知っておくと今後の開発でも役に立つと思います。
是非、無駄なレンダリングが走ってる場面に遭遇したり、レンダリング間で値を保持しい場面に遭遇したらuseRef()のことを思い出してみてください。

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

Reactivesearch v3でいい感じの検索SPAを30分ぐらいで作る

この記事はElastic Stack (Elasticsearch)その2 Advent Calendar 2019の11日目の記事です。

記事の内容を始める前に

去年のAdvent Calendar記事でReactivesearchの使い方を紹介したのですが、2019/8/28にReactivesearch 3.0.0がリリースされ、前回の記事の内容そのままでは動作しなくなってしまいました。

では、新バージョンを無視してそのまま古いバージョンを使い続けられるのかというと、Reactivesearch 2.x系は現在のメジャーバージョンであるElasticsearch 7.x系に対応していないため、真面目にアップグレードしている人たちやこれからElasticsearchを使う人であればReactivesearch 3.x系をオススメしたい状況です。

そこで、去年のAdvent Calendar記事をReactivesearch 3.xに対応する形で書き換えてみました。コード以外の内容は同じなので、既に読んだことがある方はスルーしちゃっても良いと思います。
まだ前回の記事を読んだことがない方や、これから実際にReactivesearch 3.x系を試してみようという方は是非こちらの記事を読んでいただけますと幸いです。

tl;dr

こんな感じのSPAを30分ぐらいで作ります。1

はじめに

Elasticsearchを使った検索WEBアプリを作りたいんだよねぇ」
『はぁ』
「データ入れたらすぐに高速な検索できちゃうんでしょ?」
『いや、すぐにという訳には...』
「あとさ、ユーザ体験を良くするためにSPAでお願いしたいんだよねぇ」
『(また突拍子もないことを言い始めたぞ...)』
「というわけで検討よろしく!検索対象にしたいデータはCSVで送っとくから」
『とりあえず検討してみます?』

こんな時、皆さんならどうしますか?
ガッツリ画面を作って検索クエリも自前でガリガリ書いて頑張りますか?

それも一つの方法ではありますが、PoCや小さい案件であれば、もっと楽に早く実現できる方法があればそっちを使いたい感じです。
そして、この場合はそれがあります。

React.jsVue.jsのUIコンポーネントとして使えるReactivesearchです。
こいつは幾つかの設定を記載するだけで Elasticsearchに対する検索クエリの組み立てや発行も自動でやってくれる 優れものです。Elasticsearchにデータが投入済みであれば、慣れると数分で簡単な検索SPAを実装できます。

今回はSteamのゲーム検索UIを作るチュートリアルを通して、Reactivesearchの使い方を紹介してみます。

完成済みのコードはこちらのリポジトリから確認できます。

https://github.com/j-yama/reactivesearch-steam-example-v3

Reactivesearchとは

reactivesearch.png

ReactivesearchはAppbase.ioが提供するReact.js/Vue.jsで使える検索UIコンポーネントです。
名前がReact.js専用っぽい響きですが、Vue.jsでも使えます

Reactivesearchの特徴はなんと言ってもElasticsearch専用に作り込まれていることです。
Elasticsearchに投入されているデータに対して、 検索クエリの組み立てや発行なども代わりにやってくれます
つまり、これを使えばElasticsearch初学者が陥りがちな 「Elasticsearchの検索クエリを覚えるのが大変」「検索クエリをJSONで書くのがつらい」…という問題を回避 できます。

ちなみに、設定として最低限必要なのは以下の3項目ぐらいなものです。

  • Elasticsearchのホスト
  • 検索対象のインデックス名
  • 検索対象のフィールド名

たったこれだけの項目を設定するだけで面倒なことを考えずに検索SPAが作れちゃいます。掛け値なしに最高です。

今回使う検索用データを投入する(10分)

では、ここからチュートリアルを進めていきます。
まずはデータセットを用意して、Elasticsearchに投入します。

1. 今回使うデータセットをダウンロードする

https://s3.amazonaws.com/public-service/steam-data.tar.gz

この中のgames-features.csvが今回使用するデータセットです。
別に怪しいデータではなくて、どのように取得されたデータであるかは以下のリポジトリで確認可能です。
何なら自分で取得し直してみても良いと思います。

GitHub - CraigKelly/steam-data: A simple data project for Steam data

2. ElasticsearchとKibanaを起動する

ElasticsearchとKibanaのインストール、起動の手順に関しては他の記事に譲ります。

ただし、今回ローカルで起動する場合はCORSに引っかからないようelasticsearch.ymlに次の設定を追加してから起動してください。

http.cors.enabled: true
http.cors.allow-credentials: true
http.cors.allow-origin: "*"
http.cors.allow-headers: X-Requested-With, X-Auth-Token, Content-Type, Content-Length, Authorization, Access-Control-Allow-Headers, Accept

3. Data Visualizer経由でCSVをimportする

Kibana 6.5からData Visualizer経由で100MBまでのCSVであればElasticsearchにデータをインポートできるようになりました。
中々便利なので、今回はこれでインポートしてみます。6.4以前を使っている場合はLogstashなど他のツールでCSVをインポートしてください。

メニューのMachine Learning -> Data Visualizerをクリックします。

data-visualizer-for-file-input.png

画面中央にgames-features.csvをドラッグアンドドロップします。

data-visualizer-for-file-analysis.png

すると、データを解析して、CSVであることを解釈し、それぞれのカラムについてのデータ傾向を表示してくれます。
インポート目的ではなくてデータの傾向を把握したい時なんかにも便利です。

軽く眺めて満足したら、左下の"Import"をクリックします。
インポート設定画面に飛んだら、今回はingest pipelineを実行したい部分があるので、"Advanced"をクリックします。

data-visualizer-for-file-ingest-pipeline.png

実は、今回のデータセットでは一部ゲームデータが重複しています。
したがって、ゲームのIDであるResponseIDというフィールドをドキュメントidに設定するingest pipelineを実行することで、重複をなくします。

画面右側のingest pipelineに以下のコードを入力します。

{
  "processors" : [
    {
      "set" : {
        "field" : "_id",
        "value" : "{{ResponseID}}"
      }
    }
  ]
}

その後、"Import"をクリックします。

data-visualizer-for-file-result.png

無事インポートされました。
そのままDiscoverなどでも閲覧できます。

kibana-discover.png

これでデータ側は準備完了です。

React.jsアプリの雛形を作って起動する(5分)

次に、React.jsアプリを作る前準備を実行します。
ほぼreact-create-appの実行とreactivesearchをインストールする時間です。

前提条件

以下のツールがインストール済みの環境であること
※参考までに動作確認時のバージョンを記載してますが、違うバージョンでも普通に動くと思います。

  • Elasticsearch 7.4.0
  • Kibana 7.4.0
  • Node.js v12.11.1
  • npm 6.11.3

1. create-react-appしてReactアプリの雛形を生成する

実行が終わるまで気長に待ちます。

npx create-react-app steam-search

2. Reactivesearchをインストールする

こっちも依存関係が多くて時間がかかりますが、気長に待ちます。
なお、本記事執筆時点での@appbaseio/reactivesearch@2.14.1ではwsのバージョンが古いため、npm auditで警告が出ます。このバージョンをプロダクションで使うのは控えた方が良いかもしれません。

cd steam-search
npm install @appbaseio/reactivesearch
  1. Reactアプリを起動する
npm start
  1. ブラウザで http://localhost:3000 にアクセスしてReactアプリの初期画面が表示されることを確認する

react.png

これでアプリ側も準備完了です。
以後、React.jsアプリは起動したまま、修正を加えていきます。

Reactivesearchでシンプルな検索GUIを作ってみる(5分)

ではまず、ゲームのタイトルを全文検索して、結果にゲームタイトルを表示する簡易的な検索UIを作ってみます。

1. 以下のコードをsrc/App.jsにコピペする

元々のsrc/App.jsのコードは全部削除して上書き保存します。

import React, {Component} from 'react';
import {DataSearch, ReactiveBase, ReactiveList, ResultList, SelectedFilters} from '@appbaseio/reactivesearch';
import './App.css';

const { ResultListWrapper } = ReactiveList;

class App extends Component {
    render() {
        return (
            <div className="main-container">
                <ReactiveBase
                    app="steam-search"
                    url="http://localhost:9200"
                    credentials="elastic:changeme"
                >
                    <DataSearch
                        componentId="title"
                        dataField={["ResponseName"]}
                        queryFormat="and"
                    />
                    <SelectedFilters/>
                    <ReactiveList
                        componentId="resultLists"
                        dataField="ResponseName"
                        size={10}
                        pagination={true}
                        react={{
                            "and": ["title"]
                        }}
                    >
                        {({data}) => (
                            <ResultListWrapper>
                                {
                                    data.map(item => (
                                        <ResultList key={item._id}>
                                            <ResultList.Content>
                                                <ResultList.Title
                                                    dangerouslySetInnerHTML={{
                                                        __html: item.ResponseName
                                                    }}
                                                />
                                            </ResultList.Content>
                                        </ResultList>
                                    ))
                                }
                            </ResultListWrapper>
                        )}
                    </ReactiveList>
                </ReactiveBase>
            </div>
        );
    }
}

export default App;

すると、画面上 (http://localhost:3000) の表示は以下のように変化します。

Screenshot_2018-12-15 React App(4).png

もし画面を開いた瞬間に結果が"No Results found."と表示される場合は以下の可能性が考えられます。環境を見直してください。

  • Elasticsearchが起動していない
  • Elasticsearchに今回のデータセットが"steam-search"というインデックス名で投入できていない
  • CORS回避の設定をElasticsearchに設定せず起動してしまった

2.挙動の確認

"Search"部分に文字を入力し始めるとゲームタイトルのオートコンプリートがサジェストされます。

Screenshot_2018-12-15 React App(3).png

また、"Search"部分で文字を入力してEnterキーを押下するか、オートコンプリートのサジェストを選択すると、該当の文字列が全文検索の条件になり、検索結果が変更されます。

Screenshot_2018-12-15 React App(2).png

画面上部の"title: fez"と表示されている検索条件や"Clear All"と表示されているバッジをクリックすると、その条件を削除できます。

また、スペース区切りで文字を入力すると、AND条件で検索されます。

Screenshot_2018-12-15 React App(6).png

これで見た目はさておき、以下を実現できました。

  • ゲームタイトルに対する全文検索
    • 文字を入力し始めるとゲームタイトルのオートコンプリートがサジェストされる
    • スペース区切りで入力するとAND条件で検索される
  • 設定されている検索条件の表示
  • 検索結果としてゲームタイトルを表示する

2. コードの解説

ReactivesearchはReactiveBaseというコンポーネントを最上位コンポーネントとして持ち、その中に検索条件入力用のコンポーネントや結果表示用のコンポーネントを配置していきます。イメージとしては以下の形に必ずなります。

{/* 必ず最上位コンポーネントとしてReactiveBaseを配置する */}
<ReactiveBase>

  {/* 検索条件入力用のコンポーネント */}
  <HogeSearch />

  {/* 検索結果表示用のコンポーネントはReactiveListの下に配置する */}
  <ReactiveList>
    <ResultHoge/>
  </ReactiveList>

</ReactiveBase>

イメージコードでは適当なコンポーネント名にしていますが、それぞれ使用できるコンポーネントは様々な種類が用意されています。詳しくはドキュメントを参照してください。

今回のコードではReactiveBaseの内部で3種類のコンポーネントを使用しています。それぞれの役割を以下に示します。

  • DataSearch
    • オートコンプリート付きの全文検索フォームの提供
  • SelectedFilters
    • 設定されている検索条件の表示と解除
  • ResultList
    • 検索結果の表示

画面上で言うと以下のイメージです。

Screenshot_2018-12-15 React App_mod.png

この3種類のコンポーネントに幾つかのプロパティ(Props)を設定するだけで、良い感じに検索できるようになります。
それぞれのコンポーネントで設定しているPropsについて説明します。

ReactiveBase

Props 今回の値 説明 備考
url "http://localhost:9200" ElasticsearchのHTTPホスト。 -
app "steam-search" 検索対象のインデックス名。 -

DataSearch

Props 今回の値 説明 備考
componentId "title" コンポーネントに付与する一意なID。 -
dataField {["ResponseName"]} 検索対象のフィールド名。 複数指定可。
queryFormat "and" スペース区切りで入力された時にOR条件にするかAND条件にするか。 設定しない場合の初期値は"or"。

ResultList(ReactiveList)

Props 今回の値 説明 備考
componentId "resultLists" コンポーネントに付与する一意なID。 -
pagination {true} 結果をページネーションで表示するかどうか。 設定しない場合の初期値は{false}で、画面最下部までスクロールすると随時下に結果が追加される表示になる。
size {25} ページネーションで1ページに表示する件数。 -
react {{ "and": ["title"] }} どの検索条件に反応して結果を表示するか。 該当の検索条件入力用コンポーネントで設定した"componentId"を指定する。

なんとなく、Reactivesearchで最低限の実装を実現する方法が理解できたでしょうか?

機能を追加していく(5分)

次に、基本的な機能に加えて以下を追加していきます。

  1. 結果表示にゲーム画像と発売時期、価格を表示し、クリックしたらゲームストアへリンクするようにする
  2. 発売価格によるソート機能

1. 結果表示にゲーム画像と発売時期、価格を表示し、クリックしたらゲームストアへリンクするようにする

今回のデータセットにはゲームに関連した以下の情報が含まれています。

フィールド名 説明
HeaderImage ヘッダー画像のURL。 http://cdn.akamai.steamstatic.com/steam/apps/224760/header.jpg?t=1472521163
ReleaseDate 発売日。 May 1 2013
PriceInitial 発売価格。単位はUSドル。 9.99
ResponseID Steamにおける該当ゲームのID。ストアでのゲームのURLに含まれる。 224760

これらのフィールドを使って、検索結果の表示を少しだけリッチにしてみます。

ResultListを以下のように変更します。

      <ResultList
          key={item._id}
+         href={`https://store.steampowered.com/app/${item.ResponseID}`}
      >
+         <ResultList.Image src={item.HeaderImage}/>
          <ResultList.Content>
              <ResultList.Title
                  dangerouslySetInnerHTML={{
                      __html: item.ResponseName
                  }}
              />
+             <ResultList.Description>
+                 <p className="releaseDate">${item.ReleaseDate}</p>
+                 <p className="price">$${item.PriceInitial}</p>
+             </ResultList.Description>
          </ResultList.Content>
      </ResultList>

画面を確認してみます。

Screenshot_2018-12-15 React App(8).png

ゲームタイトルに加えて、先ほどの情報を追加表示できるようになりました。
また、結果をクリックすることで、Steamの該当ゲームのストアページへ飛ぶことが可能になりました。

2. 発売価格によるソート機能

ReactiveListのPropsとしてsortOptionsを追加します。

     <ReactiveList
         componentId="resultLists"
         dataField="ResponseName"
         size={10}
         pagination={true}
         react={{
             "and": ["title"]
         }}
+        sortOptions={[
+            {label: "Best Match", dataField: "_score", sortBy: "desc"},
+            {label: "Lowest Price", dataField: "PriceInitial", sortBy: "asc"},
+            {label: "Highest Price", dataField: "PriceInitial", sortBy: "desc"},
+        ]}
     >

画面を確認します。

Screenshot_2018-12-15 React App(9).png

検索ワード入力欄の右下にソート用のセレクトボックスが追加されました。
Best Matchはクエリ発行結果の_scoreの降順に並べる設定です。

Screenshot_2018-12-15 React App(10).png

Lowest Priceは発売価格の昇順なので、検索結果の序盤には無料ゲームが並んでいます。

Screenshot_2018-12-15 React App(11).png

Highest Priceは発売価格の降順なので、検索結果の序盤には高価な制作者向けツールやバンドルなどが並んでいます。

CSSで画面を調整する(5分)

ここまできたら後はCSSで見た目を調整してフィニッシュです。

1. App.jsの修正

まずApp.jsを以下のように変更します。

  import React, {Component} from 'react';
  import {DataSearch, ReactiveBase, ReactiveList, ResultList, SelectedFilters} from '@appbaseio/reactivesearch';
  import './App.css';
+ import './SteamSearch.css'

  const {ResultListWrapper} = ReactiveList;

  class App extends Component {
      render() {
          return (
              <div className="main-container">
                  <ReactiveBase
                      app="steam-search"
                      url="http://localhost:9200"
                      credentials="elastic:changeme"
+                     theme={
+                         {
+                             typography: {
+                                 fontFamily: 'Arial, Helvetica, sans-serif',
+                                 fontSize: '16px',
+                             },
+                             colors: {
+                                 titleColor: '#c7d5e0',
+                                 textColor: '#c7d5e0',
+                                 backgroundColor: '#212121',
+                                 primaryColor: '#2B475E',
+                             }
+                         }
+                     }
                  >
                      <DataSearch
                          componentId="title"
                          dataField={["ResponseName"]}
                          queryFormat="and"
+                         placeholder="enter search term"
+                         showIcon={false}
+                         title="Steam Search"
+                         className="data-search"
+                         innerClass={{
+                             input: 'input',
+                             list: 'list',
+                         }}
                      />
                      <SelectedFilters/>
                      <ReactiveList
                          componentId="resultLists"
                          dataField="ResponseName"
-                         size={10}
+                         size={25}
                          pagination={true}
                          react={{
                              "and": ["title"]
                          }}
                          sortOptions={[
                              {label: "Best Match", dataField: "_score", sortBy: "desc"},
                              {label: "Lowest Price", dataField: "PriceInitial", sortBy: "asc"},
                              {label: "Highest Price", dataField: "PriceInitial", sortBy: "desc"},
                          ]}
+                         className="result-list"
+                         innerClass={{
+                             resultsInfo: "resultsInfo",
+                             resultStats: "resultStats",
+                         }}
                      >
                          {({data}) => (
                              <ResultListWrapper>
                                  {
                                      data.map(item => (
                                          <ResultList
                                              key={item._id}
                                              href={`https://store.steampowered.com/app/${item.ResponseID}`}
+                                             className="listItem"
                                          >
-                                             <ResultList.Image src={item.HeaderImage}/>
+                                             <ResultList.Image className="image" src={item.HeaderImage}/>
                      (以下省略)

まず、ReactiveBasethemeを設定することで、配下のコンポーネントの共通設定を変更します

次に、ページネーションの表示数や検索フォームのプレースホルダをSteamの検索ページに合わせたりします。

あとは、各コンポーネントにinnerClassを渡すことで、各コンポーネント内部のパーツにclass名を付けます。これで、CSSセレクタで指定しやすくなります。どのパーツにclass名をつけることができるかは各コンポーネントのドキュメントのStylesに記載されています。

2. CSSをあてる

次にsrc/SteamSearch.cssを新たに作成します。

@font-face {
    font-family: "IonIcons";
    src: url("//code.ionicframework.com/ionicons/2.0.1/fonts/ionicons.eot?v=2.0.1");
    src: url("//code.ionicframework.com/ionicons/2.0.1/fonts/ionicons.eot?v=2.0.1#iefix") format("embedded-opentype"), url("//code.ionicframework.com/ionicons/2.0.1/fonts/ionicons.ttf?v=2.0.1") format("truetype"), url("//code.ionicframework.com/ionicons/2.0.1/fonts/ionicons.woff?v=2.0.1") format("woff"), url("//code.ionicframework.com/ionicons/2.0.1/fonts/ionicons.svg?v=2.0.1#Ionicons") format("svg");
    font-weight: normal;
    font-style: normal
}

html {
    min-height: 100%;
}

body {
    background-color: #1B3C53;
    background-image: linear-gradient(315deg, #000000 0%, #1B3C53 74%);
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
}

.main-container {
    width: 616px;
    margin-top: 20px;
}

.data-search .input {
    color: white;
}

.data-search .list li {
    background-color: #aaa !important;
    color: #000;
    overflow: hidden;
}

.data-search .list li:target {
    background-color: #666 !important;
}

.resultsInfo {
    position: relative;
}

.resultsInfo:before {
    z-index: 1;
    position: absolute;
    right: 15px;
    top: 0;
    content: "\f123";
    font-family: "IonIcons";
    line-height: 43px;
    color: #7F878C;
    pointer-events: none;
}

.resultsInfo select {
    background-color: #18394D;
    color: #67c1f5;
    outline: 1px solid #000;
    background: transparent;
    background-image: none;
    padding-right: 50px;
}

.resultsInfo option {
    background-color: #417A9B;
    color: #fff;
}

.result-list .resultStats {
    color: #3b6e8c;
}

.result-list .listItem {
    background-color: #16202D;
    padding: 0;
    margin: 2px 0;
    border: 0;
    background: rgba(0, 0, 0, 0.4);
}

.result-list .listItem:hover {
    background: rgba(0, 0, 0, 0.8);
}

.result-list .image {
    width: 120px;
    height: 45px;
}

.result-list article, .result-list article div {
    display: flex;
    align-items: center;
}

.result-list article h2 {
    padding: 0;
}

.releaseDate {
    color: #4c6c8c;
    width: 150px;
}

.price {
    width: 50px;
}

input {
    background-color: #1C3345 !important;
    border: 1px solid #000 !important;
}

bodyinputに直で何かを当てたり!importantをバリバリ使っていたり、かなりアレな感じですが見なかったことにして…ここまでの手順を経ると画面は以下のようになっているはずです。

Screenshot_2018-12-15 React App(12).png

実際に動かしてみるとこんな感じです。

ezgif-1-1a81359e991b.gif

中々それらしい検索画面になったのではないでしょうか。

おわりに

Reactivesearch、いかがだったでしょうか。
個人的にはかなり気に入りました。Elasticsearchで検索UIを作るときのBootstrap的存在として流行ってもおかしくないと思います。
まだまだ使えていない機能がたくさんあるので、ちょこちょこ使っていこうかと思います。

最後に読まれた方が気になりそうな仮想質問にカジュアルに答えるコーナーを設けて終わろうと思います。

仮想質問に対するQ&A

  • Q. ライセンスを教えろください
  • Q. エンドユーザのブラウザから直接Elasticsearchに接続できる必要があったりします?その場合、プロダクションで使うにはセキュリティ的にどうかと思うんですが。
    • A. プロキシサーバーを間に挟むことで直接接続を回避できます。公式の実装例もあるので、その辺りを参考にすればよいと思います。また、ElasticsearchのSecurity機能で、最小権限のユーザーを作成し、使用することを推奨します。
  • Q. CSS当てないとまともに使えないんですか?
    • A. 機能的には問題なく動きます。見た目の好みの問題です。Themes機能を使いこなせれば、開発中は耐えうる見た目にできるかもしれません。
  • Q. クエリを自動で発行してくれるみたいだけど、自分でカスタマイズはできないんですか?
  • Q. もっと他の実装、デザイン例はないんですか?
    • A. 公式でデモサイトが幾つかあるので、それを覗いたりすると良いと思います。リポジトリでコードも公開されているので参考になります。GitXploreとかすごい。

本当のおわりに

以上です。

明日の枠はまだ空いてるみたいです。小ネタでも何でも共有してみてはいかがでしょうか!ではでは〜。


  1. ただし、検索用データセットのDLと、この記事の解説を読む時間は除きます...ずるい? 

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

GraphQL でカクテル検索サービスを実装してみた

はじめに

こんにちは。自分は普段 React でフロントを実装しています。最近 GraphQL に興味があって、REST 以外の選択肢を知っていると技術選定の幅が広がると思い、GraphQL で何かサービスを実装してみました。最後に感想を述べていますので、GraphQL への理解を深める参考になれば幸いです。以下に実際に動作する成果物を載せておきます。

サンプルとコード

clinet: https://liquor-react.netlify.com/
server: https://liquor-graphql-server.glitch.me/graphql
client source: https://github.com/yoneda/liquor-react-client
server source: https://github.com/yoneda/liquor-graphql-server

全体の構成

フリーで使えるデータを探していたところ、RapidAPI というサービスが見つかりました。そこで酒やカクテルを検索できる Cocktail DB API が見つかったので今回はこちらを使用しました。クライアントは React、サーバーは GraphQL で実装しました。GraphQL は、DB だけでなく、他の REST API だったり、または他の GraphQL も接続できるということでした。GraphQLはDBと接続されることが多いと思いますが、今回はREST形式のRapidAPI と接続しました。GraphQL は BFF のような働きをします。図にすると以下のような感じです。

graphql.png

使用技術

今回使った技術は以下の通りです。
クライアント: react, apollo-client, react-scripts, reach-router
サーバー: express, express-apollo-server, superagent, nodemon

サーバーサイド

サーバーサイドは Express + ApolloServer で実装しました。ApolloServer ではスキーマを定義してそれに対応するリゾルバを実装していきます。RapidAPI から返されたJSON を見ながらスキーマを定義します。カクテルのリストを返すqueryは以下のようにしました。

  enum DrinkCategory{
    COCKTAIL
    COFFEE
    BEER
    OTHER
  }
  type Query{
    allDrinks(category: DrinkCategory): [Drink!]!
  }
  type Drink{
    id: ID!
    name: String!
    url: String!
    category: DrinkCategory
  }

クライアント

クライアントは React + ApolloCLient で実装しました。React で GraphQL のクエリを発行するのはシンプルでした。コンポーネントのルートで GraphQL の URI を設定する、useQuery の Hooks でデータをフェッチする、この2つだけでサーバーと接続可能です。

実装した感想

Apollo Client の強力なキャッシュ機能が便利

Apollo Clientは、サーバーからのフェッチ結果をどこでもローカルにキャッシュします。Aコンポーネントでブログ記事を取得した後、Bコンポーネントから同じものをフェッチしようとしたときは、再度サーバーにリクエストを送らず、ApolloClinet がキャッシュしているデータにアクセスするので高速に表示されます。こういった実装はこれまでは Redux によって行われていました(AコンポーネントとBコンポーネントがアクセス可能な Redux の値を用意する。Aコンポーネントでブログ記事を取得した結果を Redux に入れておいて、Bコンポーネントがフェッチしようとしたときは代わりにRedux の値にアクセスするようにする)。Redux によってAction や Reducer を書いて、ほぼ自前で実装してたキャッシュの機能が、ApolloClient では最初から用意されています。コードを書く量が減ると思います。

バリデーションの手間が減る

サーバーサイドを Node.js で実装していて、バリデーションしたいときは別途ミドルウェアを組み込む必要がありました。ここでいうバリデーションとは、Request のパラメータ値の型をチェックしたり、その値が必須かどうかなどをチェックするような工程です。GraphQL でクライアント-サーバー間で型が保証されているというのは、開発者を安心させます。

GraphQL はリクエスト回数が少なくてすむ

REST と GraphQL の特徴を比較したときに、どちらが効率的にデータを取得できるかというと、自分は GraphQL の方が優れていると思います。特に、あるデータとその関連データを取得するような場面では、GraphQL の良さを発揮できるようでしょう。例えば、ブログ記事とその記事についたコメントを取得するAPIを実装してみるとします。

GET /articles/:id
GET /articles/:id/comments

REST では、上記のようにと2回リクエストを送るように実装するのが自然です。(もちろん、ブログ記事とそのコメントを同時に取得するような実装も可能です。1つのリソースに GET / POST / PUT / DELETE を割り当てて設計していく REST の思想に忠実に従うと、上記のような設計のほうが自然ではないかと思います。)

query{
  article(id: $id){
    title
    body
    postedComments{
      comment
      user
    }
  }
}

GraphQLでは、1回のリクエストで取得できます。さらに、必要なフィールドだけ指定して取得できます。無駄なフィールドは取得せず効率的です。GraphQLはデータを効率的に取得できるような設計になっていると感じました。

次回(予定)

今回はqueryだけだったので、次回はmutationやユーザ認証まで実装したいと思ってます。実装でき次第、この記事に追記するか、新しい記事として投稿する予定です!

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

【プログラミング歴1年】初心者が全力でアプリを作ってみる。

はじめに

自己紹介

未経験の23歳からIT教育関係に転職し、2019の年末でプログラミング歴が1年になります。
副業でスタートアップの企業にも参加して色々教えてもらってきました。

しかし、仕事が忙しくポートフォリオを作ろうとしても途中で辞めてしまう事が多く・・・

そうだ人生初!ポートフォリオを作成してまとめようという事で1〜10までの足跡を記事にしていきたいと思います。

ベテランエンジニアの方からは見苦しい内容かもしれませんが、アドバイス頂けると幸いです!!!

※半年後の完成を描いています

目次

目次は随時更新します!!

(最終更新日2019/12/11)

アプリケーションについて

名前

未定

目的

新しい物語の伝え方を開拓する。

概要

写真家/イラストレーター小説家のマッチングアプリ

供給できるもの

  • 写真やイラスト付きのイメージ膨らむ小説
  • 写真やイラストと小説の相乗効果を生んだ新しい作品
  • 新感覚の小説の楽しみ方
  • クリエイターの活躍機会を増やす

ペルソナ

写真家/イラストレーター,小説家

  • 写真/イラストが好き
  • 小説が好き
  • 他人に見てもらう機会がない
  • いい作品ができても披露する機会がない
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React + Firebaseで社内LTを楽しくする投稿型アプリを作りたかった話

はじめに

はじめまして!
今年8月からWebのお仕事を始めた新人フロントエンドエンジニアのワタナベです。日々勉強中です。

さて、NIJIBOXでは隔週でクリエイティブ室のメンバーで集まってLT(ライトニングトーク、10分程度のプレゼン)を行っています。
話す内容は使ってみた技術の紹介から業務知識の共有など様々で、質疑応答も活発に行われています。

これまでのLT会では資料を社内Google Drive内にアップロードしていたのですが、

  • 資料の一覧性が低い
  • バックナンバーをじっくり読み返したい
  • 質疑応答やメモの記録は有志がslack上に残しているが、後で読み返すためのログとしてはやや不便

といった課題がありました。
また、発表に対するフィードバックを可視化できればもっと発表が活発化しそう!という思いつきもあり、学習中のReact + Firebase の練習を兼ねて「LTアプリ」を作ってみることにしました。

作りたかった」というタイトル通り完成には程遠いのですが、奮闘の記録を下記に残します。

構想

まずは必須の機能を洗い出して、そこに絞って開発を進めることにしました。

  • ユーザーログイン
  • ユーザーに紐付いたスライド(PDF)の投稿
  • 投稿されたスライドの一覧表示
  • スライドのプレビュー
  • スライドへのコメント

使用技術

また、今回の初チャレンジとして、

にも挑戦してみました。後々苦戦することになる……。

作ってみた

それでは早速作っていきましょう。
環境構築として、create-react-appに以下のものを加えています。

  • eslint-airbnb
  • stylelint
  • stylelint-order
  • stylelint-processor-styled-components
  • stylelint-config-rational-order
  • stylelint-config-styled-components
  • prettier
  • prettier-eslint
  • prettier-stylelint
  • react-firebase-hooks

認証

まずは認証機能です。

ss02.png

Firebase AuthenticationのGoogle認証を利用しますが、今回は社内向け資料を閲覧するアプリとなるので、ユーザー登録できるアドレスを社内ドメインのGoogleメールアドレスに限定することにしました。

Login.jsx
const signIn = () => {
  const provider = new firebase.auth.GoogleAuthProvider();

  // 認証に使えるドメインを限定する
  provider.setCustomParameters({
    hd: 'yourdomain.co.jp', // サンプル
  });

  firebase.auth().signInWithPopup(provider);
};

hdプロパティを設定することで、以下のように入力できるドメインが限定されます。

ss01.png

同様に、firestore.rulesにもドメイン指定を行うことで、ドメインによってドキュメントの閲覧制限をかけることができます。

ログイン状態の管理

Firebaseとhooksを併用する際にとても便利なパッケージreact-firebase-hooksを使います。
useAuthStateを用いることで、認証状態を簡単に取得することができます。

App.jsx
const App = () => {
  const [user, userLoading] = useAuthState(firebase.auth());
  .
  . /* 省略 */
  .
  return (
    <>
      {userLoading ? (
        <p>please wait ...</p>
      ) : (
        <MuiThemeProvider theme={theme}>
          <GlobalStyle />
          <BrowserRouter>
            <Header user={user} />
            <Content>
              <Router user={user} posts={posts} />
            </Content>
          </BrowserRouter>
        </MuiThemeProvider>
      )}
    </>
  );
};

未ログインの場合はログインページにリダイレクトする

React RouterのRedirectを使えば、ログインユーザー向けのページはログイン後しか閲覧できないようにすることができます。

Router.jsx
const Router = props => {
  const {
    user,
    .
    . /* 省略 */
    .
  } = props;

  return (
    <>
      {user ? <></> : <Redirect to="/login" />}
      .
      . /* 省略 */
      .
    </>
  );
};

投稿

続いて投稿機能です。

ss03.png

タイトル、投稿ユーザー、スライドのURL、投稿日時などをまとめたドキュメントをCloud Firestore(以下、Firestore)に、スライドの本体ファイルをCloud Storageに格納します。

New.jsx
// 投稿に関する処理
const uploadFile = (file, ref) => {
  return new Promise(resolve => {
    ref.put(file).then(() => {
      resolve();
    });
  });
};

const getFileUrl = ref => {
  return new Promise(resolve => {
    ref.getDownloadURL().then(url => {
      resolve(url);
    });
  });
};

// コンポーネント

const New = props => {
  const { user } = props;
  const [isLoading, setLoading] = useState(false);
  const [choosedFile, setChoosedFile] = useState(null);

  const getChoosedFile = e => {
    const file = e.target.files[0];
    setChoosedFile(file);
  };

  const submit = async e => {
    e.preventDefault();
    const postId = generateRandomId(8);
    const currentTime = generateCurrentTime();
    const title = e.target.title.value;
    const file = choosedFile;

    if (title && file) {
      const ref = storage.ref().child(`${postId}.pdf`);

      // ファイルをアップロードし、そのURLを取得する同期処理
      setLoading(true);

      await uploadFile(file, ref);
      const fileUrl = await getFileUrl(ref);
      const EncodedUrl = encodeURIComponent(fileUrl);

      firestore.collection('post').add({
        postId,
        title,
        fileUrl: EncodedUrl,
        authorId: user.uid,
        authorName: user.displayName,
        authorIcon: user.photoURL,
        postTime: currentTime,
        comments: [],
      });

      setChoosedFile(null);
      setLoading(false);

      alert('アップロードが完了しました');
    } else {
      alert('タイトルとファイルを入力してください');
    }
  };
  .
  .
  .

このとき、async/awaitを用いて

  1. スライドファイルのアップロード完了を待つ
  2. アップロードしたファイルを参照するためのURLを取得
  3. その後、Firestoreに格納

という処理を行っています。

保存された投稿データの取得と表示

ss04.png

Firestoreからデータを取得する際にもreact-firebase-hooksが便利です。
useCollectionDataを使うと、Firestoreの検索結果をオブジェクトの配列として格納することができます。

App.jsx
const App = () => {
  .
  . /* 省略 */
  .
  const [posts, postsLoading] = useCollectionData(
    firebase
      .firestore()
      .collection('post')
      .orderBy('postTime', 'desc'),
  );
  .
  .
  .

配列にmapを行うことで、記事の一覧表示ができます。

Dashboard.jsx
const Dashboard = props => {
  const { posts, postsLoading } = props;

  return (
    <>
      {postsLoading ? (
        <p>loading ...</p>
      ) : (
        <PostList
          component="nav"
          subheader={<ListSubheader>記事一覧</ListSubheader>}
        >
          {posts.map(post => (
            <PostItem
              key={post.postId}
              id={post.postId}
              title={post.title}
              authorId={post.authorId}
              authorName={post.authorName}
              authorIcon={post.authorIcon}
              postTime={post.postTime}
            />
          ))}
        </PostList>
      )}
    </>
  );
};

ページ遷移

トップページに並べた投稿一覧PostItem.jsxをクリックすると、閲覧ページに遷移するようになっています。
Qiitaの記事一覧ページ→記事ページのようなイメージです。

PostItem.jsx
const PostItem = props => {
  const { id, title, authorId, authorName, authorIcon, postTime } = props;

  const handleToPostView = () => {
    props.history.push(`/${authorId}/${id}`);
  };

  return (
    <>
      <Divider />
      <ListItem button onClick={handleToPostView}>
        <ListItemIcon>
          <Avatar src={authorIcon} />
        </ListItemIcon>
        <ListItemText
          primary={title}
          secondary={`by ${authorName} ${generateDisplayTime(postTime)}`}
        />
      </ListItem>
    </>
  );
};

閲覧ページの実体Post.jsxにはReact Routerの機能である、paramsを渡します。
paramsはURLの値をpropsとして渡すことができる機能です。

最初はparamsと通常のpropsを両方渡す方法がわからずハマりましたが、下記のように書くことで解決しました(参考資料に助けられた……)。

Router.jsx
        <Route
          path="/:authorId/:postId"
          render={({ match }) => (
            <Post
              userName={userName}
              userIcon={userIcon}
              posts={posts}
              match={match}
            />
          )}
        />

投稿の閲覧

ss05.png

遷移先のPost.jsxは、URLから受け取ったpostIdを使って該当する記事を検索します。

Post.jsx
const Post = props => {
  const {
    user,
    posts,
    match: {
      params: { postId },
    },
  } = props;

  if (posts) {
    const targetPost = posts.filter(post => post.postId === postId);

    const handleToAuthorPage = () => {
      props.history.push(`/${targetPost[0].authorId}`);
    };

    return (
      <>
        {/* targetPostは配列のため、lengthで存在判定 */}
        {targetPost.length ? (
          <Wrapper>
            <Data>
              <Author>
                <AuthorIcon
                  src={targetPost[0].authorIcon}
                  onClick={handleToAuthorPage}
                />
                <AuthorName>{targetPost[0].authorName}</AuthorName>
              </Author>
              <PostTime>{generateDisplayTime(targetPost[0].postTime)}</PostTime>
            </Data>
            <Title>{targetPost[0].title}</Title>
            <SlideContainer>
              <Slide
                title={targetPost[0].title}
                src={`https://docs.google.com/viewer?url=${targetPost[0].fileUrl}&embedded=true`}
              />
            </SlideContainer>
            <Divider />
            <Title>コメント</Title>
            <List>
              {targetPost[0].comments.map(comment => (
                <Comment
                  key={comment.commentId}
                  userName={comment.userName}
                  userIcon={comment.userIcon}
                  value={comment.value}
                  commentTime={comment.commentTime}
                />
              ))}
            </List>
            <CommentForm postId={postId} user={user} />
          </Wrapper>
        ) : (
          <p>Not Found</p>
        )}
      </>
    );
  }

  return <p>loading ...</p>;
};

スライドのプレビュー

ちなみに、スライドの埋め込みプレビューにはGoogle Docs Viewerを使っています。
iframeタグで埋め込み、事前に取得しておいたファイルURLを参照するだけでお手軽に使えます。

スライドへのコメント

ss06.png

対象の投稿データのcommentsプロパティに新しいオブジェクトを追加することで、コメントの追加を行っています。

CommentForm.jsx
const CommentForm = props => {
  const { postId, user } = props;

  // コメントの投稿
  const handleComment = e => {
    e.preventDefault();

    const commentId = generateRandomId(8);
    const currentTime = generateCurrentTime();
    const comment = e.target.comment.value;
    const q = firestore.collection('post').where('postId', '==', postId);

    q.get().then(querySnapshot => {
      querySnapshot.forEach(doc => {
        doc.ref.update({
          comments: firebase.firestore.FieldValue.arrayUnion({
            commentId,
            userName: user.displayName,
            userIcon: user.photoURL,
            value: comment,
            commentTime: currentTime,
          }),
        });
      });
    });
  };

  return (
    <Box mt={4}>
      <form onSubmit={handleComment}>
        <TextField
          name="comment"
          variant="outlined"
          fullWidth
          label="コメントを入力"
          multiline
          rows="2"
          required
        />
        <Box mt={1}>
          <Button type="submit" variant="outlined" fullWidth color="primary">
            コメントを投稿
          </Button>
        </Box>
      </form>
    </Box>
  );
};

完成形

以上で基本的な機能は完成です!!
こんな感じで動きます。

mv01.gif

学んだこと

hooks

useEffectの使い方でハマりました。何度か無限ループしてFirebaseに大量のリクエストを送ってしまった……。
hooksのuseEffectは、class componentにおけるライフサイクルメソッド(ComponentDidMountなど)とは異なり、「副作用」としてひとくくりになっています。

公式ドキュメントにもあるように、

再レンダー間で特定の値が変わっていない場合には副作用の適用をスキップするよう、React に伝えることができるのです。そのためには、useEffect のオプションの第 2 引数として配列を渡してください。

第2引数として渡した値が更新されたときのみ副作用を適用することができます。
また、第2引数に空の配列[]を渡すことで、ComponentDidMountに近い挙動を実現することもできます(厳密には異なるようです)。

React Router

今回のアプリでは、通常のWebページのようにURLから特定のスライドにアクセスできるようにルーティングを行いました。

最終的には実現することができましたが、最初のうちはURLを直接入力したりブラウザで更新をかけると挙動がおかしくなるなど苦戦した箇所が多かっため、引き続きじっくり学習して改めて記事にしたいと思っています。

styled-components

こちらはCSSの記法でとても直感的に使えると感じました。

const AuthorIcon = styled(Avatar)`
  cursor: pointer;
`;

こんな感じで、Material-UIに対してスタイル変更を行うことも簡単にできて便利です。

まとめ

よくある「投稿Webアプリ」ではありますが、実際に作ってみるととても勉強になりました。

私は以前「Rails Tutorial」をやったことがあったのですが、似た機能のWebアプリでもサーバーサイドからのアプローチフロントエンドからのアプローチの両方を知ることができたのも良かったです。

今回のアプリはまだまだ多くの課題があり実用には遠いのですが、じっくり完成させてReact力を高めていきたいと思います!

参考資料

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

パスワードは 90 年代の代物だ(JSConfカンファレンス参加メモ)

この記事はフューチャー 2 Advent Calendar 2019の 11 日目の記事です。
昨日は@Tetsu_minorityさんの記事でした。
Futureアドベントカレンダーはその1もあります!!ぜひご覧ください。

ごあいさつ

11/30、12/1 に開催されたJSConf JPに参加してきましたが、
たくさんのレクチャーがあり初めて知ることばかりでした。
今回はその中から、個人的に面白かったパスワードについてのレクチャーを
おさらいを兼ねてまとめてみようと思います。

Password is so 1990s(「パスワードは 90 年代の代物だ」) by Sam Bellen

「パスワード」の歴史

  • パスワードの歴史ははるか昔、古代ローマ人の時代にさかのぼる。
  • 10世紀ごろには「ひらけゴマ」という「パスワード」も誕生した。
  • 1961年にはタイムシェアリングシステムが登場。1台のコンピュータを複数人のユーザーでシェアして使うことができるようになった。
  • 1970年代には「ハッシュ化」の技術が生まれる。パスワードをよりセキュアに保存することができるように。
  • 1990年代には、ハッキングがより深刻な問題として顕在化した。

パスワードの種類

ところでパスワードとは何か

それは 秘密をシェアするすべてのもの のことである!

色々なパスワード

  • 意味のないアルファベットの並んだ文字列など、複雑なものは覚えられにくい。
    • つまり、複雑であれば他人から推定されにくい。
  • 一方でPINコードのように簡単なものは覚えやすい。
    • つまり、他人から推定されやすい。
    • よってPINコードなどは生体認証(などの複雑な認証)と合わせて使うとより安全である。
  • パターン認証は推定されにくいほどのものではない。
    • 覚えやすくはある。物の認証に使われる。

パスワードの問題点

  • 忘れたらリセットしなきゃいけない。
  • リセットの作業がとにかく面倒!
  • 手間を省くためにパスワードマネジャーを使うことになるが、パスワードが必要なサイトが増えるほどハックされる可能性も高い。

より良いパスワードにするためには

  • 複雑なパスワードにする
  • 個人情報を含めない
  • パスワードを再利用しない
  • 頻繁にパスワードを変更する

これらが有効だが、誰もそんなことしない。Sam Bellen氏だってしない。
そこで、

パスワードレスな認証

  • ワンタイムパスワード
    • 一度だけ使用できる。短時間で有効期限切れる。
    • ユーザーに直接届く。iOSやAndroidならば届いたメッセージから推測して入力できる。
    • 二段階認証が必要ない。
    • ただし:携帯が必要である。
    • メールで送信できれば問題ない?否、メールも解析される恐れがある。
  • 認証アプリ
    • 時系列だったり、プッシュ型だったりする。
    • アプリケーションと認証サービス間で秘密の情報を共有する必要がある。
  • SMS認証
    • 覚えなくてはならないパスワードが減る。
    • 信頼できるサービスにのみパスワードを教えるだけで良い!
    • ただし;他のサービスに認証を依存することになる。
  • 他の認証アプリも、二段階認証の一つとしてよく使われる。

Web認証API

webauthnというクールな認証APIがある

  • キーベースの認証である。
  • 認証はハードウェアで行う。
  • 新しいキーを生成し、保存するようになっている。
  • モダンなデバイスだと認証装置がビルトインになっている。(touch IDなど)

どのように動作するか?

  • クレデンシャル登録・・・一度登録すれば、いつでも認証できるようになる!

    1. 認証のリクエストがサーバーに送信される。
    2. レスポンス(「Challenge」と呼ばれていた)がデバイスに返ってくる。
    3. ユーザーがデバイスを操作する。(ChallengeにSignする)
    4. デバイスからサーバーへSigned ChallengePublic Keyraw IDが送られる。
    5. サーバーはユーザー名と一緒にそれを保存する。
    6. 登録完了 image.png
  • 認証

    1. ユーザーがユーザー名をクライアントに入力する。
    2. ChallengePublic Keyraw IDがサーバーから返ってくる。
    3. ユーザーがデバイスを操作する。(ChallengeにSignする)
    4. デバイスからサーバーへSigned Challengeと、登録時と同じPublic Keyが送られる。
    5. 認証完了 image.png

webauthnで嬉しいこと

  • ハードウェア認証なのでinternal、externalも分けられる。
  • ハードウェアを通しての認証なので、パスワードを入力する必要がない!

課題もある

  • ユーザーがクレデンシャルをどう管理するか
  • デバイスをまたいでのクレデンシャル発行
    • 紛失したデバイスの認証システムはどう復旧するか、など

それでも

  • webauthnはパスワードに取って代わるシステムとなるだろう。
    • (Auth0などの認証サービスの代わりにはならないだろう)
  • W3C勧告にもなっており、様々なブラウザが対応しようとしている。
  • GoogleやGithubではすでにwebauthnを使える!

所感

  • この発表によって、ログインにはIDとパスワードのセットが当たり前、という思い込みを打ち破られました。
  • 時代はクラウド!と言われる中でハードウェア認証なんだ、、と最初は戸惑ったが、物理的な認証であれば自分が持っている限り盗まれることはない=ハックされにくいという点では納得できる気もしました。

参考

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

tensorflow.jsでクリスマス度を数値化してみる

こんにちは。NIJIBOXのエンジニアのつんあーです。

こちらの記事はNIJIBOX Advent Calendar 2019の12日目の記事(になるはず)です。

https://github.com/ats05/christmas_scouter
デモはこちら

※画面を開いてからモデルのロードが完了するまで--.-%と表示されます。しばらくお待ちください。
※カメラの映像が滑らかに表示できません。なんとかしたい。

TensorFlow.jsの情報を調べてみると、意外にまだまだコレ!といった情報が少なかった印象でした。
情報を探している方の助け&web技術の発展の助けになれば幸甚です。


導入

さて、厚手のコートを着ていても吹く風の冷たさを感じる今日この頃。
街はクリスマスムード真っ只中です。

そこかしこにイルミネーションが輝き、少し浮かれ気味なムードが漂っています。

実は、私はこの華やかな時期の街中が結構好きなのです。
イルミネーションが綺麗な街で、「ああクリスマスっぽいなぁ」ってぼんやりするのは結構楽しいものです。

はたと思いました。

クリスマスっぽいとは一体なんなのでしょう。

なぜだかワクワクするような気持ちを感じさせる街の雰囲気とは、
一体どんなところから溢れてくるのでしょう。

とりあえず検索してみましょう。

スクリーンショット 2019-12-06 18.56.03.png

いいですね。

こういう雰囲気ですよ。こういう雰囲気。

今度はクリスマスを感じない街を検索してみます。

スクリーンショット 2019-12-10 22.01.18.png

普通ですね。(そりゃそうだ)

画像検索してるのにキーワードに「写真」と入れているあたりに茶目っ気を感じます。


ところで、私達はこれらの画像検索の結果から「クリスマスっぽさ」
をなんとなく感じ取ることができています

では、これらの画像をひたすら学習させれば、コンピュータでも
「ああクリスマスっぽいなぁ」と感じさせることができるのではないでしょうか。

モノは試し、やってみましょう。

というわけで、今回はTensorflowを使い、画像のクリスマスっぽさをコンピュータに判断させてみます。
ついでに、NIJIBOXはWebに強い会社ということで、
これをWebに移植しスマホからでも簡単に実行できるようにしてみましょう。

今回はコチラの記事を参考に、画像の分類を行ってみました。


Tensorflowでクリスマスっぽさを判断させる

画像を集める

まず、こちらのツールを使ってGoogle画像検索から画像を集めます。
https://github.com/hardikvasa/google-images-download

1度に100件以上の画像をダウンロードする際はChromeDriverを入れないといけないようなので、導入します。

$ brew tap homebrew/cask
$ brew cask install chromedriver
$ which chromedriver
=> /usr/local/bin/chromedriver # 入った!

先ほどと同じキーワードで、クリスマスっぽさを感じる画像を集めてみます。
とりあえず上限は10000件にしてみました。欲張りですね!

$ googleimagesdownload --keywords "クリスマス 写真 昼間 街" --limit 10000 --chromedriver /usr/local/bin/chromedriver

と思ったら残念、376件までしかダウンロードしてくれませんでした。ガックシガックシ。

Unfortunately all 10000 could not be downloaded because some images were not downloadable. 376 is all we got for this search filter!

集まった画像はこんな感じ。良さそうですね。

スクリーンショット 2019-12-06 18.31.16.png
画像は実行したディレクトリ/download/キーワードという場所に保存されます。

同様の手順で、クリスマスっぽくない画像も集めます。

$ googleimagesdownload --keywords "街 写真" --limit 10000 --chromedriver /usr/local/bin/chromedriver

...

Unfortunately all 10000 could not be downloaded because some images were not downloadable. 379 is all we got for this search filter!

やはり途中で終わってしまいました。こちらは379件。

先ほどのスクリーンショットを見ていただくとわかるのですが、
データが壊れていて開けない画像や、重複した画像、そもそもクリスマスとあまり関係のない画像が混ざっています。

これらを手作業で除外していきます。

その結果、
クリスマスっぽい画像:300枚くらい
クリスマスっぽくない画像:350枚くらい

まで減りました。

画像は、「chrsitmas」ディレクトリと「nochristmas」ディレクトリに分けておきます。

downloads/
├── christmas
└── nochristmas

スクリーンショット 2019-12-10 22.18.42.png

ちなみに、クリスマス画像にはやたらと合コン募集の画像が入っていました。
どうしろと言うのでしょう。


画像を学習させる

画像が集まったので、これらをTensorflowで読み込ませ学習させていきます。

既に基本的な訓練をされたモデルがあるので、それを利用します。
下記のスクリプトをダウンロード。
https://github.com/tensorflow/hub/raw/r0.1/examples/image_retraining/retrain.py

とりあえず、実行してみます。

--追記--
ここでは--saved_model_dirオプションをつける必要がありました。
私はこれに気付かずかなりの時間を使ってしまいました。

$ python retrain.py --image_dir ./google-images-download/downloads/ --saved_model_dir ./SavedModel

...

AttributeError: module 'tensorflow' has no attribute 'app'

まあ、一発でうまく行くわけがないですよね。
エラーが出ました。

上記エラーの内容を調べてみると、TensorFlow2.x以降だとtf.appが使えないからエラーになっているようです。
手元のTensorflowのバージョンを確認してみます。

$ pip show tensorflow
=> Name: tensorflow
   Version: 2.0.0

残念、2.0.0が入っていました。。。。

仕方ないので、1.x系にダウングレードします。
今入っているものを削除し、1.x系の最新をインストール。

$ pip uninstall tensorflow 
$ pip install tensorflow==1.15
$ pip uninstall tensorflow-hub 
$ pip install tensorflow-hub 

$ pip show tensorflow
=> Name: tensorflow
   Version: 1.15.0

もう一度、学習のスクリプトを実行してみます。

$ python retrain.py --image_dir ./google-images-download/downloads/   

お、何か始まりましたね。

スクリーンショット 2019-12-06 18.54.56.png

...

OSError: [Errno 63] File name too long: '/tmp/bottleneck/christmas/363.%E3%83%8B%E3%83%A5%E3%83%BC%E3%83%A8%E3%83%BC%E3%82%AF-%E3%82%AF%E3%83%AA%E3%82%B9%E3%83%9E%E3%82%B9%E3%82%B7%E3%83%BC%E3%82%B9%E3%82%99%E3%83%B3-%E3%82%AB%E3%83%AB%E3%83%86%E3%82%A3%E3%82%A8.jpg_https~tfhub.dev~google~imagenet~inception_v3~feature_vector~1.txt'

。。。と思ったらまたエラーが出ました。
読み込んだ画像のファイル名が長すぎるそうです。(そんなことあるのか。。)

ダウンロードした画像のファイル名を修正し、再々チャレンジ。

INFO:tensorflow:Froze 378 variables.
I1206 19:20:09.427611 140737240245184 graph_util_impl.py:334] Froze 378 variables.
INFO:tensorflow:Converted 378 variables to const ops.
I1206 19:20:09.723118 140737240245184 graph_util_impl.py:394] Converted 378 variables to const ops.

今度は成功しました。
所要時間は私の環境で30分ほど。あれ?思ったより少ない。

学習の状況が知りたいですね。
とりあえずどの説明でもTensorBoardを使って確認しているようなので、同じようにやってみました。

$ pip install tensorboard
$ tensorboard --logdir /tmp/retrain_logs
=> TensorBoard 1.15.0 at http://localhost:6006/ (Press CTRL+C to quit)

スクリーンショット 2019-12-06 19.36.18.png

ほうほう、それっぽいそれっぽい。

がしかし、
見方がわからない。

それっぽいのでよしとしましょう。

ちなみに、TensorBoardで表示するのに必要なログの出力先はretrain.pyの中に書いてありました。


実行してみる

適当に用意した画像を使って、実行してみましょう。

実行用には、こちらのスクリプトを使います。
https://github.com/tensorflow/tensorflow/raw/master/tensorflow/examples/label_image/label_image.py

retrain.pyは、--saved_model_dirオプションをつけない場合/tmp配下にモデルファイルが生成されるようですので、
--graph--labelsオプションでそれらを指定します。

--追記--
--saved_model_dirオプションが付いているとここはうまくいかないようです。
pythonで実行してみたい方は、上記オプションを外してみてください。

$ python label_image.py \
--graph=/tmp/output_graph.pb --labels=/tmp/output_labels.txt \
--input_layer=Placeholder \
--output_layer=final_result \
--image=画像ファイル

では、早速いってみましょう。


こちらは、表参道のイルミネーションの写真です。
illmi.jpg

christmas 0.9571908
nochristmas 0.042809136

約96%でクリスマスと判定されました。
なかなかの精度です。


こちらは、お肉のフリー素材サイトoniku images様から頂いたサーロインの画像です。
とても美味しそうです。
oniku-1.jpg

christmas 0.7308841
nochristmas 0.26911587

若干クリスマス度が高めです。
やはりクリスマスといえばご馳走ということなのでしょう。


こちらは、いつのまにかクリスマス仕様になっていた弊社エントランスです。

niji.jpg

christmas 0.994396
nochristmas 0.0056040385

これもクリスマスと判定されました。
かなりいいですね。


こちらは、お肉のフリー素材サイトoniku images様から頂いた動きのあるお肉の画像です。

躍動感に溢れています。
oniku-2.jpg

christmas 0.5222237
nochristmas 0.47777635

お肉に躍動感はいらないかもしれません。

作成したモデルをTensorFlow.jsに移植する

さて、クリスマス度を判定してくれるモデルができたところで、
これをTensorFlow.jsで扱えるように変換していきます。

TensorFlowには、公式で変換ツールが用意されています。
https://github.com/tensorflow/tfjs/tree/master/tfjs-converter

tfjs-converterはpythonのTensorFlow.jsに付属しています。
ので、インストール。

$ pip install tensorflowjs

ここからの試行錯誤が色々ありました。興味のある方は開いてみてください。

https://github.com/tensorflow/tfjs/tree/master/tfjs-converter
こちらのReadmeを参照しながら、試してみます。

こんなことが書いてあります。

The converter expects a TensorFlow SavedModel, TensorFlow Hub module, TensorFlow.js JSON format, Keras HDF5 model, or tf.keras SavedModel for input.

どうやら一口に「TensorFlowのモデル」といっても色々な形式があるようです。
変換時にはそれを指定する必要があります。

さて、今使っているモデルの形式は何なんでしょう?

https://github.com/tensorflow/hub/raw/r0.1/examples/image_retraining/retrain.py

こちらは先ほどの学習済みモデルのスクリプトなのですが、urlにはtensorflow/hubという記載があります。

ということは、

TensorFlow Hub module

が正しそう?

$ tensorflowjs_converter \
    --input_format=tf_hub \
    /tmp/output_graph.pb \
    ./output/web_model

=> OSError: SavedModel file does not exist at: /tmp/output_graph.pb/{saved_model.pbtxt|saved_model.pb}

指定するパスが変なことになっていますね。output_graph.pbを使って欲しいのですが、
指定するのはディレクトリだけみたいですね。

$ tensorflowjs_converter \
    --input_format=tf_hub \
    /tmp \
    ./output/web_model

=> OSError: SavedModel file does not exist at: /tmp/{saved_model.pbtxt|saved_model.pb}

おおん?

エラーをよくみると、

/tmp/{saved_model.pbtxt|saved_model.pb}

とあります。
つまり、saved_model.pbtxtsaved_model.pbというファイル名でないといけないようです。

リネームしてもう一度。

$ mv /tmp/output_graph.pb /tmp/saved_model.pb  
$ tensorflowjs_converter \
    --input_format=tf_hub \
    /tmp \
    ./output/web_model

=> RuntimeError: MetaGraphDef associated with tags 'serve' could not be found in SavedModel. To inspect available tag-sets in the SavedModel, please use the SavedModel CLI: `saved_model_cli`

serveというtagが見つからないそうです。何だそれは。

saved_model_cliを使ってくれよな!

という記述があります。

やってみます。

$ saved_model_cli  show --dir /tmp
=> The given SavedModel contains the following tag-sets:

tagの中身は何も無いようです。。。
というか、さっきからSavedModelというのが何度も出てきています。

先ほどのtfjs_converterの説明によると、

The converter expects a TensorFlow SavedModel, TensorFlow Hub module, TensorFlow.js JSON format, Keras HDF5 model, or tf.keras SavedModel for input.

どうやらSavedModelというのもモデルの形式の一つのようです。

このモデルはtf_hubでなくSavedModelなのでしょうか?

やってみましょう。
--signature_name=serving_default--saved_model_tags=serveというオプションが追加されているようです。

$ tensorflowjs_converter \
    --input_format=tf_saved_model \
    --output_format=tfjs_graph_model \
    --signature_name=serving_default \
    --saved_model_tags=serve \
    /tmp \
   ./output/web_model

=> RuntimeError: MetaGraphDef associated with tags 'serve' could not be found in SavedModel. To inspect available tag-sets in the SavedModel, please use the SavedModel CLI: `saved_model_cli`

同じエラーが。。。

グラフの中にserveというタグが無いとのことです。
調べたところ、これを解消するには、tf.saved_model.builderを使うときに[tf.saved_model.tag_constants.SERVING]というキーワードを入れてあげれば良いようです。

retrain.pyでモデルを保存している箇所に、その記述を追加すれば良さそうですね。

retrain.pyの中を見てみます。
私はpythonリテラシーが極低なので、saveとかそれっぽいキーワードでgrepして怪しいところを探してみました。

retrain.py
...
    # Save out the SavedModel.
    builder = tf.saved_model.builder.SavedModelBuilder(saved_model_dir)
    builder.add_meta_graph_and_variables(
        sess, [tf.saved_model.tag_constants.SERVING],
        signature_def_map={
            tf.saved_model.signature_constants.
            DEFAULT_SERVING_SIGNATURE_DEF_KEY:
                signature
        },
        legacy_init_op=legacy_init_op)
    builder.save() # <- ここ?

お、[tf.saved_model.tag_constants.SERVING]に関する記述が初めからありますね。

この部分はexport_model()という関数の中にあります。
そもそもこの関数が呼ばれているのかどうかを確認調べてみたところ、下記の記述が。

retrain.py
...
    if FLAGS.saved_model_dir:
      export_model(module_spec, class_count, FLAGS.saved_model_dir)
...

FLAGS.saved_model_dirが真でないと、そもそも呼ばれなさそうですね。
FLAGS.saved_model_dirがどこで代入されるかを調べると、こんな感じ。

retrain.py
  parser.add_argument(
      '--saved_model_dir',
      type=str,
      default='',
      help='Where to save the exported graph.')
  FLAGS, unparsed = parser.parse_known_args()

どうやら、実行時のオプションにて--saved_model_dirを指定しないと正しい形で保存してくれないようです。
/tmp配下に保存されるものは一体。。。?

結論から言いますと、
学習時に--saved_model_dirパラメータをつけるのが正しそうです。

$ python retrain.py --image_dir ./google-images-download/downloads/ --saved_model_dir ./SavedModel

これで、SavedModel配下にモデルが保存されます。
このモデルをsaved_model_cliで確認してみると、

saved_model_cli show --dir ./SavedModel/                                                                                  (2019ac) 
The given SavedModel contains the following tag-sets:
serve

serveというタグが設定されています!良さそうですね。
もう一度、tfjs_converterで変換してみます。

--追記--
この変換の際に、--quantization_bytesオプションをつけることで、量子化バイト数を指定し、出来上がるモデルの精度とサイズを調整することができました。
デフォルトでは--quantization_bytes=4になっているそうです。

$ tensorflowjs_converter \
  --input_format=tf_saved_model \
  --output_format=tfjs_graph_model \
  --signature_name=serving_default \
  --saved_model_tags=serve \
  ./SavedModel/ \
  ./WebModel/web_model

...


2019-12-10 15:48:40.072180: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   arithmetic_optimizer: Graph size after: 332 nodes (0), 376 edges (0), time = 168.506ms.
2019-12-10 15:48:40.072183: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   dependency_optimizer: Graph size after: 332 nodes (0), 376 edges (0), time = 58.479ms.
2019-12-10 15:48:40.072186: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   remapper: Graph size after: 332 nodes (0), 376 edges (0), time = 111.758ms.
2019-12-10 15:48:40.072273: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   constant_folding: Graph size after: 332 nodes (0), 376 edges (0), time = 263.073ms.
2019-12-10 15:48:40.072296: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   arithmetic_optimizer: Graph size after: 332 nodes (0), 376 edges (0), time = 165.07ms.
2019-12-10 15:48:40.072308: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   dependency_optimizer: Graph size after: 332 nodes (0), 376 edges (0), time = 55.012ms.
Writing weight file ./WebModel/web_model/model.json...

おお!終わった!

上記コマンドで指定した/WebModel/web_model配下に、何やらファイルが作成されました。

WebModel/
└── web_model
    ├── group1-shard10of21.bin
    ├── group1-shard11of21.bin
    ├── group1-shard12of21.bin
    ├── group1-shard13of21.bin
    ├── group1-shard14of21.bin
    ├── group1-shard15of21.bin
    ├── group1-shard16of21.bin
    ├── group1-shard17of21.bin
    ├── group1-shard18of21.bin
    ├── group1-shard19of21.bin
    ├── group1-shard1of21.bin
    ├── group1-shard20of21.bin
    ├── group1-shard21of21.bin
    ├── group1-shard2of21.bin
    ├── group1-shard3of21.bin
    ├── group1-shard4of21.bin
    ├── group1-shard5of21.bin
    ├── group1-shard6of21.bin
    ├── group1-shard7of21.bin
    ├── group1-shard8of21.bin
    ├── group1-shard9of21.bin
    └── model.json

多っ。
全部で87MBもあります。

--追記--
このあと、--quantization_bytes=1のオプションをつけて実行したところ、22MBまで削減できました。
正確に1/4になっていますね。
この程度の用途であれば精度への影響はほとんど感じませんでした。
デモもこの軽量化したモデルを使用しています。

JSから呼び出してみる

ここからはJSを使っていきます。

とりあえず、npmのモジュールとしては下記が必要になります。

npm i -S @tensorflow/tfjs @tensorflow/tfjs-converter

作成したモデルはやたら重たいですが、とりあえずjsから呼び出してみます。
呼び出すのはmodel.jsonだけでいいとのことですが、同じディレクトリにgroup1-xxxxx.binが全て揃っているような状態にします。

import * as tf from '@tensorflow/tfjs';
import {loadGraphModel} from '@tensorflow/tfjs-converter';

...

tf.loadGraphModel('...../model.json').then((model) => {
    console.log(model)
})

何か出ました!今のところエラーは出ていないので順調のようです。

スクリーンショット 2019-12-10 20.21.50.png

canvas要素に適当な画像を突っ込んで、getElementByIdとかで取得して、モデルに渡してみます。

let model = null;
tf.loadGraphModel('...../model.json').then((model) => {
    model = model;
})

const channels = 3;
let inputImage = tf.browser.fromPixels(canvasElement, 3); // <- canvas要素からテンソルを作ってくれるらしい
console.log(inputImage);

let result = model.predict(inputImage);

下記のようなエラーが出ました。

スクリーンショット 2019-12-10 20.46.12.png

入力に使うテンソルのシェイプは [-1, 299, 299, 3] でないといけないが、入力されたのは[720, 406, 3]である

てなところでしょうか。

渡したcanvas要素のシェイプを確認してみます。

let inputImage = tf.browser.fromPixels(canvasElement, 3);
console.log(inputImage.shape);
// => [720, 406, 3]

720, 406というのは渡したcanvas要素の縦横サイズです。
これを[-1, 299, 299, 3]に直さないといけないようです。

label_image.pyで画像を分類させるときに、pythonスクリプト内で行なっている画像の下処理と同様のことを、JSで行わないといけないようです。

label_image.pyはこんな感じ

label_image.py
  float_caster = tf.cast(image_reader, tf.float32)
  dims_expander = tf.expand_dims(float_caster, 0)
  resized = tf.image.resize_bilinear(dims_expander, [input_height, input_width])
  normalized = tf.divide(tf.subtract(resized, [input_mean]), [input_std])
  sess = tf.compat.v1.Session() # <-ここはセッションの起動だから、JSでは無視
  result = sess.run(normalized) # <-ここで計算

tensorflow.jsのリファレンスをみながら、頑張ってみます。
https://js.tensorflow.org/api/1.0.0/

こんな感じ。

// Pyrhonだと : float_caster = tf.cast(image_reader, tf.float32)
let float_caster = tf.cast(inputImage, 'float32');
console.log(float_caster.shape);
// => [720, 406, 3]

// Pythonだと : dims_expander = tf.expand_dims(float_caster, 0)
let dims_expander = float_caster.expandDims(0);
console.log(dims_expander);
// => [1, 720, 406, 3]

// Pythonだと : resized = tf.image.resize_bilinear(dims_expander, [input_height, input_width])
// ※ input_height, input_widthは共に299
let resized = tf.image.resizeBilinear(dims_expander, [299, 299]);
console.log(resized);
// => [1, 299, 299, 3]

// Pythonだと : normalized = tf.divide(tf.subtract(resized, [input_mean]), [input_std])
// ※ input_meanは0, input_stdは255
let normalized = tf.div(tf.sub(resized, [0]), [255]);
console.log(normalized);
// => [1, 299, 299, 3]


this.state.model.predict(normalized).print();

※シェイプの一つ目が-1ではなく1になっていますが、なぜかうまくいきました。理由はよくわかりません。

それっぽい結果がコンソールに出力されました。

スクリーンショット 2019-12-10 21.11.46.png

これがどうやら、pythonで画像分類を行なったときに出た

christmas xxx
nochristmas xxx

という結果と対応しているようです。
ラベルがないとわかりにくいですね。

コードを整理する

生成したテンソルがメモリを食いつぶさないようにtidyで囲み、推定結果をpromiseで非同期に受け取るようにしました。
結果もテンソルではなく、配列で受け取るようにしています。

その他Reactで実装したりアレコレやっていますが、必要なnpmモジュールはGithubを参照してもらえたらと思います。

let tensor = tf.tidy(() => {
    const channels = 3;
    let inputImage = tf.browser.fromPixels(this.videoElement, 3);
    let float_caster = tf.cast(inputImage, 'float32');
    let dims_expander = float_caster.expandDims(0);
    let resized = tf.image.resizeBilinear(dims_expander, [299, 299]);
    let normalized = tf.div(tf.sub(resized, [0]), [255]);
    return normalized;
})
new Promise((resolve, reject) => {
    let result = this.state.model.predict(tensor).array(); // 値はarray()で取り出せる
    resolve(result);
}).then(result => {
    console.log(result);
})

スクリーンショット 2019-12-10 21.19.42.png

これらを元に、ブラウザとwebカメラで推定を行えるようにしたデモを作成しました。
https://github.com/ats05/christmas_scouter

アクセスしてしばらくは --.-%と表示されるかと思います。
TensorFlow.jsはどうも読み込む際に時間がかかるみたいです。

これを持って少し街に繰り出してみました。




やはりイルミネーションは高スコアになりますね。

おしまい。

※特にオチはありません。

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

kintone + Backlog API 連携やってみた(その3)

kintoneアドベントカレンダーで書いた記事の続きです。
一旦やりたい事は出来たので終わりにしたいと思います。

概要

kintoneからBacklogの「ユーザーの最近の活動の取得」APIを使って、データを取得して、それを別の集計用のkintoneアプリにレコードを登録します。

できる事

普段Backlogとkintoneを使っている方なら、Backlogの活動履歴(例えば、課題を作成したとか、GitにPushしたとか、Wikiを更新したなどなど)をkintoneに渡して、kintoneの機能を使ってグラフ集計したり出来ます。

スクリーンショット 2019-12-11 10.37.34.png

用途

会議でエンジニアがどのくらい作業をしているかの一つの指標として、アウトプットをどれくらいしているかがあると思っています。
それをある程度見える化出来ます。
Backlogを更新してなくても、仕事はちゃんとやっているという方もいるかと思いますが、自分の作業を見えるようにアウトプットする事は大事だと思います。
スクリーンショット 2019-12-11 10.39.42.png

環境

  • macOS 10.14.6

その他は設定ファイル等をご確認ください。

管理画面

スクリーンショット 2019-12-11 9.23.29.png

シンプルにデータを取得する先のBacklogの情報を入力するフィールドと取得したデータを登録する先のkintoneアプリIDを入力するフィールドがあるだけです。

以下に貼り付けるコードでは、新規に登録するだけなので、再度登録する時にはレコードを削除してから登録するなどしてください。

あと、エラーチェックとかバリデーションなどはほぼやっておりませんのでご注意ください。

コード

カスタマイズビューのHTML

<div id="backlog"></div>

index.jsx

カスタマイズビューにフォームを描画する処理です。

index.jsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as App from './js/App';
import * as Common from './js/common';

(() => {
  kintone.events.on('app.record.index.show', event => {
    if (Common.getViewId(event) !== Common.VIEW_ID) {
      return;
    }
    ReactDOM.render(
      <App.App />,
      document.getElementById('backlog')
    );
    return event;
  });
})();

App.js

フォームの共通コンポーネントの作成とデータ取得・登録する処理です。

App.js
import * as React from 'react';
import { Text, Button } from '@kintone/kintone-ui-component';
import * as Common from './common';
import '../css/index.css';
import 'bulma/css/bulma.css'

export class App extends React.Component {
  constructor (props) {
    super(props)
    // 親コンポーネントstate
    this.state = {
      account: '',
      apikey: '',
      appid: ''
    }
  }
  accountChange(value) {
    console.log(value)
    this.setState({account: value})
  }
  apikeyChange(value) {
    console.log(value)
    this.setState({apikey: value})
  }  
  appidChange(value) {
    console.log(value)
    this.setState({appid: value})
  }  
  clickSubmit() {
    console.log(`Account: ${this.state.account} APIKey: ${this.state.apikey} AppId: ${this.state.appid}`)
    getBacklogMyActivities(this.state.account, this.state.apikey, this.state.appid)
  }  
  render () {
    return (
        <div className="container">
          <div className="notification">
            <h1 className="title">
              Backlog設定
            </h1>
            <div className="field is-horizontal">
              <div className="field-label is-normal">
                <label className="label">Backlog URL</label>
              </div>
              <div className="field-body">
                <div className="field">
                  <p className="control is-expanded">
                    <UIText
                      onChange={(value) => this.accountChange(value)}
                    />
                  </p>
                </div>  
              </div>
            </div>
            <div className="field is-horizontal">
              <div className="field-label is-normal">
                    <label className="label">Backlog API Key</label>
              </div>
              <div className="field-body">
                <div className="field">
                  <p className="control is-expanded">
                    <UIText
                      onChange={(value) => this.apikeyChange(value)}
                    />
                  </p>
                </div>
              </div>
            </div>
            <div className="field is-horizontal">
              <div className="field-label is-normal">
                    <label className="label">kintoneアプリID</label>
              </div>
              <div className="field-body">
                <div className="field">
                  <p className="control is-expanded">
                    <UIText
                      onChange={(value) => this.appidChange(value)}
                    />
                  </p>
                </div>
              </div>
            </div>
            <div className="field is-horizontal">
              <div className="field-label is-normal">
                    <label className="label"></label>
              </div>
              <div className="field-body">
                <div className="field">
                  <p className="control is-expanded">
                    <UIButton
                      onClickSubmit={(event) => this.clickSubmit(event)}
                    />
                  </p>
                </div>  
              </div>
            </div>
          </div>  
        </div>
      )
  }
}


// Textコンポーネント
export class UIText extends React.Component {
  constructor(props) {
      super(props);
      this.state = {
          value: '',
      }
  }
  render() {
      return (
          <Text
            value={this.state.value}
            onChange={this.onChange.bind(this)}
          />
      );
  };
  onChange = (value) => {
      this.setState({value});
      this.props.onChange(value);
  }
};

// Submitボタンコンポーネント
export class UIButton extends React.Component {
    constructor(props) {
        super(props);
    }
    render() {
        return (
            <Button text='Submit' type='submit' isDisabled={false} isVisible={true} onClick={this.handleButtonClick} />
        );
    }
    handleButtonClick = (event) => {
        this.props.onClickSubmit(event)
    }
}

// Backlog API を叩く
const getBacklogMyActivities = (url, apikey, appid) => {
  const BACKLOG_URL = url; // https://<My Backlog Account>.backlog.jp or backlog.com
  const APIKEY = apikey;
  const APPID = appid;
  // debug用
  console.log(`URL=>${url}, APIKey=>${apikey}, AppId=>${appid}`);

  kintone.proxy('https://' + BACKLOG_URL + '/api/v2/users/myself?apiKey=' + APIKEY, 'GET', {}, {})
  .then(function(resp)
  {
    const body = JSON.parse(resp[0]);
    kintone.proxy('https://' + BACKLOG_URL + '/api/v2/users/' + body.id + '/activities?apiKey=' + APIKEY, 'GET', {}, {})
    .then(function(resp)
    {
      const body = JSON.parse(resp[0]);
      const records = [];
      body.map( value => {
        records.push(Common.preparePostData(value));
        console.log(
          `${value.id},${value.createdUser.id},${value.createdUser.name},${value.created},${value.type},${value.project.projectKey}`,
          Common.isInclude(value.type, [5, 6, 7]) ? `${value.content.name},` : `${value.content.summary},`
        );
      });
      const post_body = {"app": APPID, "records": records};
      console.log(post_body);
      kintone.api(kintone.api.url('/k/v1/records', true), 'POST', post_body)
      .then(function(resp){
        console.log(resp);
      });
    })
  }).catch(function(error) {
    console.log(error);
  });
}

common.js

共通の関数をまとめてあります。

common.js
export const VIEW_ID = 5737650;
export const getViewId = (kintone_event) => {
  return kintone_event.viewId;
};
export function isInclude(type, arrayType) {
  if (arrayType.includes(type)) {
    return true;
  }
  return false;
}
export function preparePostData(resp) {
  const ret = {
    "activities_id": {"value": resp.id},
    "user_id": {"value": resp.createdUser.id},
    "datetime": {"value": resp.created},
    "type": {"value": resp.type},
    "projectkey": {"value": resp.project.projectKey},
    "ticket_title": {"value": isInclude(resp.type, [5, 6, 7]) ? resp.content.name : resp.content.summary}
  };
  return ret;
}

index.css

これ以外のスタイルはBlumaというCSSフレームワークを使っています。

index.css
.App {
    margin: 1rem;
    font-family: Arial, Helvetica, sans-serif;
}
.kuc-input-text {
    display: inline-block;
    width: 30em;
}

設定ファイルなど

基本的には kintone-cli を使ってプロジェクト以下の設定ファイル等を自動で作成しています。

package.json

package.json
{
  "name": "b2k01",
  "version": "0.0.1",
  "description": "kintone customization project",
  "author": "K.Y",
  "license": "MIT",
  "dependencies": {
    "@kintone/kintone-js-sdk": "^0.6.2",
    "@kintone/kintone-ui-component": "^0.4.0",
    "bulma": "^0.8.0",
    "bulma-start": "0.0.3",
    "react": "^16.8.6",
    "react-bulma-components": "^3.1.3",
    "react-dom": "^16.7.0"
  },
  "devDependencies": {
    "@babel/cli": "^7.7.4",
    "@babel/core": "^7.3.3",
    "@babel/plugin-proposal-class-properties": "^7.3.3",
    "@babel/plugin-syntax-dynamic-import": "^7.2.0",
    "@babel/plugin-transform-modules-commonjs": "^7.7.4",
    "@babel/polyfill": "^7.7.0",
    "@babel/preset-env": "^7.3.1",
    "@babel/preset-react": "^7.0.0",
    "@cybozu/eslint-config": ">=7.1.0",
    "@kintone/customize-uploader": "^2.0.5",
    "babel-jest": "^24.9.0",
    "babel-loader": "^8.0.5",
    "core-js": "^3.2.1",
    "css-loader": "^2.1.0",
    "eslint": "^6.5.1",
    "jest": "^24.9.0",
    "local-web-server": "^2.6.1",
    "regenerator-runtime": "^0.13.3",
    "style-loader": "^0.23.1",
    "webpack": "^4.30.0",
    "webpack-cli": "^3.2.3"
  },
  "scripts": {
    "dev": "ws",
    "build-backlog2kintone": "webpack --config backlog2kintone/webpack.config.js",
    "lint-all": "eslint . --ext .js,.jsx,.ts,.tsx",
    "lint-all-fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
    "lint-backlog2kintone": "eslint backlog2kintone/ --ext .jsx",
    "lint-backlog2kintone-fix": "eslint backlog2kintone/ --ext .jsx --fix",
    "test": "jest --no-chache --watchAll --coverage"
  }
}

webpack.config.js

kintone-cli で設定したままです。

webpack.config.js
const path = require('path');
const config = {
  entry: path.resolve('backlog2kintone/source/index.jsx'),
  resolve: {
    extensions: ['.ts', '.tsx', '.js']
  },
  output: {
    path: path.resolve('backlog2kintone/dist'),
    filename: 'backlog2kintone.min.js'
  },
  module: {
    rules: [
      {
        test: /.js?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader'
        }
      },
      {
        test: /.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/react']
          }
        }
      },
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  }
};

module.exports = (env, argv) => {
  if (argv.mode === 'development') {
    config.devtool = 'source-map';
  }

  if (argv.mode === 'production') {
    //...
  }
  return [config];
};

kintone-cli で作成されるファイルには、その他に auth.jsonとconfig.jsonがありますがこちらは割愛します。

ユニットテスト

Jestを利用しました。

設定ファイルを貼り付けておきます。

jest.config.js

jest.config.js
module.exports = {
    transform: {
      '^.+\\.[t|j]sx?$'  : '<rootDir>/node_modules/babel-jest',
    },
    moduleFileExtensions: ['js', 'jsx']
  };

.babelrc

{
    "plugins": [
        "@babel/plugin-proposal-class-properties",
        "@babel/plugin-syntax-dynamic-import",
        "@babel/plugin-transform-modules-commonjs"
    ],
    "presets": [
        [
            "@babel/preset-env",
            {
                "useBuiltIns": "usage",
                "corejs": {
                    "version": 3,
                    "proposals": true
                }
            }
        ]
    ],
    "env": {
        "test": {
            "plugins": [
                "@babel/plugin-transform-modules-commonjs"
            ]
        }
    }
}

レコード登録先アプリ

Backlogのレコードが首尾よく登録されると、以下のような画面が表示されます。
あとはもう少し件数を増やしたり、集計方法を工夫したり、Backlogの取得データを違うものにしたりなどなど。
良い感じでお使いいただければと思います。

一覧画面
スクリーンショット 2019-12-11 9.27.17.png

日別のグラフ
スクリーンショット 2019-12-11 9.27.38.png

Backlogの操作種別の円グラフ
スクリーンショット 2019-12-11 9.27.53.png

それでは良い年末を:santa:

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

React + dat.GUI でササッと入力GUIを実装

最近、ジェネレーティブアートに興味があって
p5.jsとか使ってなにかCGアート作品作りたいなぁと考えています。

そのようなCG作品が沢山投稿されてるCodepenっていうサイトがあります
Codepenとか覗いていると、閲覧中によく右上にあるGUIでCGのパラメータを
変更できるパーツがあって、あれって何かなあとずっと疑問に思っていたので調べました。

dat.GUI とは

スクリーンショット 2019-12-11 10.21.18.png

https://github.com/dataarts/dat.gui

Googleのデータアート部門の人が作った、Javascript製の
パラメーター変更のためのグラフィカルで軽量なUIです。

簡単な書き方でササッと入力用GUIを追加できるので、CGアート界隈の人がよく使ってる感じらしいです。

Tutorial

React + dat.GUI

dat.GUIでJavascriptの変数を変更できるのはわかりました。

Reactは単方向データバインディングなので、
Reactとdat.GUIを組み合わせようと思ったら、dat.GUIのパラメータ変更時にsetState()を実行しないといけません。

そのあたりのノウハウを実践してみたので共有したいと思います。

プロジェクトを作る

create-react-appでさくっと作ります。
最近はまってるのでTypescriptでやります。

yarn create react-app react_dat --template typescript

必要なライブラリを追加

dat.GUIに加えて、
state管理にunstated-nextを使います

  • dat.gui
  • @types/dat.gui
  • unstated-next
yarn add dat.gui @types/dat.gui unstated-next

unstated-next

unstated-next は React Context Hook を使いやすくしたライブラリです。

基本的な概念としては、

  • stateを管理・変更通知するContainerオブジェクトを作る
  • <Container.Provider>でコンポーネントをラップする
  • ラップされた内部ではContainer.useContainer()でstateが使える
  • stateに変更があったら、Providerがラップしているコンポーネントツリーを更新する

みたいな感じ?
偉大な先駆者様の解説記事があるのでそちらを見ると良いでしょう
https://qiita.com/kaba/items/b05f680f850dd46548f3

GUIContainerを作る

React Hooksをたくさん使います。

  • useState
  • useMemo
    • dat.GUI オブジェクトをメモリに確保する
  • useEffect
    • 最初の1回だけguiにプロパティを追加する
GUI.ts
import dat from 'dat.gui'
import { createContainer } from 'unstated-next'
import { useState, useMemo, useEffect } from 'react'

const GUIContainer = createContainer(() => {
    const [message, setMessage] = useState('dat.gui')
    const [speed, setSpeed] = useState(0.8)
    const [flag, setFlag] = useState(false)
    const [fruit, setFruit] = useState('apple')
    const [number, setNumber] = useState(1)

    const gui = useMemo(() => new dat.GUI(), [])

    useEffect(() => {
        gui.add({ message }, 'message').onChange(value => setMessage(value))
        gui.add({ speed }, 'speed', -5, 5).onChange(value => setSpeed(value))
        gui.add({ flag }, 'flag').onChange(value => setFlag(value))
        gui.add({ fruit }, 'fruit', [
            'apple',
            'orange',
            'grape'
        ]).onChange(value => setFruit(value))
        gui.add({ number }, 'number', {
            one: 1,
            two: 2,
            three: 3
        }).onChange(value => setNumber(value))

        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [])

    return { message, speed, flag, fruit, number }
})

export default GUIContainer

ちょっと長いファイルになりましたが、dat.GUIの紹介ということで
色々なフォームを詰め込んで見ました。

基本的には、以下のような書式でGUIフォームを追加できます。

dat
import dat from 'dat.gui'

let gui = new dat.GUI()
gui.add(Object, "プロパティ名").onChange(value => /* code */)

.onChange() メソッドは、変更されたvalueを受け取ってなにか処理をするコールバックを登録できます。

このコールバックの中でsetState()を実行すれば、ReactDOMを再描写できるというわけですね。

作ったGUIコンテナのstateを使う

App.tsx
import React from 'react'
import GUIContainer from './GUI'

const App: React.FC = () => {
    const { message, speed, flag, fruit, number } = GUIContainer.useContainer()

    return (
        <>
            <p>{message}</p>
            <p>{speed}</p>
            <p>{`${flag}`}</p>
            <p>{fruit}</p>
            <p>{number}</p>
        </>
    )
}

export default () => (
    <GUIContainer.Provider>
        <App />
    </GUIContainer.Provider>
)

確認してみる

yarn start

localhost:3000 にブラウザでアクセス

スクリーンショット 2019-12-11 10.16.41.png

まとめ

dat.GUI と React を組み合わせるノウハウを書いてみました。

dat.GUIはとても手軽に入力用のGUIを追加できるので、
デジタルアート以外でも、仕事でプロトタイピングのwebアプリの入力を試したい
なんてときにも役に立つと思います。

終わり。

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

新:React + FirestoreでCRUD(基礎編)

別途CRUDの記事を書いたら「複雑すぎる」ということなので簡単バージョンを書きます。
radio, checkbox, file等、各種Form要素のコントロールを知りたい人は、複雑?バージョンをご覧下さい。

仕様

下記のような感じ。
CRUDとはいえ、React(SAP)なので、詳細表示、編集、削除は1つのページで実装します。

スクリーンショット 2019-12-11 9.34.06.png

準備

create-react-app

create-react-appでプロジェクトを作成します。

create-react-app crud-basic
cd crud-basic

必要なモジュールインストール

必要なモジュールをインストールします。

npm install --save bootstrap reactstrap react-router-dom formik yup firebase

yarnならyarn add に置き換えてください。

簡単にモジュールの解説をしておくと、

  • bootstrap reactstrap : bootstrap風に見た目を整えるのに使います
  • react-router-dom:SPA内でページ遷移(ページ移動)するのに使います
  • formik yup : Formの管理とバリデーションについかいます
  • firebase : firebaseを利用するのに使います

必要なファイル生成

あらかじめ必要なファイルを生成しておきます。

cd src
touch Firebase.js

mkdir screens
touch screens/Index.js
touch screens/Create.js
touch screens/Detail.js
touch screens/page404.js

Firebase.js

Firebaseを利用するための設定を行います。各自の環境に合わせて設定してください。

Firebase.js
import firebase from 'firebase/app';
import 'firebase/firestore';

const firebaseConfig = {
    apiKey: "xxxxxxxxxx",
    authDomain: "xxxxxxxxxx",
    databaseURL: "xxxxxxxxxx",
    projectId: "xxxxxxxxxx",
    storageBucket: "xxxxxxxxxx",
    messagingSenderId: "xxxxxxxxxx",
    appId: "xxxxxxxxxx",
    measurementId: "xxxxxxxxxx"
};

firebase.initializeApp(firebaseConfig);
export default firebase;
export const db = firebase.firestore();

実装:骨組み作り

CRUD処理を実装するまえにreact-routerでページ遷移できる最低限の記述と動作チェックをしてみます。

Index.js

ただ、「一覧表示」と表示するだけ。新規作成ページへのリンクを貼っておきます。

Index.js
import React from 'react';
import { Link } from 'react-router-dom';

import firebase, { db } from '../Firebase';

class Index extends React.Component {
    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">一覧表示</h3>
                <div className="my-3"><Link to="/create">新規登録</Link></div>
            </div>
        );
    }
}

export default Index;

Create.js

「新規作成」の表示とTopページへ戻るリンクを設置。

Create.js
import React from 'react';
import { Link } from 'react-router-dom';

class Create extends React.Component {
    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">新規作成</h3>
                <div className="text-right my-3"><Link to="/">一覧へ戻る</Link></div>
            </div>
        );
    }
}

export default Create;

Detail.js

「詳細・編集」の表示とTopページへ戻るリンクを設置。

Detail.js
import React from 'react';
import { Link } from 'react-router-dom';

class Detail extends React.Component {
    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">詳細・編集</h3>
                <div className="text-right my-3"><Link to="/">一覧へ戻る</Link></div>
            </div>
        );
    }
}

export default Detail;

page404.js

404用のページも作っておきます。

page404.js
import React from 'react';
import { Link } from 'react-router-dom';

class page404 extends React.Component{
    render(){
        return(
            <div className="container">
                <h3 className="text-center my-5">Page not found.</h3>
                <div className="text-center"><Link to="/">トップページへ</Link></div>
            </div>
        );
    }
}

export default page404;

App.js

各ファイルができたらルーティングを設定します。

App.js
import React from 'react';
import './App.css';
import { BrowserRouter, Route, Switch } from 'react-router-dom';

//screens
import Index from './screens/Index';
import Create from './screens/Create';
import Detail from './screens/Detail';
import page404 from './screens/page404';

class App extends React.Component {
  render() {
    return (
      <BrowserRouter>
        <Switch>
          <Route exact path="/" component={Index} />
          <Route path="/create/" component={Create} />
          <Route path="/detail/:uid" component={Detail} />
          <Route path="/404" component={page404} />
          <Route component={page404} />
        </Switch>
      </BrowserRouter>
    );
  }
}

export default App;

動作確認

ここまでできたら、各ページの表示やルーティングがうまくいくか確認してください。

npm start

yarn start

Topページはもちろん、/create, /detail, /404等が表示されるか確認してみてください。

実装:本実装

ではそれぞれのページを実装していきます。

Create.js

データがないと表示できないのでCreate.jsから実装します。
nameとemailだけを入力・登録するFormを設置します。Formikで最低限のバリデーションも付けています。

Create.js
import React from 'react';
import { Form, FormGroup, Label, Input, Button, FormFeedback } from 'reactstrap';
import { Link } from 'react-router-dom';
import { Formik } from 'formik';
import * as Yup from 'yup';
import firebase, { db } from '../Firebase';

class Create extends React.Component {

    //登録ボタンが押されたら
    handleOnSubmit = (values) => {
        const docId = db.collection("members").doc().id;
        db.collection("members").doc(docId).set({
            docId: docId,
            name: values.name,
            email: values.email,
            createdAt: firebase.firestore.FieldValue.serverTimestamp(),
        });

        //登録後、Topに移動
        this.props.history.push("/");
    }

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">新規作成</h3>
                <div className="text-right my-3"><Link to="/">一覧へ戻る</Link></div>
                <Formik
                    initialValues={{ name: '', email: '' }}
                    onSubmit={values => this.handleOnSubmit(values)}
                    validationSchema={Yup.object().shape({
                        name: Yup.string().required('氏名は必須です。'),
                        email: Yup.string().email('emailの形式ではありません。').required('Emailは必須です。'),
                    })}
                >
                    {
                        ({ handleSubmit, handleChange, handleBlur, values, errors, touched }) => (
                            <Form onSubmit={handleSubmit}>
                                <FormGroup>
                                    <Label for="name">氏名</Label>
                                    <Input
                                        type="text"
                                        name="name"
                                        id="name"
                                        value={values.name}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.name && errors.name)}
                                    />
                                    <FormFeedback>
                                        {errors.name}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup>
                                    <Label for="email">Email</Label>
                                    <Input
                                        type="email"
                                        email="email"
                                        id="email"
                                        value={values.email}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.email && errors.email)}
                                    />
                                    <FormFeedback>
                                        {errors.email}
                                    </FormFeedback>
                                </FormGroup>
                                <Button type="submit">登録</Button>
                            </Form>
                        )
                    }
                </Formik>
            </div>
        );
    }
}

export default Create;

Index.js

次に登録されたデータの一覧を作成表示ページを作成します。

Index.js
import React from 'react';
import { Link } from 'react-router-dom';

import firebase, { db } from '../Firebase';

class Index extends React.Component {

    state = {
        list: [],
    }

    //データ取得
    getData = async () => {
        const colRef = db.collection("members")
            .orderBy('createdAt', 'desc')
            .limit(10);
        const snapshots = await colRef.get();
        const docs = snapshots.docs.map(doc => doc.data());
        await this.setState({
            list: docs,
        });
    }

    //更新時のcalback
    onCollectionUpdate = (querySnapshot) => {
        //変更の発生源を特定 local:自分, server:他人
        // const source = querySnapshot.metadata.hasPendingWrites ? "local" : "server";
        // if (source === 'local')  this.getData(); //期待した動きをしない
        this.getData();
    }

    componentDidMount = async () => {
        //普通に取得
        await this.getData();
        //collectionの更新を監視
        this.unsubscribe = db.collection("members").onSnapshot(this.onCollectionUpdate);
    }

    //監視解除
    componentWillUnmount = () => {
        this.unsubscribe();
    }

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">一覧表示</h3>
                <div className="my-3"><Link to="/create">新規登録</Link></div>
                <table className="table">
                    <tbody>
                        {
                            this.state.list.map(item => (
                                <tr key={item.docId + String(new Date())}>
                                    <td>{item.docId}</td>
                                    <td>{item.name}</td>
                                    <td>{item.email}</td>
                                    <td><Link to={`/Detail/${item.docId}`}>詳細</Link></td>
                                </tr>
                            ))
                        }
                    </tbody>
                </table>
            </div>
        );
    }
}

export default Index;

実装方法の考察

上記実装では、componentDidMount()でgetData()を実行し値を取得すると同時に、onSnapshot()にて変化を監視し、変更が検知された際にもgetData()を実行しています。多くの場合、DidMount時で十分なのですが、これ以降で実装する新規登録や更新処理において、処理完了時にTopページ("/")にリダイレクトしていますが、そのタイミングではまだdb(firestore)に更新が反映されていないため、リスト表示と操作にギャップが発生するのうめるために、firestoreでの反映が完了した時点で、再度getData()が行われるようにしています。

この実装はこのサンプルの範囲ではうまく稼働しますが、他人による更新がリアルタイムに反映されたり、検索やページネーション等の機能を加えた際、よきせぬ動きになる場合があります。目的に応じて更新ロジックを変更する必要があります。

Detail.js

次に、詳細表示、更新、削除機能を持ったページを作成します。
Create.jsをベースに以下のようにしました。

ポイントは、FormikのinitialValuesに検索結果をセットしているところです。また、そのためにenableReinitializeをtrueにしています。

<Formik
    enableReinitialize
    initialValues={{ name: this.state.member.name, email: this.state.member.email }}

以下、実装です。

Detail.js
import React from 'react';
import { Form, FormGroup, Label, Input, Button, FormFeedback } from 'reactstrap';
import { Link } from 'react-router-dom';
import { Formik } from 'formik';
import * as Yup from 'yup';
import firebase, { db } from '../Firebase';

class Detail extends React.Component {

    state = {
        member: { name: '', email: '' }
    }

    //更新ボタンが押されたら
    handleOnSubmit = (values) => {
        db.collection("members").doc(this.props.match.params.uid).update({
            name: values.name,
            email: values.email
        });

        //Topに移動
        this.props.history.push("/");

    }

    //uidで指定したメンバーの値を取得
    getMember = async (uid) => {
        const docRef = db.collection("members").doc(uid);
        const doc = await docRef.get();
        //ドキュメントの存在確認
        if (doc.exists) {
            this.setState({
                member: doc.data(),
            });
        }else{
            //なければ404ページへ
            this.props.history.push("/404");
        }
    }

    //delete
    handleDelete = (uid) => {
        if (window.confirm('削除しますか?')) {
            db.collection("members").doc(uid).delete();
            this.props.history.push("/");
        }
    }

    //値を取得
    componentDidMount = () => {
        this.getMember(this.props.match.params.uid);
    }

    render() {
        return (
            <div className="container">
                <h3 className="text-center my-5">詳細・編集</h3>
                <div className="text-right my-3"><Link to="/">一覧へ戻る</Link></div>
                <Formik
                    enableReinitialize
                    initialValues={{ name: this.state.member.name, email: this.state.member.email }}
                    onSubmit={values => this.handleOnSubmit(values)}
                    validationSchema={Yup.object().shape({
                        name: Yup.string().required('氏名は必須です。'),
                        email: Yup.string().email('emailの形式ではありません。').required('Emailは必須です。'),
                    })}
                >
                    {
                        ({ handleSubmit, handleChange, handleBlur, values, errors, touched }) => (
                            <Form onSubmit={handleSubmit}>
                                <FormGroup>
                                    <Label for="name">氏名</Label>
                                    <Input
                                        type="text"
                                        name="name"
                                        id="name"
                                        value={values.name}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.name && errors.name)}
                                    />
                                    <FormFeedback>
                                        {errors.name}
                                    </FormFeedback>
                                </FormGroup>
                                <FormGroup>
                                    <Label for="email">Email</Label>
                                    <Input
                                        type="email"
                                        email="email"
                                        id="email"
                                        value={values.email}
                                        onChange={handleChange}
                                        onBlur={handleBlur}
                                        invalid={Boolean(touched.email && errors.email)}
                                    />
                                    <FormFeedback>
                                        {errors.email}
                                    </FormFeedback>
                                </FormGroup>
                                <Button type="submit" color="success">更新</Button>
                            </Form>
                        )
                    }
                </Formik>
                <div className="my-3">
                    <Button color="danger" onClick={() => this.handleDelete(this.props.match.params.uid)}>削除</Button>
                </div>
            </div>
        );
    }
}

export default Detail;

簡単ではありますが、以上です。

応用

  • 各種Formのバリデーションを知りたければこちら
  • ページネーションや検索はこちら
  • あと、2019年12月現在、Formikが変なエラー?を吐くようになっていて、その対応方はこちら
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Transcribeの英語ミーティング文字起こしをmarkdownにする

fushimiです。
この記事は Wanoグループ Advent Calendar 2019 Advent Calendar 2019 の11日目の記事になります。

(英語)会議 is ...:innocent:

最近プロジェクトではニューヨークのチームと英語での会議をする機会が多いです。基本リスニングも不得手なので、会議同席中も熱が入って速度が速い時はなかなか聞き取れないことがあります。
そこで会議の後など、勉強/議事録がてらAmazon Transcribeでの音声文字起こしを見てわからなかったところの文脈を追ってみたりしています。
今回はTranscribeの出力をmarkdown化するやつをwebアプリに起こしてみました。

制作物

リポジトリ:
wano/aws-transcribe-render

アプリケーションのページ:
https://wano.github.io/aws-transcribe-render/

Amazon Transcribe

いわゆる文字起こしサービスです。S3上の音声ファイルを解析してくれます。
最近日本語対応したり東京リージョンで使えるようになったりしました。
ただ文字起こしをする、ってだけではなく、話者解析(誰がそのフレーズを喋っているか)が取得できるのがなかなか面白いところかな、と思います。
Termは割と適当なんだけど、話者解析もあって文脈が追えるので復習にはいいかな...というステータスのサービスです。

ちょっとした文ならコンソール上で結果がプレビューできるのですが、ある程度の長さになると出力結果であるオリジナルのjsonを使うしかありません。

...
Can you see the seats? No option. Hello. It's not"}],"speaker_labels":{"speakers":8,"segments":[{"start_time":"1.44","speaker_label":"spk_4","end_time":"2.35","items":[{"start_time":"1.44","speaker_label":"spk_4","end_time":"1.81"},{"start_time":"1.94","speaker_label":"spk_4","end_time":"2.35"}]},{"start_time":"11.94","speaker_label":"spk_4","end_time":"12.45","items":[{"start_time":"11.94","speaker_label":"spk_4","end_time":"12.45"}]},{"start_time":"13.71","speaker_label":"spk_4","end_time":"14.16","items":[{"start_time":"13.71","speaker_label":"spk_4","end_time":"14.16"}]},{"start_time":"14.71","speaker_label":"spk_4","end_time":"15.38","items":[{"start_time":"14.71","speaker_label":"spk_4","end_time":"15.38"}]},{"start_time":"16.24","speaker_label":"spk_4","end_time":"16.91","items":[{"start_time":"16.24","speaker_label":"spk_4","end_time":"16.91"}]},{"start_time":"25.86","speaker_label":"spk_1","end_time":"26.97",
...

こういう感じ。なかなか辛い

初めはPHP製のパーサーaws-transcribe-transcriptを改変してコマンド叩いていたのですが、jsonパース/整形/改変くらいwebのクライアントサイドでサクッとやれるべきだよな...という感想があったので、今回のアドベントカレンダーを機にjsで書いてみました。

aws-transcribe-render

image.png

markdown化する、と書きましたが、mustache記法でテンプレを書いているだけなのでなんでもありといえばありです。
テンプレートの塊は、ある話者が話し始めてから終わるまでとなっています。

使い方

事前: まずは文字起こし

まず、会話/会議の音声データをs3に上げ、transcribeのコンソールからjson化しておきます。
image.png

jsonをアプリに入力/テンプレート編集

そのjsonをこちらのアプリに入力します。

  • speaker
  • text
  • time

がテンプレート変数として渡ってくるので、markdownでもhtmlでもお好みのフォーマットで出力できます。

話者名の上書き機能

Transcribeのデフォルトの話者名を上書きすることもできます。
spk_0 という身も蓋もないラベリングになってたりするので、多少記憶を掘り起こし話者の名前を書きましょう。

結果

ここまでの結果が以下のような感じです。

image.png

あとはコピーして議事録に貼り付けるなど。

まとめ

これで、出力結果をinputするだけでさっくりと編集する機能ができました。
英語で行われるミーティングでまた試す予定ですが、そういえば日本語会話でTranscribeを試したことがないので、そちらの精度も気になりますね。

課題

  • 外に出しても差し支えないような会話でアプリにサンプルを載っけたかったんだけど、著作権フリーな「会話音声データ」ってなかなか見つからないですね....
  • GUIと関係ないコア部分のロジックは切り離されていますが、肝心のnpmモジュール化とかはまだしていません。 1年以上ぶりくらいにReactを触ったら「useStateすげー!」「useEffectすげー!」ってなってたりしたのでそっちで時間が溶けました。
  • XSS対策真面目にやっていません。mustacheのテンプレをそのまま出しています。どこか永続化するサイトに使うわけでもないのでどうということはないのですが。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React+Formikのvalidationで[Uncaught (in promise)]というエラーが出る際の対応

いつの頃からかValidationでエラーの際に、ChromeのConsoleに、

スクリーンショット 2019-12-11 5.36.25.png

というようなエラーが表示されるようになっていた。
エラーは本当の意味でのエラーではなく、"正常に"バリデーションが効いた際に表示されているようだ。

古いサンプルを動かしたら出ないのでバージョンの問題か?。原因は不明。

エラーが表示されるコード(いままで)

render={}としている以外は本家のサンプルと変わらない。

render=()使っても出ます。

import React from 'react';
import './App.css';

import { Formik } from 'formik';
import * as Yup from 'yup';

class App extends React.Component {
  render() {
    return (
      <div style={{padding:30}}>
        <Formik
          initialValues={{ name: '' }}
          onSubmit={(values) => alert(JSON.stringify(values))}
          validationSchema={Yup.object().shape({
            name: Yup.string().required(),
          })}
        >
          {
            ({ handleSubmit, handleChange, handleBlur, values, errors, touched, setErrors }) => (
              <form onSubmit={handleSubmit}>
                <input
                  type="text"
                  name="name"
                  id="name"
                  value={values.name}
                  onChange={handleChange}
                  onBlur={handleBlur}
                />
                <div style={{color:'red'}}>
                  {errors.name}
                </div>
                <div>
                  <button type="submit">submit</button>
                </div>
              </form>
            )
          }
        </Formik>
      </div>
    );
  }
}

export default App;

このコードを実行してValidationを効かせると、

スクリーンショット 2019-12-11 5.49.00.png

という感じになる。。。
ほっといてもいいが、気持ち悪いので対応を考えました。

改善(案)

エラーメッセージ自体は「Promiseのrejectがcatchされてないぞ!」というものなので、catchするようにすればよい。
が、どこでcatchすればいいのだろう・・・と試行錯誤し、とりあえず下記のようにしました。

要点は、

  • formのonSubmit={handleSubmit}をやめる
  • submitボタンが押されたときにsubmitForm()を実行し、そこでエラーをcatch
  • いちおうsetErrorsでerrorをerrorsに反映(これはやらなくても動く)

という感じ。
handleSubmitをオーバーライドしてもいい気がしたが、一旦対応とする。

import React from 'react';
import './App.css';

import { Formik } from 'formik';
import * as Yup from 'yup';

class App extends React.Component {
  render() {
    return (
      <div style={{ padding: 30 }}>
        <Formik
          initialValues={{ name: '' }}
          onSubmit={(values) => alert(JSON.stringify(values))}
          validationSchema={Yup.object().shape({
            name: Yup.string().required(),
          })}
        >
          {
            ({ handleSubmit, handleChange, handleBlur, values, errors, touched, submitForm, setErrors }) => (
+              <form>
                <input
                  type="text"
                  name="name"
                  id="name"
                  value={values.name}
                  onChange={handleChange}
                  onBlur={handleBlur}
                />
                <div style={{ color: 'red' }}>
                  {errors.name}
                </div>
+                <div>
+                  <button type="submit" onClick={(e) => {
+                    e.preventDefault();
+                    submitForm().catch(error => setErrors(error));
+                  }}>submit</button>
+                </div>
              </form>
            )
          }
        </Formik>
      </div>
    );
  }
}

export default App;

上記を実行すると、

スクリーンショット 2019-12-11 5.50.04.png

いちおう何も出ない。

その他

以前に自分が書いたコードでは出てなかった。その際のFormikのバージョンは2.0.6で、現在は2.0.7なのでバージョンを落としてみたが、だめだった。ブラウザのバージョン差か?と思ったりもしたが、違うみたい・・・。なんだろう。

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

Redux Dynamic Modulesで楽してReduxのCode Splittingしようぞ!

この記事はReact Advent Calendar 2019の10日目担当@Sinack_jpです。

はじめに

タイトルが全てを物語っています。
reducerの種類がそこそこあるReact + Reduxなアプリケーションを開発するにあたって面倒なことをせずにreducerをモジュール化して、ハッピーなCode Splittingライフを送りましょうという記事です。

Code Splittingってそもそもなんぞ

Reactなどで作られたSPAは、バンドルされたJavaScriptファイルを読み込むことでアプリケーションとして動作することは皆さんご存知の通りと思います。
でも、アプリケーションの規模が大きくなってくると、バンドルされるファイルサイズもどんどん大きくなってきて、それに伴って起動時間がどんどん遅くなっていくわけです。
そこで、バンドルされるファイルの分割し、必要な部分を必要な時に動的に読み込むことでパフォーマンスを改善しましょうぞ。
というのがCode Splitting。

ReduxのCode splittingについて

で、本題に入ります。
実際にReact/Reduxなアプリケーションを開発する際には、複数のreducerを組み合わせてアプリケーションを開発することがほとんどだと思います。
そういった場合、combineReducers()でreducerを束ねたrootReducerのようなものをcreateStore()に渡して、みたいなことをするのが一般的かと思うのですが、それだけだとすべてのreducerが常にロードされている状態なので、reducerの種類が増えてくると、このときはこのreducerだけ読み込めてればいいのになあ、という状況が生まれる場合があります。
(もちろんすべてのreducerが常に読み込まれていないと機能しないような場合は読み込まないとだめです)

Reduxでは、Code Splittingの方法がドキュメントに記載されていて、そちらも読んではみたものの、なんかとにかく面倒くさそうなイメージと、redux-thunkやredux-sagaなどのミドルウェアが絡むとより一層わからん感が増してきて、うげっとなってしまっていました。
そんな感じでテンションが下がっていたところ、この記事の本題でもあるRedux Dynamic Modulesがページの下のほうにこそっと紹介されていたので使ってみたところ、なんかわかりやすいぞ。という気持ちになったので記事にしています。

Redux Dynamic Modulesについて

やっと本題です。
実はmicrosoftが作っているライブラリなので、ドキュメントがゴイスーちゃんとしています。

Redux Dynamic Modules
ドキュメントページ

Redux Dynamic Modulesをざっくり説明すると、storeとredux-thunk , redux-sagaなどのミドルウェアをグループとしてまとめたり、そのグループを好きなタイミングで読み込んだり外したりすることができるライブラリです。
非同期処理のミドルウェアはredux-thunk,redux-sagaに対応していて、今回の記事では扱いませんがこちらも簡単に組み込むことができます。

また、Reduxを使ったアプリケーション開発を行うときにはRedux Devtools Extensionがないと生きていけないのですが
Redux Dynamic Modulesを使うと、勝手にRedux Devtools Extensionも適用してくれます。地味に楽です。

サンプルを用意しました

リポジトリはこちら

今回のサンプルでは

  • カウンター
  • メッセージボード

という2つの機能があるアプリケーションを作りました。

カウンター内ではメッセージボードのreducer,storeは全く使う必要がなく、
逆も同様に、メッセージボード内ではカウンターのreducer,storeは全く必要としていません。

カウンターとメッセージボードは、react-routerで表示するコンポーネントを切り替えられるような作りになっていて
カウンターが表示されているときは、カウンターで必要なreducer,storeのみ、
メッセージボードが表示されている時は、メッセージボードで必要なreducer,storeのみを動的に付け外ししています。

あとせっかくなのでreact-routerはconnected-react-routerを使ってRedux storeで管理できるようにしています。

ふんわりした説明

/src/Counter/src/MessagesListというディレクトリに、
コンポーネントやらactionやらreducerやらをまとめて入れてあります。

module.js以外は至って普通のReact/Reduxな登場人物です。
module.jsの中はこんな感じになってます。

/src/Counter/module.js
import couterReducer from "./reducer";

const counterModule = () => {
  return {
    id: "counter",
    reducerMap: {
      counter: couterReducer,
      // initialActions: [hogeAction()],
      // finalActions: [hugaAction()],
    },
  };
};
export default counterModule;

counterModuleが返すオブジェクトはカウンターで使うreducerをグループ化(といっても今回reducerは1つですが)idをつけ(ここではcounter)モジュールにしたものです。

reducerMapには、このモジュールで使うreducerを複数指定することができ、このモジュールが読み込まれると、
ここで指定したreducerがすべて読み込まれる、という感じになります。
今回は使わないのでコメントにしてありますが、initialActionsfinalActionsというキーを指定することで
このモジュールが追加されたとき、削除されたときに発火するアクションを指定することができます。便利・・・

んで次はコンポーネントファイルであるindex.jsを見てみます。

/src/Counter/index.js
import React from "react";
import { DynamicModuleLoader } from "redux-dynamic-modules";
import { useCounter } from "./useCounter";
import counterModule from "./module";

const Counter = () => {
  const { counterStore, increment, decrement } = useCounter();

  return (
    <div>
      <div>カウンター:{counterStore}</div>
      <button onClick={() => increment()}>+1</button>
      <button onClick={() => decrement()}>-1</button>
    </div>
  );
};

export default () => (
  <DynamicModuleLoader modules={[counterModule()]}>
    <Counter />
  </DynamicModuleLoader>
);

CounterコンポーネントをDynamicModuleLoaderコンポーネントでラップし、modulespropsにさきほどのオブジェクトを返す関数を渡しているのがポイントです。
こうしてやることで、DynamicModuleLoaderでラップされたコンポーネントがレンダーされるとき、指定したモジュールが動的にロードされます。

また、modules={[counterModule()]となっていますが、ここではモジュールオブジェクトを配列で複数設定することも可能です。

ちなみにここでexportしたものは/src/routes.jsで読み込んでいます。

/src/routes.js
import React from "react";
import { Route, Switch } from "react-router-dom";
import Counter from "./Counter";
import MessagesList from "./MessagesList";
import Menu from "./Menu";

const routes = (
  <>
    <Menu />
    <Switch>
      <Route exact path="/" component={Counter} />
      <Route path="/messages" component={MessagesList} />
    </Switch>
  </>
);

export default routes;

次に、おなじみProviderにstoreを設定してあげるところを見てみましょう。

/src/index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { DynamicModuleLoader } from "redux-dynamic-modules";
import { Provider } from "react-redux";
import { history, configureStore } from "./store";
import * as serviceWorker from "./serviceWorker";

const store = configureStore();
ReactDOM.render(
  <Provider store={store}>
    <DynamicModuleLoader>
      <App history={history} />
    </DynamicModuleLoader>
  </Provider>,
  document.getElementById("root")
);

serviceWorker.unregister();

ここは普通にReduxを使うときとあまり変わらないです。
余談ですが、Appコンポーネントは、react-routerをRedux storeで管理するためのConnectedRouterを返していて、
ConnectedRouterは、さきほどのroutesをラップしています。
これだけだとconfigureStore()が何をしているのかわからないので./store.jsを見てみましょう。

/src/store.js
import { createStore } from "redux-dynamic-modules";
// import { getSagaExtension } from "redux-dynamic-modules-saga";
import { routerMiddleware } from "connected-react-router";
import { createBrowserHistory } from "history";
import { applyMiddleware, compose } from "redux";
import { routerModule } from "./modules/router/routerModule";

export const history = createBrowserHistory();

export const configureStore = (preloadedState = {}) => {
  return createStore(
    {
      initialState: preloadedState,
      enhancers: [compose(applyMiddleware(routerMiddleware(history)))],
      // extensions: [getSagaExtension()],
    },
    routerModule()
  );
};

export default configureStore;

普段は、reduxcreateStore()を使うところ、redux-dynamic-modulescreateStore()を使っているのがポイントです。
コード内ではコメントアウトしてありますが、extensionsというキーでredux-sagaやredux-thunkの設定も行えます。

こうして無事にstoreをProviderに設定することができました。

起動してみる

実際にアプリケーションを起動して確認してみると、下の画像のようにカウンターとメッセージボードで
それぞれ必要なstoreだけが読み込まれているのが分かると思います。
リンクをクリックすると、コンポーネントのレンダー時に、storeが切り替わるので試してみてください。

adcal_gif.gif

さいごに

Redux Dynamic Modulesは今回紹介した以外にも、モジュールの依存関係を設定するための機能などもあったりして、
とても便利に使えるライブラリです。スターが600ちょいしかついてないのが不思議でしょうがないです。
めちゃくちゃ便利なのに・・・

本当はconnected-react-routerやredux-saga、モジュールの依存やなんやを絡めていろいろ説明したかったのですが、
時間がなかったよすまん・・・
それはまたの機会にでもできたらなと思いまっす。

かなりダッシュかつざっくりしすぎた説明でReduxをあまり触らない方は意味わからん内容になってしまったかもと思いつつ
いつかどっかで紹介してやろうと思っていたRedux Dynamic Modulesを紹介できてよかったです。

本当にドキュメントわかりやすいので、この記事で興味をもっていただけたら是非使ってみてください!
それでは!

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

エーッ!HooksでcomponentDidUpdate出来ないんですか!!?

こんにちは!Hanakla(@_ragg_)です!
React Hooks Advent Calender 11日目となるこの記事では、「意外と実装できないuseEffectでcomponentDidUpdateするアレ」を実装した小話をします。

TL;DR

使って、どうぞ。

useUpdateEffect.ts
import { useRef, useEffect, DependencyList } from 'react'

export const useUpdateEffect = <T extends DependencyList>(
  effect: (...args: T) => void | (() => void | undefined),
  watch: T,
  deps: DependencyList = [],
) => {
  const prevWatch = useRef<T | null>(null);

  useEffect(() => {
    if (prevWatch.current == null) {
      prevWatch.current = watch;
      return;
    }

    // React Hooksの内部の比較処理とObject.isを使ってない以外は同等の変更検出処理
    for (const idx in watch) {
      if (prevWatch.current[idx] !== watch[idx]) {
        const prev = prevWatch.current;
        prevWatch.current = watch;
        return effect(...prev);
      }
    }
  }, [...watch, ...deps]);
};
使い方.ts
import { useUpdateEffect } from './useUpdateEffect'

const SomeComponent = ({ lastUpdate }: { lastUpdate: number }) => {
  useUpdateEffect((prevLastUpdate) => {
    // Do something when `lastUpdate` changed
  }, [ lastUpdate ], [ /* なんか依存してる変数があればここに */ ]);

 return null
};

componentDidUpdateは素直にhooksに置き換えられない

みなさんHooks使ってますか? 局所的に生じるViewの重い処理をuseMemoでキャッシュさせて高速化できると気持ちいいですね。

さて、Class ComponentからFunction Component w/ Hooksへの置き換えをしていると、実はcomponentDidUpdateと同じような挙動をしてくれるhooksがないということに気づきました。

一番近いものでuseEffectなんですが、これはcomponentDidUpdateとは実行タイミングが異なります。

  • componentDidUpdate
    • Componentがマウントされても実行されない
    • Componentが更新されたら実行される
  • useEffect
    • Componentがマウントされたらまず実行される
    • depsに更新があれば実行される / depsが未指定なら更新ごとに実行される

さて、ざっくりした要件としてはつまり初回マウント時の処理を無視してあげるuseEffectを作ればいいという感じですね。じゃあ作ってみましょう。

import { useRef, useEffect } from 'react'

export const useUpdateEffect = (
  effect: () => (void | () => void | undefined), 
) => {
  // useState使うとレンダリング走りそうで嫌なので`useRef`を使う
  const initial = useRef(true)

  useEffect(() => {
   if (initial.current) {
     initial.current = false
     return
   }

    return effect()
  })
}

はい。一番愚直なcomponentDidUpdateのhooks版の原型です。(たぶん実行タイミングが若干違うと思うが)じゃあもうちょっとcomponentDidUpdateに合わせて、直前の値を使えるようにしてみましょう。

まずインターフェースとしてはこんな感じですかね

useUpdateEffect((prev) => { /* effect */ }, [ current ])

じゃあ実装します

import { useRef, useEffect, DependencyList } from 'react'

export const useUpdateEffect = (
  effect: () => (void | () => void | undefined), 
  current?: DependencyList
) => {
  // currentは必ず配列を受けるのでprevious.currentがnullかどうかだけで初回か判断してよい
  // const initial = useRef(true)
  const previous = useRef(null)

  useEffect(() => {
   if (previous.current == null) {
     previous.current = current
     return
   }

    return effect(...previous.current)
  }, current)
}

これで多分componentDidUpdateとほぼ同等になりました。でも実はこのコードには欠陥があります。
それはeffectの中でクロージャ外の変数を参照すると、古い変数への参照をもちっぱなしになるという、hooksでdepsの指定を忘れるとありがちなやつですね。なのでdepsを追加で指定できるようにしましょう。

previouseffectに渡すので、このdepspreviousと混同してはいけません。実装してみます。

import { useRef, useEffect, DependencyList } from 'react'

export const useUpdateEffect = (
  effect: () => (void | () => void | undefined), 
  current?: DependencyList
  deps?: DependencyList
) => {
  const previous = useRef(null)

  useEffect(() => {
   if (previous.current == null) {
     previous.current = current
     return
   }

    return effect(...previous.current)
  }, [...deps, ...current])
}

はい。これで一見いいように見えますが、depsが入った事により、depsだけの更新でもeffectが走るようになってしまいました… 本当にeffectを走らせるべきかどうかは、currentが変わったかどうかで判断しなければなりません。
またHooksとして振る舞っているので、出来るだけReact標準のHooksに近い挙動にしたいですね。なのでcurrentとpreviousの比較処理はHooksの実装コードからパクって来ましょう。

Reactのコードを読んでいくと、react-reconciler/src/ReactFiberHooks.jsuseEffectの定義があります。

ここからコードをたどっていくとupdateEffectupdateEffectImplareHooksInputsEqual と辿って、ようやくdepsの比較処理が出てきます

  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;

そしてこのisが何かというと、同ファイル内で以下のimportをされている関数です。実際のファイルはreact/packages/shared/objectIs.jsにあります。Object.isのポリフィルのようですね。

import is from 'shared/objectIs';

とはいえ、ぱっと書き捨てのhooksにObject.isのポリフィルをつけるのもなんだか気が重いので、雑にfor-inして!==な要素があったら変化したということにするという方針でやります。

あとは、「関数のインターフェースにcurrentってあるけどwatchesの方が何の値入れてほしいのか伝わるじゃん」とか、細かい直しをすると記事の頭で出したコードになります。

import { useRef, useEffect, DependencyList } from 'react'

export const useUpdateEffect = <T extends DependencyList>(
  effect: (...args: T) => void | (() => void | undefined),
  watch: T,
  deps: DependencyList = [],
) => {
  const prevWatch = useRef<T | null>(null);

  useEffect(() => {
    if (prevWatch.current == null) {
      prevWatch.current = watch;
      return;
    }

    // React Hooksの内部の比較処理とObject.isを使ってない以外は同等の更新処理
    for (const idx in watch) {
      if (prevWatch.current[idx] !== watch[idx]) {
        const prev = prevWatch.current;
        prevWatch.current = watch;
        return effect(...prev);
      }
    }
  }, [...watch, ...deps]);
};

はい

いかがでしたか? 弊プロダクトでは割とcomponentDidUpdateを使う機会が多かったので「やるかーやるしかないかー」の気持ちで作りました。(アイテムをアップロードし終わったらモーダル閉じますみたいなやつな)

御プロダクトでも使う機会があったら適当に使ってください。
明日のReact Hooks Advent Calenderは daishi さんの「react-hooks-workerの紹介」です。
(えっ待って、絶対ヤバいやつじゃん… 楽しみ…)

僕はTypeScript Advent Calender 15日目にも型定義テクみたいな話をするので、そちらもお楽しみに。
最後まで読んでいただいてありがとうございました〜

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