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

更新後のstateの値を使ってdebounce処理をしたい

デモ

App.js

import React, { useState, useEffect, useCallback } from "react";

export default function App() {
  const [state, setState] = useState("");

  const callback = useCallback(() => {
    //この中で更新後の値が使える
    console.log("debounced!!!", state);
  }, [state]);

  useEffect(() => {
    const handler = setTimeout(callback, 1000);
    //cleanup
    return () => {
      clearTimeout(handler);
    };
  },[callback);
  return (
    <div className="App">
      <input type="text" value={state} onChange={e => setState(e.target.value)} />
    </div>
  );
}

普通にlodashとか使って作ったdebounce関数をuseEffectにぶちこむと更新後のstateを使うためには引数として渡さなければいけないのですが、useEffectのcleanup関数をうまく使ってあげることでstateが変わりcallback関数が再作成されるたびcleanupの中のclearTimeoutが待機中だった処理をキャンセルして、effectの中で新しいsetTimeoutを作成することでdebounce処理を行うことができます。

customHook版

useDebouncedEffect.js

import { useEffect, useCallback } from "react";

const useDebounceeEffect = (effect, deps, delay) => {
  const callback = useCallback(effect, deps);

  useEffect(() => {
    const handler = setTimeout(callback, delay);
    //cleanup
    return () => {
      clearTimeout(handler);
    };
  }, [callback, delay]);
};

export default useDebounceeEffect;

App.js

import React, { useState } from "react";

import useDebouncedEffect from "./useDebouncedEffect";

export default function App() {
  const [text, setText] = useState("");

  useDebouncedEffect(
    () => {
      console.log("debounced!!!", text);
    },
    [text],
    1000
  );

  return (
    <div className="App">
      <input type="text" value={text} onChange={e => setText(e.target.value)} />
    </div>
  );
}

これでかんたんにstateの更新と紐付けたdebounce処理ができます

やったぜ。

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

日本一わかりやすいReact-Redux講座 実践編 #1~2 学習備忘録

はじめに

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

ここから、いよいよ本格的にアプリを開発していきます。

前回記事はこちら

要約

  1. 店舗:ユーザー = 1:n のECアプリを開発する
  2. Firebase Authによる新規登録、サインイン認証を実装する
  3. reducksパターンに沿った関数の実装手順を理解する

#1 ECアプリの機能とデータ設計

実装するアプリ概要

  • ファッション系のECアプリ。服や靴などが出品される。
  • 店舗:ユーザー = 1:n
    • 店舗は商品の出品、在庫管理、売り上げ管理ができる
    • ユーザーは商品閲覧、カートへ追加、購入、注文履歴の確認ができる

ユニクロや無印のECアプリのように、商品の出品者が固定の設計(メルカリやヤフオクのように、ユーザー自身が出品者になれるものが、n:n のECアプリ)

実装する機能の整理

現時点では完璧には中身を理解できていませんが、後で振り返られるよう、動画内容に沿ってまとめておきます。

認証機能

ユーザーの新規作成、ログイン、ログアウト機能を実装する。Firebase Authを利用します。

認証のリッスン

認証情報をブラウザに残すことで、再ログインの手間を省く。アプリ全体をAuthコンポーネントでラッピングして実装します。

商品情報のCRUD

  • Create: 商品情報の追加
  • Read: 商品情報の読み込み
  • Update: 商品情報の更新
  • Delete: 商品情報の削除

Firebase の Cloud Function を DB として利用する。商品情報の操作は、管理者ユーザーしか行えないようにします。

react-swiper による画像スライダー

一つの商品情報に対して複数の画像を対応させたい(例えば、同じ型のTシャツの色違い、など)ので、動的な画像スライダーを作ります。

Drawer メニュー

ヘッダーコンポーネントとして、表示・非表示を動的に制御できるメニューを作る。Material-UI を活用します。

カートへの商品追加

商品をカートに追加することで、カートアイコン上に表示される"アイテム数"を変更させる。Firestore でデータをリッスンします。

商品の注文

トランザクションを実装します( Firestore を利用)。加えて、注文履歴の閲覧機能も作ります。

タグ検索機能

「メンズ」「レディース」「トップス」「ボトムス」のようなタグを用いた検索機能を作ります。Firestore indexes を活用する。

データ設計

データ設計
1. categoriesコレクション
2. productsコレクション
3. usersコレクション
  ├── 3-1. cartサブコレクション
  └── 3-2. ordersサブコレクション

categoriesコレクション

商品情報をグループ分けするためのタグの情報を保存する。

productsコレクション

商品情報を保存する。

usersコレクション

ユーザー情報の保存する。

cartsサブコレクション

対応するユーザーのカートも中身の情報を保存する。

orders サブコレクション

対応するユーザーの注文履歴の情報を保存する。

#2 Firebase Authで認証機能を作ろう

Firebase Authとは

Firebaseの中に含まれている、認証機能を簡単に実装するための機能。非常に少ないコード量で処理が複雑な認証機能を実装できます。

パスワード認証、電話番号認証、各種OAuth認証(gmail, twitter, facebookなど)などの複数の認証方法に対応しています。

今回は最も基本的なメール/パスワード認証を実装します。

ConsoleからAuthcenticationを設定する

まず最初に、Firebase側でFirebase Authの機能を有効します。

Firebaseコンソールから、Authenticationに移動。

image.png

メール/パスワードの鉛筆マークから、本機能を有効化します。

image.png
image.png
image.png

これで有効化ができました!とっても簡単。

Firebase用の設定ファイルを作る

ここからは、アプリ側でコードを書いていきます。

まず最初に、有効化したFirebase Auth機能をアプリと連携する設定変数を取得します。

コンソールの歯車マーク->プロジェクトを設定
image.png

Firebase SDK snippet構成を選択すると、設定変数が取得できます。

image.png

アプリ側でfirebase用のファイルを作成します。srcディレクトリ直下にfirebaseディレクトリを作り、ここに設定用ファイルを定義します。

src
 └─ firebase
     ├─ config.js
     └─ index.js  
src/firebase/config.js
export const firebaseConfig = {
  apiKey: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  authDomain: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  databaseURL: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  projectId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  storageBucket: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  messagingSenderId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  appId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  measurementId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX"
};

先ほどの設定変数を保存するファイル。文頭でexportしている点は注意。

src/firebase/index.js
import firebase from "firebase/app";
import 'firebase/auth';
import 'firebase/firestore';
import 'firebase/storage';
import 'firebase/functions';
import {firebaseConfig} from "./config";

firebase.initializeApp(firebaseConfig);
export const auth = firebase.auth();
export const db = firebase.firestore();
export const storage = firebase.storage();
export const functions = firebase.functions();
export const FirebaseTimestamp = firebase.firestore.Timestamp;
  • firebaseの各種機能と、先ほど定義したfirebaseConfigをimportする
  • firebase.initializeApp(firebaseConfig);と書くことで、reactアプリとfirebaseインスタンスを接続する(接続先のfirebaseインスタンスを定義する)
  • firebaseの各種機能を定数化し、外部ファイルで使用できるようexportする

ファイルを整理

ここまでの講座で作成したファイルやcreate-react-appで自動作成されたファイルのうち、不要なものを削除しておきます。

削除ファイル
## css関連ファイル。cssは別に用意されたものを使うため
src/App.css
src/index.css

## テスト関連ファイル。今回はテストは扱わないため。
src/App.test.js
src/logo.svg
src/setup.js

## 基礎編#10で作成したコンテナーコンポーネント。今後はHooksのみを使用する。
src/containers/index.js
src/containers/Login.js
src/templates/LoginClass.jsx

src/index.jsで、src/App.cssを読み込む記述が残っているため、削除。

src/index.js
import * as History from 'history';
// import './index.css'; // 削除
import App from './App';
import * as serviceWorker from './serviceWorker';

本講座のデモアプリが公開されているgithubリポジトリ(https://github.com/deatiger/ec-app-demo)から、css関連ファイルを含むsrc/assetsディレクトリをダウンロードし、開発アプリのsrcディレクトリ直下に配置する。

assetsディレクトリ
assets
 ├── img
 │   ├── icons
 │   │   └── logo.png
 │   └── src
 │       ├── no-profile.png
 │       └── no_image.png
 ├── reset.css
 ├── style.css
 └── theme.js

cssファイルとしては、今後のこのreset.cssstyle.cssを使用していきます。

これらを読み込む記述をsrc/App.jsxに加えます。

src/App.jsx
import React from 'react'
import Router from './Router'
import "./assets/reset.css"
import "./assets/style.css"
.
.
.

SignUp画面を作る

template として SignUpコンポーネントを作成するにあたり、必要になる UI コンポーネントを先に用意します。

componentsディレクトリの下にUIkitディレクトリを作成し、その中で複数のコンポーネントで使用するための UI コンポーネントをまとめて管理するようにします。

components
└── UIkit
    ├── PrimaryButton.jsx // ボタンのUIコンポーネント。SignUp画面では、アカウント登録のボタンとして使う
    ├── TextInput.jsx // テキスト入力フィールド。
    └── index.js // エントリーポイント

PrimaryButtonコンポーネント、TextInputコンポーネントは、Material-UIを活用して作成します。

src/components/UIkit/PrimaryButton.jsx
import React from "react";
import Button from "@material-ui/core/Button";
import {makeStyles} from "@material-ui/styles";

const useStyles = makeStyles({
  "button": {
    backgroundColor: "#4dd0e1",
    color: "#000",
    fontSize: 16,
    height: 48,
    marginButton: 16,
    width: 256
  }
})

const PrimaryButton = (props) => {

  const classes = useStyles();

  return(
    <Button className={classes.button} variant="contained" onClick={() => props.onClick()}>
      {props.label}
    </Button>
  )
}

export default PrimaryButton
src/components/UIkit/TextField.jsx
import React from 'react';
import TextField from '@material-ui/core/TextField'

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

export default TextInput
src/components/UIkit/index.js
export {default as PrimaryButton} from "./PrimaryButton"
export {default as TextInput} from "./TextInput"

各UIコンポーネントは、親コンポーネントからもらう props に応じて、諸々のパラメーター(ラベルや行数など)を変えられるようにしてあります。

上記のUIコンポーネントを利用して、SignUpテンプレートを作成します。

src/templates/SignUp.jsx
import React, {useState,useCallback} from "react";
import {PrimaryButton,TextInput} from "../components/UIkit"

const SignUp = () => {

  const [username,setUsername] = useState()
  const [email,setEmail] = useState()
  const [password,setPassword] = useState()
  const [confirmPassword,setConfirmPassword] = useState()

    const inputUsername = useCallback((event) => {
      setUsername(event.target.value)
    },[setUsername])

    const inputEmail = useCallback((event) => {
      setEmail(event.target.value)
    },[setEmail])

    const inputPassword = useCallback((event) => {
      setPassword(event.target.value)
    },[setPassword])

    const inputConfirmPassword = useCallback((event) => {
      setConfirmPassword(event.target.value)
    },[setConfirmPassword])

  return(
    <div className="c-section-container">
      <h2 className="u-text__headline u-text-center">アカウント登録</h2>
      <div className="module-spacer--medium" />

      <TextInput
        fullWidth={true} label={"ユーザー名"} multiline={false}
        required={true} rows={1} value={username} type={"text"} onChange={inputUsername}
      />

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

      <TextInput
        fullWidth={true} label={"パスワード"} multiline={false}
        required={true} rows={1} value={password} type={"password"} onChange={inputPassword}
      />

      <TextInput
        fullWidth={true} label={"パスワード(確認用)"} multiline={false}
        required={true} rows={1} value={confirmPassword} type={"password"} onChange={inputConfirmPassword}
      />

      <div className="module-spacer--medium" />

      <div className="center">
        <PrimaryButton
          label={"アカウントを登録する"}
          onClick={() => console.log("Clicked!")}
        />
      </div>
    </div>
  )
}

export default SignUp
  • テキスト入力フィールドを扱うときは、各入力値を受ける state をuseState ()で定義し、useCallback()で永続化する(実践編#12のおさらい)
  • 「アカウントを登録する」ボタンの onClick イベントには、本来はアカウント登録処理を行うイベントを埋め込むが、今はダミーでconsole.log("Clicked!")を設置

作成した SignUp に対応するルーティング(/signup)を定義します。

src/Router.jsx
import React from 'react';
import {Route, Switch} from "react-router";
import {Home,Login,SignUp} from "./templates";

const Router = () => {
  return (
    <Switch>
      <Route exact path={"/signup"} component={SignUp} />
      <Route exact path={"/login"} component={Login} />
      <Route exact path={"(/)?"} component={Home} />
    </Switch>
  );
};

export default Router

これで、SignUp画面で一通りできたはずです。ブラウザで確認してみると、
image.png

いい感じにできています!アカウント登録ボタンを押してみると、
image.png

console.log("Clicked!")が発火しています!

アカウント登録機能を作る

今回講座の肝です。先ほどのアカウント登録ボタンの中身を作ります。

一般に、reducksパターンにおいては state に関わる処理を定義する場合、以下の4ファイルを新規作成or修正します

1. コンポーネントファイル
2. operations.js
3. actions.js
4. reducers.js

1. コンポーネントファイルは、ここではまさに先ほど作成したSignUp.jsxです。

この中に、実装したい処理の発火点を作ります。今回のケースでは、 アカウント登録処理を行う関数である signUp() を、「アカウントを登録する」ボタンの onClickイベントに埋め込む ということになります。

2. operations.jsで、実際に実行したい処理を記述します。今回のケースでは引数のバリデーションを確認した上で、DB(Cloud firestore)にユーザー情報を登録する関数であるsignUp()を定義するということになります。

3. actions.js, 4. reducers.jsで、2. operations.jsで処理をされた値を元に Store内の state の変更を行います。

しかし今回のケースでは、「signUp()関数は user state に関わる処理のため reducks/users/operations.js に記述をするが、アカウント登録に使用したユーザー情報は画面描画には使用せず、登録後はすぐにルートにリダイレクトさせるため state を更新する必要はない」という状況のため、3. actions.js, 4. reducers.jsについてはノータッチになります(こういうケースは珍しい方かと思います)

後に作るサインイン機能は、実際に state の更新まで行うので、上記流れで実装をしますので、そのときに改めて解説します。

まず、1. コンポーネントファイルに関数を設置しましょう。

src/templates/SignUp.jsx
.
.
.
import {signUp} from "../reducks/users/operations"
import {useDispatch} from "react-redux"

const SignUp = () => {
  const dispatch = useDispatch()
  .
  .
  .

  return(
    <div className="c-section-container">
      .
      .
      .
      <div className="center">
        <PrimaryButton
          label={"アカウントを登録する"}
          onClick={() => dispatch(signUp(username,email,password,confirmPassword))}
        />
      </div>
    </div>
  )
}

アカウント登録を行う関数として定義されるsignUp()に対して、必要な引数を渡してonClickイベントに設置します。

このsignUp()はこれから作成するもので、これはoperations.jsに記述します。

operations.jsに定義した関数をコンポーネントで利用するためには、Hooksの一種であるuseDispatch()を使う必要があります。

次に、operations.jssignUp()を定義します。

src/reducks/users/operations.js
.
.
.
import {push} from "connected-react-router";
import {auth, db, FirebaseTimestamp} from "../../firebase/index"
.
.
.
export const signUp = (username,email,password,confiramPassword) => {
  return async (dispatch) => {

    if (username === "" || email === "" || password === "" || confiramPassword === "") {
      alert("必須項目が未入力です")
      return false
    }

    if (password !== confiramPassword) {
      alert("パスワードが一致しません。もう一度お試しください")
      return false
    }

    return auth.createUserWithEmailAndPassword(email,password)
      .then(result => {
        const user = result.user

        if (user) {
          const uid = user.uid
          const timestamp = FirebaseTimestamp.now()

          const userInitialData = {
            created_at: timestamp,
            email: email,
            role: "customer",
            uid: uid,
            updated_at: timestamp,
            username: username
          }

          db.collection("users").doc(uid).set(userInitialData)
          .then(()=>{
            dispatch(push("/"))
          })
        }
      })
  }
}
  • src/firebase/index.jsで定数化したauth,db,FirebaseTimestampをインポート
  • 引数に対するバリデーションを実施。空欄のものがあるときか、パスワードが不一致のときはfalseを返して処理を終了させる
  • asyncを入れて非同期処理を制御(DB通信時のお約束)
  • auth.createUserWithEmailAndPassword()で、メール/パスワード認証によるfirebase側のとの通信を簡単に実装できる
  • uidunique idの略。auth.createUserWithEmailAndPassword()を実行した時点で自動的に生成される(resultの中に含まれる)
  • usersコレクションのうち、上記のuidのところへ、各引数を保存する。保存が完了したら、ルートへリダイレクトする

これでsignUp()関数の設定は完了しました。しかし、もう一つ追加でやることがあります。

現時点では、Cloud Firestore へのデータの書き込みが禁止された設定になっているため、それをいったん解除する必要があります。

データの書き込み・読み込み権限はfirestore.rulesで定義します。

./firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read: if request.auth.uid != null;
      allow create;
      allow update: if request.auth.uid == userId;
      allow delete: if request.auth.uid == userId;
    }
  }
}

usersコレクションに対して、createはいつでも可能、read、update, deleteはユーザー認証状態でのみ可能、と言う設定になっています。

これを有効化するため、firestore.rulesのみをいったんfirebase側にデプロイする必要があります。

terminal
$ firebase deploy --only firestore:rules

ここまでやれば準備完了です!ブラウザから実際にアカウント登録を実行してみます。
image.png

アカウント登録ボタンを押すと、ルートへリダイレクトされます。firebaseコンソールから、実際にアカウントが作成されたかを確認します。

Authentication

image.png

Database

image.png

アカウント登録が完了していますね!

Sign in 画面を作る

Sign Up画面をベースに、Sign in画面も作っていきます。修正するファイルは以下の通り。

繰り返しになりますが、reducksパターンにおいては、state に関わる処理を定義する場合、以下の4ファイルを新規作成or修正します。

1. コンポーネントファイル
2. operations.js
3. actions.js
4. reducers.js

1.コンポーネントファイルとして、SignIn.jsxを新たに作成します。この中に、2. operations.jsで定義するsignIn()関数を設置したボタンを配置します。

2. operations.jsで、signIn()関数を追記します。この関数は、引数として渡されたユーザー情報を元にDB(Cloud firebase)と通信をしてユーザー情報を取得し、それをactionsjsへ渡すという役割を担います。

3.actions.jsおよび4. reducers.jsで、2. operations.jsでDBから取ってきたユーザー情報を元に Store 内の state を更新する記述を行います。

今回は、すでにサインイン処理についてある程度記述がされています。3.actions.jsについてのみ、一部修正をします。

以上の流れで実装を行います。実際に新規作成or修正するファイルをまとめると、

1. src/templates/SignIn.jsx
2. src/reducks/users/operations.js
3. src/reducks/users/actions.js
4. src/reducks/store/initialState.js // actions.jsの修正に応じて一部修正
5. src/templates/index.js // エントリーポイント。SignIn.jsxを新規作成したため追記が必要。
6. src/Router.jsx // SignIn画面のルーティング。SignIn.jsxを新規作成したため追記が必要。
src/templates/SignIn.jsx
import React, {useState,useCallback} from "react";
import {PrimaryButton,TextInput} from "../components/UIkit"
import {signIn} from "../reducks/users/operations"
import {useDispatch} from "react-redux"

const SignIn = () => {
  const dispatch = useDispatch()

  const [email,setEmail] = useState()
  const [password,setPassword] = useState()

    const inputEmail = useCallback((event) => {
      setEmail(event.target.value)
    },[setEmail])

    const inputPassword = useCallback((event) => {
      setPassword(event.target.value)
    },[setPassword])

  return(
    <div className="c-section-container">
      <h2 className="u-text__headline u-text-center">サインイン</h2>
      <div className="module-spacer--medium" />

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

      <TextInput
        fullWidth={true} label={"パスワード"} multiline={false}
        required={true} rows={1} value={password} type={"password"} onChange={inputPassword}
      />

      <div className="module-spacer--medium" />

      <div className="center">
        <PrimaryButton
          label={"サインイン"}
          onClick={() => dispatch(signIn(email,password))}
        />
      </div>
    </div>
  )
}

export default SignIn
  • サインインに必要な情報はemailpasswordのみなので、usernameconfirmPasswordの入力フォーム(及びそれらを管理するためのstate)を削除
  • サインインボタンを押すことで、operations.jsで定義するsignIn()関数が発火するように記述
src/reducks/users/operations.js
import { signInAction } from "./actions";
import {push} from "connected-react-router";
import {auth, db, FirebaseTimestamp} from "../../firebase/index"

export const signIn = (email,password) => {
  return async (dispatch) => {

    if (email === "" || password === "" ) {
      alert("必須項目が未入力です")
      return false
    }

    auth.signInWithEmailAndPassword(email,password)
      .then(result => {
        const user = result.user

        if (user) {
          const uid = user.uid

          db.collection("users").doc(uid).get()
            .then(snapshot => {
              const data = snapshot.data()

              dispatch(signInAction({
                isSignedIn: true,
                role: data.role,
                uid: uid,
                username: data.username
              }))

              dispatch(push("/"))
            })
        }
      })
  }
}

.
.
.
  • auth.signInWithEmailAndPassword()でサインイン認証が行える。
  • 上記メソッドの返り値をもとに、DBから具体的なユーザー情報を取り出す。
  • ユーザー情報をsignInActionに渡すことで、stateの更新を行う。
src/reducks/users/actions.js
export const SIGN_IN = "SIGN_IN";
export const signInAction = (userState) => {
  return {
    type: "SIGN_IN",
    payload: {
      isSignedIn: true,
      role: userState.role,
      uid: userState.uid,
      username: userState.username
    }
  }
};

.
.
.
  • operations.jsより引数として渡されたユーザー情報を受け取るよう記述。
  • roleカラムを追加している。
src/reducks/users/reducers.js(変更箇所なし)
import * as Actions from './actions'
import initialState from '../store/initialState'

export const UsersReducer = (state = initialState.users, action) => {
  switch (action.type) {
    case Actions.SIGN_IN:
      return {
        ...state,
        ...action.payload
      };
    default:
      return state
  }
}

参考として掲載。先のアクションはcase Actions.SIGN_IN:が対応しています。

src/reducks/store/store.js(変更箇所なし)
.
.
.
export default function createStore(history) {
  return reduxCreateStore(
    combineReducers({
      router: connectRouter(history),
      users: UsersReducer
    }),
.
.
.

参考として掲載。UsersReducerからStoreに指令が来ることで、サインイン時のユーザー情報をもとにstateが更新されます。

src/reducks/store/initialState.js
const initialState = {
  users: {
    isSignedIn: false,
    role:  "",
    uid: "",
    username: ""
  }
};

export default initialState

  • roleカラムを追加している。
src/templates/index.js
export {default as Home} from './Home'
export {default as SignIn} from './SignIn'
export {default as SignUp} from './SignUp'
  • Loginを削除し、SignInを追加。
src/Router.jsx
import React from 'react';
import {Route, Switch} from "react-router";
import {Home,SignIn,SignUp} from "./templates";

const Router = () => {
  return (
    <Switch>
      <Route exact path={"/signup"} component={SignUp} />
      <Route exact path={"/signin"} component={SignIn} />
      <Route exact path={"(/)?"} component={Home} />
    </Switch>
  );
};

export default Router
  • SignInテンプレートに対するルーティング(/signin)を追加。

これにより、サインイン画面が完成したはずです。ブラウザで、まずルート(/)を見てみます。

localhost:3000/

image.png

initialStateで定義した通り、ユーザーID、ユーザー名がブランクの状態です。

サインイン画面(/signin)より、先ほどアカウント登録したユーザー情報を用いてサインインを行います。

localhost:3000/signin

image.png

サインインボタンを押すと、ルートへリダイレクトされます。

image.png

先ほど登録したアカウントのユーザーID,ユーザー名が表示されていればOKです!

おわり

再度要点をまとめると、

  1. 店舗:ユーザー = 1:n のECアプリを開発する
  2. firebase.authによる新規登録、サインイン認証を実装する
  3. reducksパターンに沿った関数の実装手順を理解する

以上です!次回は認証のリッスンによる state の永続化を行います。

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

NextAuthで認証してGithubのアクセストークンを取得でのハマりポイント

Next.jsで認証するのに何を使おうか・・

せっかくNext.js使ってるしNextAuthを使ってみよう!ということで、軽い気持ちで導入したらドハマリしました。

今回GitHubのアクセストークンを取得する認証の仕組みを作りたいなと思いNextAuthを使いました。

GitHubで認証してアクセストークンの取得でハマったポイントを書きます。

Next.jsでAPIを定義

NextAuthではAPIを使うのでまずはNext.jsのAPI定義方法をさらっと。

Next.jsではpages/api配下にファイルを配置することで、APIのエンドポイントを定義することができます。

https://nextjs.org/docs/api-routes/introduction

pages/api/user.js
export default (req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'application/json')
  res.end(JSON.stringify({ name: 'John Doe' }))
}

NextAuthでAPIを実装

NextAuthではNext.jsのAPIルーティングの機能を利用してpages/api/[...nextauth].jsを作ることによって認証に必要なAPIを定義することができます。

https://next-auth.js.org/getting-started/example

pages/api/[...nextauth].js
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'

const options = {
  site: process.env.SITE || 'http://localhost:3000',

  // Configure one or more authentication providers
  providers: [
    Providers.GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET
    }),
  ],

  // A database is optional, but required to persist accounts in a database
  database: process.env.DATABASE_URL,
}

export default (req, res) => NextAuth(req, res, options)

NextAuthのセッション管理の種類

NextAuthではセッション管理に2パターンあります。

  • DB
  • JWTトークン

オプションのdatabaseはDBを使う場合に指定が必要ですが、今回はJWTでのトークン発行を使いたいのでoptionsから消してしまって問題ありません。

もしdatabaseの指定がない場合はオプションのsession.jwtが自動的にtrueになり、JWTトークンによるセッション管理になります。

公式のオプションにあるsessionの項目にも記載があります。

// Use JSON Web Tokens for session instead of database sessions.
// This option can be used with or without a database for users/accounts.
// Note: `jwt` is automatically set to `true` if no database is specified.

セッションの取得

NextAuthでセッションを取得するにはuseSession()やgetSession()を使います。

https://next-auth.js.org/getting-started/client#usesession

そしてドキュメントを見るとデータは下記のような感じで返ってくるんだな、と理解したのですがここがひとつハマりポイントでした。

{
  user: {
    name: string,
    email: string,
    image: uri
  },
  accessToken: string,
  expires: "YYYY-MM-DDTHH:mm:ss.SSSZ"
}

実際にuseSessionを使ってみると返ってくるデータが下記。

{
  user: {
    name: string,
    email: string,
    image: uri
  },
  expires: "YYYY-MM-DDTHH:mm:ss.SSSZ"
}

一番欲しいはずのaccessTokenがない・・。

callbacksを使ってaccessTokenを追加

session.jwtがtrueの場合はaccessTokenが含まれません。
そのため最初に紹介したoptionsで下記のようなcallbacksを追加します。

callbacks: {
  session: async (session, token) => {
    return Promise.resolve({
      ...session,
      accessToken: token.account.accessToken
    })
  }
}

callbacks.sessionはセッション情報を取得しようとしたときに呼ばれ、返すデータを変更することできます。

https://next-auth.js.org/configuration/callbacks

デフォルトでは引数のsessionをそのまま返すため、tokenに含まれるaccessTokenをデータへ含めるようにします。

引数のsessionとtokenはそれぞれの下記のようデータです。

■session

interface Session {
  user: {
    name: string
    email: string | null
    image: string
  }
  expires: string
}

■token

interface Token {
  user: {
    name: string
    email: string | null
    image: string
  }
  account: {
    provider: 'github'
    type: string
    id: number
    refreshToken?: string
    accessToken: string
    accessTokenExpires: string
  }
  iat: number
  expt: number
}

GitHubのscopeを設定

GitHubで欲しい情報に制限がかかっている場合はscopeを事前に設定した上でアクセストークンを取得する必要があります。

NextAuthでGitHub認証のscopeを設定するにはProviders.GitHubにscopeを追加します。

providers: [
  Providers.GitHub({
    clientId: process.env.GITHUB_ID,
    clientSecret: process.env.GITHUB_SECRET,
    scope: 'repo read:org'
  })
],

scopeの指定は文字列で複数指定する場合にはスペースをいれて並べることで指定可能です。

scopeの指定方法に注意

NextAuthのドキュメントでscopeの指定方法を見つけることができず、scopeの設定に苦戦してしまいました・・。

scopeは文字列で指定するのが正解ですが、配列で指定しても動いているような挙動になっていたんですよね。

scope: ['repo', 'read:org']

こんな感じです。
この指定方法では適切にscopeが設定されたトークンを取得できないので注意が必要です。

まとめ

わかってしまえばNextAuthはとても便利!という印象を受けましたが、ドキュメントのコードサンプルがもうちょっとあると嬉しいです。

(ちゃんとドキュメントを読んで、コードを読めばいいんですけど・・)

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

NextAuthで認証してGitHubのアクセストークンを取得でのハマりポイント

Next.jsで認証するのに何を使おうか・・

せっかくNext.js使ってるしNextAuthを使ってみよう!ということで、軽い気持ちで導入したらドハマリしました。

今回GitHubのアクセストークンを取得する認証の仕組みを作りたいなと思いNextAuthを使いました。

GitHubで認証してアクセストークンの取得でハマったポイントを書きます。

Next.jsでAPIを定義

NextAuthではAPIを使うのでまずはNext.jsのAPI定義方法をさらっと。

Next.jsではpages/api配下にファイルを配置することで、APIのエンドポイントを定義することができます。

https://nextjs.org/docs/api-routes/introduction

pages/api/user.js
export default (req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'application/json')
  res.end(JSON.stringify({ name: 'John Doe' }))
}

NextAuthでAPIを実装

NextAuthではNext.jsのAPIルーティングの機能を利用してpages/api/[...nextauth].jsを作ることによって認証に必要なAPIを定義することができます。

https://next-auth.js.org/getting-started/example

pages/api/[...nextauth].js
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'

const options = {
  site: process.env.SITE || 'http://localhost:3000',

  // Configure one or more authentication providers
  providers: [
    Providers.GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET
    }),
  ],

  // A database is optional, but required to persist accounts in a database
  database: process.env.DATABASE_URL,
}

export default (req, res) => NextAuth(req, res, options)

NextAuthのセッション管理の種類

NextAuthではセッション管理に2パターンあります。

  • DB
  • JWTトークン

オプションのdatabaseはDBを使う場合に指定が必要ですが、今回はJWTでのトークン発行を使いたいのでoptionsから消してしまって問題ありません。

もしdatabaseの指定がない場合はオプションのsession.jwtが自動的にtrueになり、JWTトークンによるセッション管理になります。

公式のオプションにあるsessionの項目にも記載があります。

// Use JSON Web Tokens for session instead of database sessions.
// This option can be used with or without a database for users/accounts.
// Note: `jwt` is automatically set to `true` if no database is specified.

セッションの取得

NextAuthでセッションを取得するにはuseSession()やgetSession()を使います。

https://next-auth.js.org/getting-started/client#usesession

そしてドキュメントを見るとデータは下記のような感じで返ってくるんだな、と理解したのですがここがひとつハマりポイントでした。

{
  user: {
    name: string,
    email: string,
    image: uri
  },
  accessToken: string,
  expires: "YYYY-MM-DDTHH:mm:ss.SSSZ"
}

実際にuseSessionを使ってみると返ってくるデータが下記。

{
  user: {
    name: string,
    email: string,
    image: uri
  },
  expires: "YYYY-MM-DDTHH:mm:ss.SSSZ"
}

一番欲しいはずのaccessTokenがない・・。

callbacksを使ってaccessTokenを追加

session.jwtがtrueの場合はaccessTokenが含まれません。
そのため最初に紹介したoptionsで下記のようなcallbacksを追加します。


コメント頂いたので追記
GitHubのトークンをaccessTokenというキーで返却してしまっていますが、セッション管理のトークンとしての役割ではありません。(キー名がわかりにくい・・)


callbacks: {
  session: async (session, token) => {
    return Promise.resolve({
      ...session,
      accessToken: token.account.accessToken
    })
  }
}

callbacks.sessionはセッション情報を取得しようとしたときに呼ばれ、返すデータを変更することできます。

https://next-auth.js.org/configuration/callbacks

デフォルトでは引数のsessionをそのまま返すため、tokenに含まれるaccessTokenをデータへ含めるようにします。
(上記追記の通り)

引数のsessionとtokenはそれぞれの下記のようデータです。

■session

interface Session {
  user: {
    name: string
    email: string | null
    image: string
  }
  expires: string
}

■token

interface Token {
  user: {
    name: string
    email: string | null
    image: string
  }
  account: {
    provider: 'github'
    type: string
    id: number
    refreshToken?: string
    accessToken: string
    accessTokenExpires: string
  }
  iat: number
  expt: number
}

GitHubのscopeを設定

GitHubで欲しい情報に制限がかかっている場合はscopeを事前に設定した上でアクセストークンを取得する必要があります。

NextAuthでGitHub認証のscopeを設定するにはProviders.GitHubにscopeを追加します。

providers: [
  Providers.GitHub({
    clientId: process.env.GITHUB_ID,
    clientSecret: process.env.GITHUB_SECRET,
    scope: 'repo read:org'
  })
],

scopeの指定は文字列で複数指定する場合にはスペースをいれて並べることで指定可能です。

scopeの指定方法に注意

NextAuthのドキュメントでscopeの指定方法を見つけることができず、scopeの設定に苦戦してしまいました・・。

scopeは文字列で指定するのが正解ですが、配列で指定しても動いているような挙動になっていたんですよね。

scope: ['repo', 'read:org']

こんな感じです。
この指定方法では適切にscopeが設定されたトークンを取得できないので注意が必要です。

まとめ

わかってしまえばNextAuthはとても便利!という印象を受けましたが、ドキュメントのコードサンプルがもうちょっとあると嬉しいです。

(ちゃんとドキュメントを読んで、コードを読めばいいんですけど・・)

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

Jupyter・RStudioみたいにパッと画像を表示する機能を作る【Docker】

概要

普段neovimでPythonやRのスクリプトを書くのですが、グラフをpngやhtmlで出力した後にいちいちファイルを開いて確認するのが面倒でした。新しいファイルが自動でパっと表示される機能がほしいなと思いdockerで作ってみました。

Seo5IDfC4BCZXHQDD0QL1594225984-1594226031.gif

初めに断っておきますが、大したものは作っていません。「ふーん、そんなこと考える人がいるんだ」くらいのテンションで読んでもらえれば嬉しいです。

使い方

まずは以下のスクリプトをdocker-compose.ymlという名前で保存1。docker-composeは使えるようにしておいてください。

docker-compose.yml
version: "3"
services:
  websocket:
    image: dr666m1/image_watcher_websocket:version-0.0
    volumes:
      - .:/work/sync
    ports:
      - "9999:9999"
  webserver:
    image: dr666m1/image_watcher_webserver:version-0.0
    volumes:
      - .:/work/sync
    ports:
      - "8888:8888"
    depends_on:
      - websocket

その後、pngやhtmlを出力する予定のディレクトリに移動し、以下のコマンドで起動・停止($FILE_PATHは先ほどのdocker-compose.yml)。

# 起動
docker-compose -f $FILE_PATH --project-directory $(pwd) up -d

# 停止
docker-compose -f $FILE_PATH --project-directory $(pwd) down

起動中にブラウザでhttp://localhost:8888/を開くと冒頭に載せたような画面になります。

仕組み

docker-compose.ymlを見ての通り、2つのdockerコンテナが動いています。以下、それぞれの役割を簡単に説明します。コードは私のgithubに載せています。

websocket (image_watcher_websocket)

WebSocketとは何か、という解説は他の記事にお任せします。このdockerコンテナの役割は以下です。

  • png・htmlファイルの作成or更新を数秒置きに検知する
  • 検知したファイルの情報をブラウザに送信する

実装にはPythonのwebsocket-serverというパッケージを利用しました。

webserver (image_watcher_webserver)

Webサーバーとは何か、という解説も他の記事にお任せします。このdockerコンテナの役割は以下です。

  • ローカルの8888番ポートでリクエストを受け付けindex.htmlを返す
  • png・htmlファイルもリクエストがあれば返す

実装にはPythonのFlaskというパッケージを利用しました。index.htmlの中ではReactを多用しています。

こだわり

表示・非表示の切り替え

ファイル名の左の「▶」「▼」で、表示・非表示の切り替えができます。

別画面での表示

ファイル名をクリックすると、そのファイルだけ別画面で表示できます。

その他

htmlファイルのscrollHeightに合わせてiframeの縦幅を自動修正したりとか、トップに戻るボタンの実装とかもちょっとしたこだわりです。

最後に

業務では分析用のPython・R・SQLくらいしか書かないので、Reactで画面を作る作業は勉強になりました。


  1. 見たことないdockerイメージが指定されていると思いますが、私が作成したものです。DockerHubで公開しているので普通にdocker pullできるはずです。 

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

なぜ import React from 'react' するのか

なぜReactをimportする必要が?

reactのアプリケーションを書いていると、何も考えずにimport Reactしませんか?
僕は何も考えていません。importし忘れたらエラーが出るのでimportしてました。

import React from 'react';
import SomeButton from '~/components/SomeButton';

function RenderButton() {
 return <SomeButton color="blue" />
}

上記のコードを見ていただければ分かる通り、Reactなんてどこにも使ってないですよね。
では何故importする必要があるのか。

JSXはReactのシンタックスシュガー

これが答えでした。

<SomeButton color="blue" />

React.createElement(
  SomeButton,
  { color: 'blue' }
)

のシンタックスシュガーです。
そのため、JSX記法を使う際にはスコープ内にReactが存在する必要があります。

import React from 'react';
import SomeButton from '~/components/someButton';

function RenderButton() {
 return <SomeButton />
 // return React.createElement(SomeButton, { color: 'blue' }) 
}

ちなみにwebpackなどでバンドルせず、scriptタグでReactを読み込んでいる場合はグローバルスコープにReactがあるのでimportなどは不要です。

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

【風のタクト】未完の名作となった海戦ゲームをTypescriptで今風に作ってみた

前説

先日、人生初の作品を、人生初のRustプログラミングで再現してみたという記事を読み、自分自身の人生初の作品は何だったのだろうかと思い返した。
入社してから手掛けたものは、いずれも既存のプログラムだし(何より公開できない)、敢えて挙げるなら入社1年目の忘年会で余興のために作ったビンゴマシン1だろうか・・・

いや、それよりも前・・・学生時代に作ったものがあった。
正直なところ、極めて不真面目な学生だった私は、3年次に情報系のゼミに配属されるまでプログラミングというものに触れたことがなかった2
それ故に担当の教授はさぞ頭を抱えたことだろう。なにせ覚えたてのSQLを嬉々として本番環境で試し、危うくマスタデータを消し飛ばすような問題児3だ。

「何でもいいから言語をひとつ覚えて作品を作ってみなさい」

そんなミッションを課され、とりあえずカッコいいWebページを作ろうと思い立った私4HTMLCSSに手を出し、そして最後にJavascriptを覚えた5

そしてようやく作りたいものが浮かんだ。
「ゼルダの伝説 風のタクト6」の海戦ゲームである。
kaisen.jpg

割と有名なゲームなので知っている方もいるかもしれないが、初見の方のためにルールを説明しておくと・・・

  • 8×8マスの中に3隻の船7が隠れている
  • 3隻の船はそれぞれ3マス、4マス、5マスの長さで、縦もしくは横向きである(=斜めはない)
  • プレイヤーは大砲を撃ち、対象の完全撃沈を目指す
  • 大砲は最大24発まで発射可能で、24発以内に完全撃沈すれば勝利

とまあこんな感じのゲームである。
そこで分からないなりに頭を働かせ、上記の言語を駆使してなんとか完成はさせたのだが、このゲームは初級者にはちょっと厳しめのポイントがいくつかある。
ぱっと挙がるだけでも下記の通りである。

  • ランダムに生成される3隻の船は盤面内に必ず収まっていなければならない
  • 船同士の座標が被ってはならない

このあたりは勘所を押さえていないとドツボにハマりがちで、ご多分に漏れず私もハマった。
「盤外の新世界へとオンザクルーズする船」「コバンザメの如く他船に重なる不届き者」が発生し、「世はまさに大海賊時代」の様相を呈していたが、残念なことに全てのバグを改修しきれずに時間切れ。
そう、未完のまま終わってしまったのである。

そこで今回、無念の思いを抱えて電子の海を彷徨う海賊たちの怨念、もといバグ供養の意味も込めて、私が普段愛用しているTypescriptReactを使って、ついでに最近かじったNeumorphism(ニューモフィズム)とかいうハイカラなデザインもゴリゴリに混ぜ込んでみる。

完成品

先に動くものを見た方が分かりがいいと思うので、画面キャプチャを載せる。
完成品はGitHub Pagesのこちらのページで公開中(PC推奨。スマホも対応予定)。
スクリーンショット 2020-07-08 23.18.27.png

いちおう上記で挙げたバグを回避しつつ、なるべくシンプルな作りに仕上げた。

ソースコード

ソースもGitHubのこちらのリポジトリで公開しているので、コアとなるGameBoard.tsxTargetInfo.tsのみ掲載。

TargetInfo.ts
TargetInfo.ts
/**
 * ターゲットクラス
 */
export default class TargetInfo {
    // 位置情報
    cells : { row : number, col : number }[] = [];
    // 向き(0:up, 1:right, 2:down, 3:left)
    direction : number = -1;
    // 沈没フラグ
    isBroken : boolean = false;
}

/**
 * 指定したマス目内に存在するTargetInfoを生成する
 * @param lines 行数
 */
export const GenerateTargets = (lines : number) : TargetInfo[] => {
    if(lines < 0) return [];
    let result : TargetInfo[] = [];

    // 長さが3,4,5のTargetInfoを生成する
    result.push(GenerateTarget(5,lines, result));
    result.push(GenerateTarget(4,lines, result));
    result.push(GenerateTarget(3,lines, result));

    return result;
}

/**
 * TargetInfoを生成
 * @param length 長さ
 * @param lines 行数
 * @param currentTargets 既に生成されているTargetInfoリスト
 */
export const GenerateTarget = (length : number, lines : number, currentTargets? : TargetInfo[]) : TargetInfo => {
    let result : TargetInfo = new TargetInfo();
    let created : boolean = false;
    currentTargets = currentTargets || [];

    while(!created){
        // ランダムに座標と向きを生成する
        const row : number = Math.floor(Math.random()*(lines));
        const col : number = Math.floor(Math.random()*(lines));
        const direction : number = Math.floor(Math.random()*(4));
        result.direction = direction;

        // 先に設定されたTargetInfoの座標と被っているかチェック
        if(currentTargets.length > 0){
            let duplicate : boolean = false;
            switch(direction){
                // up
                case 0:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.col === col && c.row >= row - length)
                    });
                    break;
                // right
                case 1:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.row === row && c.col <= col + length)
                    });
                    break;
                // down
                case 2:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.col === col && c.row <= row + length)
                    });
                    break;
                // left
                case 3:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.row === row && c.col >= col - length)
                    });
                    break;
            }

            if(duplicate) continue;
        }

        // 生成された座標と向きがマス内に完全に収まるかチェック
        switch(direction){
            // up
            case 0:
                if(length <= row+1){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ row : row-i, col })
                    }
                } 
                break;
            // right
            case 1:
                if(col + length <= lines){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ col : col+i, row })
                    }
                } 
                break;
            // down
            case 2: 
                if(row + length <= lines){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ row : row+i, col })
                    }
                } 
                break;
            // left
            case 3:
                if(length <= col+1){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ col : col-i, row })
                    }
                } 
                break;
        }
    }

    return result;
}

GameBoard.tsx
GameBoard.tsx
import React from 'react';
import './GameBoard.css';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShip, faTimes, faCertificate, faCrosshairs, faWater } from "@fortawesome/free-solid-svg-icons";
import TargetInfo, { GenerateTargets } from '../modules/TargetInfo';

interface Props {
    lines? : number,
}

interface State {
    selectedCell : { col : number, row : number }[],
    targets : TargetInfo[],
    max : number,
}

class GameBoard extends React.PureComponent<Props, State> {

    constructor(props : Props) {
        super(props);

        this.state = {
            selectedCell : [],
            targets : [],
            max : 24,
        }
    }

    componentDidMount = () => {
        this?.reset();
    }

    /**
     * ゲームリセット
     */
    reset = () => {
        this.setState({
            targets : [...GenerateTargets(8)],
            selectedCell : [],
        })
    }

    /**
     * セル押下時処理
     * @param row 
     * @param col 
     */
    onClickCell = (row : number, col : number) => {
        let targets = Array.from(this.state.targets);
        const max = this.state.max;
        const selectedCell = Array.from(this.state.selectedCell);
        const aliveShips : number = targets.filter(t => !t.isBroken).length
        const isFinished : boolean = selectedCell.length === max || aliveShips === 0;

        if(isFinished) {
            alert('もう一度遊ぶには「リセット」を押してね!!');
            return
        };

        // 既に選択済のマスかチェック
        const findIndex : number = selectedCell.findIndex((c) => c.row === row && c.col === col);
        if(findIndex < 0){
            selectedCell.push({row, col});
        } else {
            return;
        }

        // 選択済のマスから大破したターゲットがいるか探索
        targets.forEach((t) => {
            if(t.isBroken) return;
            const allHit : boolean = selectedCell.filter((c) => {
                return !!(t.cells.find((tc) => tc.row === c.row && tc.col === c.col));
            }).length === t.cells.length;
            // 大破の場合はフラグを立てておく
            t.isBroken = allHit;
        })

        this.setState({
            selectedCell,
            targets
        })
    }

    render = () => {

        const { selectedCell, targets, max } = this.state;
        const aliveShips : number = targets.filter(t => !t.isBroken).length
        const isFinished : boolean = selectedCell.length === max || aliveShips === 0;

        let { lines } = this.props;
        lines = lines || 8;

        let rows : JSX.Element[] = [];

        for(let i = 0; i < lines; i++){

            let cells : JSX.Element[] = [];

            for(let j= 0; j < lines; j++){
                const selected : boolean = selectedCell.some((c) => c.row === i && c.col === j);
                const isTarget : boolean = targets.some((t) => {
                    return !!(t.cells.find((c) => c.row === i && c.col === j));
                });

                // デフォルトでは透明アイコンを表示
                let icon : JSX.Element = (
                    <FontAwesomeIcon className='water' icon={faWater} />
                );

                // もし選択されたマスなら
                if(selected){
                    // ターゲットの有無で表示するアイコンを変更
                    icon = isTarget? (
                        <FontAwesomeIcon className='icon certificate' icon={faCertificate} />
                    ) : (
                        <FontAwesomeIcon className='icon times' icon={faTimes} />
                    )
                }

                cells.push(
                    <button className={`cell ${selected? 'selected' : 'unselected'} ${isFinished && isTarget? 'target' : ''}`} 
                            key={`${i.toString()}_${j.toString()}`} 
                            onClick={()=>{this.onClickCell(i, j)}}>
                        {icon}
                    </button>
                )
            }

            rows.push(
                <div className="row" key={`${i.toString()}`}>
                    {cells}
                </div>
            )
        }

        const result : JSX.Element = isFinished? (
            <div className="status inset">
                    {targets.filter(t => !t.isBroken).length === 0? (
                        <p className='result'>完全勝利!</p>
                    ) : (
                        <p className='result'>残念!また遊んでね</p>
                    )}
            </div>
        ) : null;

        return (
            <div>
                <div className="description inset">
                    <p>下記のマスの中に隠れている海賊船を大砲で撃沈しよう!<br/>
                    海賊船は3隻で、船体の長さはそれぞれ3マス、4マス、5マス。<br/>
                    大砲の弾は最大24発まで発射できます。</p>
                </div>
                {result}
                <div className="status inset"> 
                    <div>
                        <FontAwesomeIcon className='icon certificate' icon={faCertificate} />
                        <p>当たり</p>
                    </div>
                    <div>
                        <FontAwesomeIcon className='icon times' icon={faTimes} />
                        <p>外れ</p>
                    </div>
                    <div>
                        <FontAwesomeIcon className='icon ship' icon={faShip} />
                        <p>{`${targets.filter(t => !t.isBroken).length}/${targets.length}`}</p>
                    </div>
                    <div>
                        <FontAwesomeIcon className='icon crosshairs' icon={faCrosshairs} />
                        <p>{`${max - selectedCell.length}/${max}`}</p>
                    </div>
                </div>
                <div>
                    {rows}
                </div>
                <div className="menu">
                    <button className="neumorphic-btn" onClick={this?.reset} >リセット</button>
                </div>
            </div>
        );
    }
}

export default GameBoard;

※駆け足で作ったので、冗長&汚い箇所がありますが、ver1として掲載。

要素技術紹介

Typescript

言わずと知れたAltJSの筆頭株。
混沌としたJavascriptに秩序と安寧をもたらすとか、もたらさないとか。
この規模のアプリならJavascriptでも全然いけるのだが、より大規模なアプリケーション開発になると静的型付け8が欲しくなってくる。

React

今をときめくJavascriptのフレームワーク。
VueやらAngularやらと宗教戦争を繰り広げている。
HTMLJavascriptがフュージョンしたようなJSXや状態管理のフレームワークであるReduxを用いて多くの初学者を苦しめる9

ちなみにTypescript搭載のReactプロジェクトを作るには下記の通りにする。

参考:create-react-appで React + Typescript な環境を構築する

create-react-app hoge --typescript

Neummorphism(ニューモフィズム)

これからのトレンドになるとかならないとか言われているUIデザイン。
初期のiOSでちらほら見受けられたSkeuomorphism(スキューモフィズム)と現在幅を聞かせているマテリアルデザインのいいとこ取りをしたような見た目。
その性質上、影を自在に操る能力者10だけが使いこなせるとされる。

参考:ニューモーフィズム?CSSコピペ実装できる新Webトレンドの参考HTMLスニペット、ツールまとめ

GitHub Pages

静的なWebページなら何のコストもかけずに公開できる。
学生の時分は、手塩にかけて育てたHTMLをローカル端末のブラウザで開いて「自分で作った物が動いた!」と大騒ぎしていたが、今では公開まで手軽にできる。

ちなみにcreate-react-appで作ったアプリを公開する場合は、package.jsonに下記を追加してnpm run buildを実行してpushするだけである(要GitHub側設定)。
※わざわざdocsディレクトリを作っているのは、GitHub Pagesmasterブランチのdocsを対象とする制約があるため。

package.json
{
  "scripts": {
    "build": "react-scripts build && mv build docs"
  },
  "homepage": "https://【GitHubユーザ名】.github.io/【リポジトリ名】"
}
npm run build

まとめ

所要時間だが、それなりに見た目を整えて公開するまでだいたい3,4時間といったところだった。
過去の自分の作品を作り直してみると、自身の成長度合いを知ることができ、非常に有意義な時間だったといえる。

ロジック的には大したものではないが、当時の自分が逆立ちしても解決できなかったようなバグをあっさりと解消できたのはなかなかに痛快だった。

また、デザインもなるべく今の時代に即した形にしているため、何年か経った後に同じ物を時々のデザインで作ると、デザイントレンドの変遷が視覚化できて面白いのではないかと思った。


  1. これがまた見返してみると酷いソースだった。忘年会後に「稀に-1番が抽選される」というビンゴの常識を覆すバグを含んでいたことが発覚したのは記憶に新しい。 

  2. 厳密には「プログラミング基礎」という講義を受講していた。最初こそ簡単だと思っていたが、「オブジェクト思考」云々のくだりで考えることを止めた。「車クラスを継承した救急車クラス?走れればなんでもええやん」という安易な思考停止は、数年越しに自分を苦しめることとなる。 

  3. 教本片手にUPDATE文を実行すると、目の前が真っ白になった。結果的に全データの削除フラグを立ててしまっただけで事なきを得たのだが、周りの私を見る目も真っ白になった。 

  4. 思い立ったというか、この時はWeb以外のクラサバやスマホアプリ等々のシステムを知らなかった。世の中知らない事だらけである。 

  5. 独特のグラフィックと広大なフィールド、さらにニテン堂等の奥深いやり込み要素に当時少年だった私は度肝を抜かれた。そして予約特典の「時のオカリナ裏」のあまりの難易度に更に度肝を抜かれた。 

  6. 今にして思うと、ニュアンスから察するに教授が課した「言語」とはJavaやらC#やらのことだったと思うのだが、きっと違ったのだろう。 

  7. 正確には「敵艦隊」。GC版では「敵艦隊」だが、リマスターされたWii U版では「巨大イカ」に変更されている。背景には倫理的な理由があったのかもしれないが、イカ相手に大砲をぶっ放すのは動物愛護的にはどうなのか。 

  8. Javascriptで何かしらを作った事がある人は「numberだと思って扱っていた変数がいつの間にかstringになっていた」という経験をしたことがあると思う。酷い時にはundefinedになっていて、原因もとい犯人探しに奔走することも数知れず。そんな理不尽な出来事も無問題。そうTypescriptならね。 

  9. 一度覚えてしまえば何てことはないのだが、覚え始めの頃は古代文明の碑文のように思えた。「StoreStateReducerがいて、ActionDispatchすればいいんだよ」というルー大柴も裸足で逃げ出すような説明をされればそう思うのも当然か。 

  10. 今回の作品開発にあたってbox-shadowをしっかり勉強することになったが、未だにリファレンスを見ながらでないと実装がおぼつかない。これをサラッとできるってんだからデザイナーさんってすげえや。 

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

【風のタクト】海戦ゲームをTypescriptで今風に作ってみた

前説

先日、人生初の作品を、人生初のRustプログラミングで再現してみたという記事を読み、自分自身の人生初の作品は何だったのだろうかと思い返した。
入社してから手掛けたものは、いずれも既存のプログラムだし(何より公開できない)、敢えて挙げるなら入社1年目の忘年会で余興のために作ったビンゴマシン1だろうか・・・

いや、それよりも前・・・学生時代に作ったものがあった。
正直なところ、極めて不真面目な学生だった私は、3年次に情報系のゼミに配属されるまでプログラミングというものに触れたことがなかった2
それ故に担当の教授はさぞ頭を抱えたことだろう。なにせ覚えたてのSQLを嬉々として本番環境で試し、危うくマスタデータを消し飛ばすような問題児3だ。

「何でもいいから言語をひとつ覚えて作品を作ってみなさい」

そんなミッションを課され、とりあえずカッコいいWebページを作ろうと思い立った私4HTMLCSSに手を出し、そして最後にJavascriptを覚えた5

そしてようやく作りたいものが浮かんだ。
「ゼルダの伝説 風のタクト6」の海戦ゲームである。
kaisen.jpg

割と有名なゲームなので知っている方もいるかもしれないが、初見の方のためにルールを説明しておくと・・・

  • 8×8マスの中に3隻の船7が隠れている
  • 3隻の船はそれぞれ3マス、4マス、5マスの長さで、縦もしくは横向きである(=斜めはない)
  • プレイヤーは大砲を撃ち、対象の完全撃沈を目指す
  • 大砲は最大24発まで発射可能で、24発以内に完全撃沈すれば勝利

とまあこんな感じのゲームである。
そこで分からないなりに頭を働かせ、上記の言語を駆使してなんとか完成はさせたのだが、このゲームは初級者にはちょっと厳しめのポイントがいくつかある。
ぱっと挙がるだけでも下記の通りである。

  • ランダムに生成される3隻の船は盤面内に必ず収まっていなければならない
  • 船同士の座標が被ってはならない

このあたりは勘所を押さえていないとドツボにハマりがちで、ご多分に漏れず私もハマった。
「盤外の新世界へとオンザクルーズする船」「コバンザメの如く他船に重なる不届き者」が発生し、「世はまさに大海賊時代」の様相を呈していたが、残念なことに全てのバグを改修しきれずに時間切れ。
そう、未完のまま終わってしまったのである。

そこで今回、無念の思いを抱えて電子の海を彷徨う海賊たちの怨念、もといバグ供養の意味も込めて、私が普段愛用しているTypescriptReactを使って、ついでに最近かじったNeumorphism(ニューモフィズム)とかいうハイカラなデザインもゴリゴリに混ぜ込んでみる。

完成品

先に動くものを見た方が分かりがいいと思うので、画面キャプチャを載せる。
完成品はGitHub Pagesのこちらのページで公開中(PC推奨。スマホも対応予定)。
スクリーンショット 2020-07-08 23.18.27.png

いちおう上記で挙げたバグを回避しつつ、なるべくシンプルな作りに仕上げた。

ソースコード

ソースもGitHubのこちらのリポジトリで公開しているので、コアとなるGameBoard.tsxTargetInfo.tsのみ掲載。

TargetInfo.ts
TargetInfo.ts
/**
 * ターゲットクラス
 */
export default class TargetInfo {
    // 位置情報
    cells : { row : number, col : number }[] = [];
    // 向き(0:up, 1:right, 2:down, 3:left)
    direction : number = -1;
    // 沈没フラグ
    isBroken : boolean = false;
}

/**
 * 指定したマス目内に存在するTargetInfoを生成する
 * @param lines 行数
 */
export const GenerateTargets = (lines : number) : TargetInfo[] => {
    if(lines < 0) return [];
    let result : TargetInfo[] = [];

    // 長さが3,4,5のTargetInfoを生成する
    result.push(GenerateTarget(5,lines, result));
    result.push(GenerateTarget(4,lines, result));
    result.push(GenerateTarget(3,lines, result));

    return result;
}

/**
 * TargetInfoを生成
 * @param length 長さ
 * @param lines 行数
 * @param currentTargets 既に生成されているTargetInfoリスト
 */
export const GenerateTarget = (length : number, lines : number, currentTargets? : TargetInfo[]) : TargetInfo => {
    let result : TargetInfo = new TargetInfo();
    let created : boolean = false;
    currentTargets = currentTargets || [];

    while(!created){
        // ランダムに座標と向きを生成する
        const row : number = Math.floor(Math.random()*(lines));
        const col : number = Math.floor(Math.random()*(lines));
        const direction : number = Math.floor(Math.random()*(4));
        result.direction = direction;

        // 先に設定されたTargetInfoの座標と被っているかチェック
        if(currentTargets.length > 0){
            let duplicate : boolean = false;
            switch(direction){
                // up
                case 0:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.col === col && c.row >= row - length)
                    });
                    break;
                // right
                case 1:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.row === row && c.col <= col + length)
                    });
                    break;
                // down
                case 2:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.col === col && c.row <= row + length)
                    });
                    break;
                // left
                case 3:
                    duplicate = currentTargets.some((t) => {
                        return !!t.cells.find((c) => c.row === row && c.col >= col - length)
                    });
                    break;
            }

            if(duplicate) continue;
        }

        // 生成された座標と向きがマス内に完全に収まるかチェック
        switch(direction){
            // up
            case 0:
                if(length <= row+1){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ row : row-i, col })
                    }
                } 
                break;
            // right
            case 1:
                if(col + length <= lines){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ col : col+i, row })
                    }
                } 
                break;
            // down
            case 2: 
                if(row + length <= lines){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ row : row+i, col })
                    }
                } 
                break;
            // left
            case 3:
                if(length <= col+1){
                    created = true;
                    for(let i = 0; i < length; i++){
                        result.cells.push({ col : col-i, row })
                    }
                } 
                break;
        }
    }

    return result;
}

GameBoard.tsx
GameBoard.tsx
import React from 'react';
import './GameBoard.css';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShip, faTimes, faCertificate, faCrosshairs, faWater } from "@fortawesome/free-solid-svg-icons";
import TargetInfo, { GenerateTargets } from '../modules/TargetInfo';

interface Props {
    lines? : number,
}

interface State {
    selectedCell : { col : number, row : number }[],
    targets : TargetInfo[],
    max : number,
}

class GameBoard extends React.PureComponent<Props, State> {

    constructor(props : Props) {
        super(props);

        this.state = {
            selectedCell : [],
            targets : [],
            max : 24,
        }
    }

    componentDidMount = () => {
        this?.reset();
    }

    /**
     * ゲームリセット
     */
    reset = () => {
        this.setState({
            targets : [...GenerateTargets(8)],
            selectedCell : [],
        })
    }

    /**
     * セル押下時処理
     * @param row 
     * @param col 
     */
    onClickCell = (row : number, col : number) => {
        let targets = Array.from(this.state.targets);
        const max = this.state.max;
        const selectedCell = Array.from(this.state.selectedCell);
        const aliveShips : number = targets.filter(t => !t.isBroken).length
        const isFinished : boolean = selectedCell.length === max || aliveShips === 0;

        if(isFinished) {
            alert('もう一度遊ぶには「リセット」を押してね!!');
            return
        };

        // 既に選択済のマスかチェック
        const findIndex : number = selectedCell.findIndex((c) => c.row === row && c.col === col);
        if(findIndex < 0){
            selectedCell.push({row, col});
        } else {
            return;
        }

        // 選択済のマスから大破したターゲットがいるか探索
        targets.forEach((t) => {
            if(t.isBroken) return;
            const allHit : boolean = selectedCell.filter((c) => {
                return !!(t.cells.find((tc) => tc.row === c.row && tc.col === c.col));
            }).length === t.cells.length;
            // 大破の場合はフラグを立てておく
            t.isBroken = allHit;
        })

        this.setState({
            selectedCell,
            targets
        })
    }

    render = () => {

        const { selectedCell, targets, max } = this.state;
        const aliveShips : number = targets.filter(t => !t.isBroken).length
        const isFinished : boolean = selectedCell.length === max || aliveShips === 0;

        let { lines } = this.props;
        lines = lines || 8;

        let rows : JSX.Element[] = [];

        for(let i = 0; i < lines; i++){

            let cells : JSX.Element[] = [];

            for(let j= 0; j < lines; j++){
                const selected : boolean = selectedCell.some((c) => c.row === i && c.col === j);
                const isTarget : boolean = targets.some((t) => {
                    return !!(t.cells.find((c) => c.row === i && c.col === j));
                });

                // デフォルトでは透明アイコンを表示
                let icon : JSX.Element = (
                    <FontAwesomeIcon className='water' icon={faWater} />
                );

                // もし選択されたマスなら
                if(selected){
                    // ターゲットの有無で表示するアイコンを変更
                    icon = isTarget? (
                        <FontAwesomeIcon className='icon certificate' icon={faCertificate} />
                    ) : (
                        <FontAwesomeIcon className='icon times' icon={faTimes} />
                    )
                }

                cells.push(
                    <button className={`cell ${selected? 'selected' : 'unselected'} ${isFinished && isTarget? 'target' : ''}`} 
                            key={`${i.toString()}_${j.toString()}`} 
                            onClick={()=>{this.onClickCell(i, j)}}>
                        {icon}
                    </button>
                )
            }

            rows.push(
                <div className="row" key={`${i.toString()}`}>
                    {cells}
                </div>
            )
        }

        const result : JSX.Element = isFinished? (
            <div className="status inset">
                    {targets.filter(t => !t.isBroken).length === 0? (
                        <p className='result'>完全勝利!</p>
                    ) : (
                        <p className='result'>残念!また遊んでね</p>
                    )}
            </div>
        ) : null;

        return (
            <div>
                <div className="description inset">
                    <p>下記のマスの中に隠れている海賊船を大砲で撃沈しよう!<br/>
                    海賊船は3隻で、船体の長さはそれぞれ3マス、4マス、5マス。<br/>
                    大砲の弾は最大24発まで発射できます。</p>
                </div>
                {result}
                <div className="status inset"> 
                    <div>
                        <FontAwesomeIcon className='icon certificate' icon={faCertificate} />
                        <p>当たり</p>
                    </div>
                    <div>
                        <FontAwesomeIcon className='icon times' icon={faTimes} />
                        <p>外れ</p>
                    </div>
                    <div>
                        <FontAwesomeIcon className='icon ship' icon={faShip} />
                        <p>{`${targets.filter(t => !t.isBroken).length}/${targets.length}`}</p>
                    </div>
                    <div>
                        <FontAwesomeIcon className='icon crosshairs' icon={faCrosshairs} />
                        <p>{`${max - selectedCell.length}/${max}`}</p>
                    </div>
                </div>
                <div>
                    {rows}
                </div>
                <div className="menu">
                    <button className="neumorphic-btn" onClick={this?.reset} >リセット</button>
                </div>
            </div>
        );
    }
}

export default GameBoard;

※駆け足で作ったので、冗長&汚い箇所がありますが、ver1として掲載。

要素技術紹介

Typescript

言わずと知れたAltJSの筆頭株。
混沌としたJavascriptに秩序と安寧をもたらすとか、もたらさないとか。
この規模のアプリならJavascriptでも全然いけるのだが、より大規模なアプリケーション開発になると静的型付け8が欲しくなってくる。

React

今をときめくJavascriptのフレームワーク。
VueやらAngularやらと宗教戦争を繰り広げている。
HTMLJavascriptがフュージョンしたようなJSXや状態管理のフレームワークであるReduxを用いて多くの初学者を苦しめる9

ちなみにTypescript搭載のReactプロジェクトを作るには下記の通りにする。

参考:create-react-appで React + Typescript な環境を構築する

create-react-app hoge --typescript

Neummorphism(ニューモフィズム)

これからのトレンドになるとかならないとか言われているUIデザイン。
初期のiOSでちらほら見受けられたSkeuomorphism(スキューモフィズム)と現在幅を聞かせているマテリアルデザインのいいとこ取りをしたような見た目。
その性質上、影を自在に操る能力者10だけが使いこなせるとされる。

参考:ニューモーフィズム?CSSコピペ実装できる新Webトレンドの参考HTMLスニペット、ツールまとめ

GitHub Pages

静的なWebページなら何のコストもかけずに公開できる。
学生の時分は、手塩にかけて育てたHTMLをローカル端末のブラウザで開いて「自分で作った物が動いた!」と大騒ぎしていたが、今では公開まで手軽にできる。

ちなみにcreate-react-appで作ったアプリを公開する場合は、package.jsonに下記を追加してnpm run buildを実行してpushするだけである(要GitHub側設定)。
※わざわざdocsディレクトリを作っているのは、GitHub Pagesmasterブランチのdocsを対象とする制約があるため。

package.json
{
  "scripts": {
    "build": "react-scripts build && mv build docs"
  },
  "homepage": "https://【GitHubユーザ名】.github.io/【リポジトリ名】"
}
npm run build

まとめ

所要時間だが、それなりに見た目を整えて公開するまでだいたい3,4時間といったところだった。
過去の自分の作品を作り直してみると、自身の成長度合いを知ることができ、非常に有意義な時間だったといえる。

ロジック的には大したものではないが、当時の自分が逆立ちしても解決できなかったようなバグをあっさりと解消できたのはなかなかに痛快だった。

また、デザインもなるべく今の時代に即した形にしているため、何年か経った後に同じ物を時々のデザインで作ると、デザイントレンドの変遷が視覚化できて面白いのではないかと思った。


  1. これがまた見返してみると酷いソースだった。忘年会後に「稀に-1番が抽選される」というビンゴの常識を覆すバグを含んでいたことが発覚したのは記憶に新しい。 

  2. 厳密には「プログラミング基礎」という講義を受講していた。最初こそ簡単だと思っていたが、「オブジェクト思考」云々のくだりで考えることを止めた。「車クラスを継承した救急車クラス?走れればなんでもええやん」という安易な思考停止は、数年越しに自分を苦しめることとなる。 

  3. 教本片手にUPDATE文を実行すると、目の前が真っ白になった。結果的に全データの削除フラグを立ててしまっただけで事なきを得たのだが、周りの私を見る目も真っ白になった。 

  4. 思い立ったというか、この時はWeb以外のクラサバやスマホアプリ等々のシステムを知らなかった。世の中知らない事だらけである。 

  5. 独特のグラフィックと広大なフィールド、さらにニテン堂等の奥深いやり込み要素に当時少年だった私は度肝を抜かれた。そして予約特典の「時のオカリナ裏」のあまりの難易度に更に度肝を抜かれた。 

  6. 今にして思うと、ニュアンスから察するに教授が課した「言語」とはJavaやらC#やらのことだったと思うのだが、きっと違ったのだろう。 

  7. 正確には「敵艦隊」。GC版では「敵艦隊」だが、リマスターされたWii U版では「巨大イカ」に変更されている。背景には倫理的な理由があったのかもしれないが、イカ相手に大砲をぶっ放すのは動物愛護的にはどうなのか。 

  8. Javascriptで何かしらを作った事がある人は「numberだと思って扱っていた変数がいつの間にかstringになっていた」という経験をしたことがあると思う。酷い時にはundefinedになっていて、原因もとい犯人探しに奔走することも数知れず。そんな理不尽な出来事も無問題。そうTypescriptならね。 

  9. 一度覚えてしまえば何てことはないのだが、覚え始めの頃は古代文明の碑文のように思えた。「StoreStateReducerがいて、ActionDispatchすればいいんだよ」というルー大柴も裸足で逃げ出すような説明をされればそう思うのも当然か。 

  10. 今回の作品開発にあたってbox-shadowをしっかり勉強することになったが、未だにリファレンスを見ながらでないと実装がおぼつかない。これをサラッとできるってんだからデザイナーさんってすげえや。 

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