- 投稿日:2020-07-09T18:55:13+09:00
更新後の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処理ができます
やったぜ。
- 投稿日:2020-07-09T16:40:10+09:00
日本一わかりやすいReact-Redux講座 実践編 #1~2 学習備忘録
はじめに
この記事は、Youtubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact-Redux講座 実践編』の学習備忘録です。
ここから、いよいよ本格的にアプリを開発していきます。
前回記事はこちら。
要約
店舗:ユーザー = 1:n
のECアプリを開発するFirebase Auth
による新規登録、サインイン認証を実装する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に移動。
メール/パスワード
の鉛筆マークから、本機能を有効化します。これで有効化ができました!とっても簡単。
Firebase用の設定ファイルを作る
ここからは、アプリ側でコードを書いていきます。
まず最初に、有効化したFirebase Auth機能をアプリと連携する設定変数を取得します。
Firebase SDK snippet
で構成
を選択すると、設定変数が取得できます。アプリ側でfirebase用のファイルを作成します。srcディレクトリ直下に
firebase
ディレクトリを作り、ここに設定用ファイルを定義します。src └─ firebase ├─ config.js └─ index.jssrc/firebase/config.jsexport const firebaseConfig = { apiKey: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX", authDomain: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX", databaseURL: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX", projectId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX", storageBucket: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX", messagingSenderId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX", appId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX", measurementId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXX" };先ほどの設定変数を保存するファイル。文頭で
export
している点は注意。src/firebase/index.jsimport 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.jsimport * 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.jscssファイルとしては、今後のこの
reset.css
やstyle.css
を使用していきます。これらを読み込む記述を
src/App.jsx
に加えます。src/App.jsximport 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.jsximport 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 PrimaryButtonsrc/components/UIkit/TextField.jsximport 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 TextInputsrc/components/UIkit/index.jsexport {default as PrimaryButton} from "./PrimaryButton" export {default as TextInput} from "./TextInput"各UIコンポーネントは、親コンポーネントからもらう props に応じて、諸々のパラメーター(ラベルや行数など)を変えられるようにしてあります。
上記のUIコンポーネントを利用して、SignUpテンプレートを作成します。
src/templates/SignUp.jsximport 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.jsximport 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画面で一通りできたはずです。ブラウザで確認してみると、
いい感じにできています!アカウント登録ボタンを押してみると、
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.js
にsignUp()
を定義します。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側のとの通信を簡単に実装できるuid
はunique id
の略。auth.createUserWithEmailAndPassword()
を実行した時点で自動的に生成される(resultの中に含まれる)- usersコレクションのうち、上記の
uid
のところへ、各引数を保存する。保存が完了したら、ルートへリダイレクトするこれでsignUp()関数の設定は完了しました。しかし、もう一つ追加でやることがあります。
現時点では、Cloud Firestore へのデータの書き込みが禁止された設定になっているため、それをいったん解除する必要があります。
データの書き込み・読み込み権限は
firestore.rules
で定義します。./firestore.rulesrules_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ここまでやれば準備完了です!ブラウザから実際にアカウント登録を実行してみます。
アカウント登録ボタンを押すと、ルートへリダイレクトされます。firebaseコンソールから、実際にアカウントが作成されたかを確認します。
Authentication
Database
アカウント登録が完了していますね!
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.jsximport 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
- サインインに必要な情報は
password
のみなので、username
とconfirmPassword
の入力フォーム(及びそれらを管理するためのstate)を削除- サインインボタンを押すことで、operations.jsで定義する
signIn()
関数が発火するように記述src/reducks/users/operations.jsimport { 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.jsexport 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.jsconst initialState = { users: { isSignedIn: false, role: "", uid: "", username: "" } }; export default initialState
role
カラムを追加している。src/templates/index.jsexport {default as Home} from './Home' export {default as SignIn} from './SignIn' export {default as SignUp} from './SignUp'
- Loginを削除し、SignInを追加。
src/Router.jsximport 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/
initialStateで定義した通り、ユーザーID、ユーザー名がブランクの状態です。
サインイン画面(/signin)より、先ほどアカウント登録したユーザー情報を用いてサインインを行います。
localhost:3000/signin
サインインボタンを押すと、ルートへリダイレクトされます。
先ほど登録したアカウントのユーザーID,ユーザー名が表示されていればOKです!
おわり
再度要点をまとめると、
店舗:ユーザー = 1:n
のECアプリを開発するfirebase.auth
による新規登録、サインイン認証を実装するreducksパターン
に沿った関数の実装手順を理解する以上です!次回は認証のリッスンによる state の永続化を行います。
- 投稿日:2020-07-09T12:30:44+09:00
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.jsexport 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].jsimport 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の設定に苦戦してしまいました・・。
githubのGraphQLでちゃんとscopeついてるトークンのはずなのにプライベートが取得できない・・なぜ・・
— Koji Murakami (@koojy3) July 7, 2020scopeは文字列で指定するのが正解ですが、配列で指定しても動いているような挙動になっていたんですよね。
scope: ['repo', 'read:org']こんな感じです。
この指定方法では適切にscopeが設定されたトークンを取得できないので注意が必要です。まとめ
わかってしまえばNextAuthはとても便利!という印象を受けましたが、ドキュメントのコードサンプルがもうちょっとあると嬉しいです。
(ちゃんとドキュメントを読んで、コードを読めばいいんですけど・・)
- 投稿日:2020-07-09T12:30:44+09:00
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.jsexport 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].jsimport 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の設定に苦戦してしまいました・・。
githubのGraphQLでちゃんとscopeついてるトークンのはずなのにプライベートが取得できない・・なぜ・・
— Koji Murakami (@koojy3) July 7, 2020scopeは文字列で指定するのが正解ですが、配列で指定しても動いているような挙動になっていたんですよね。
scope: ['repo', 'read:org']こんな感じです。
この指定方法では適切にscopeが設定されたトークンを取得できないので注意が必要です。まとめ
わかってしまえばNextAuthはとても便利!という印象を受けましたが、ドキュメントのコードサンプルがもうちょっとあると嬉しいです。
(ちゃんとドキュメントを読んで、コードを読めばいいんですけど・・)
- 投稿日:2020-07-09T01:39:07+09:00
Jupyter・RStudioみたいにパッと画像を表示する機能を作る【Docker】
概要
普段neovimでPythonやRのスクリプトを書くのですが、グラフをpngやhtmlで出力した後にいちいちファイルを開いて確認するのが面倒でした。新しいファイルが自動でパっと表示される機能がほしいなと思いdockerで作ってみました。
初めに断っておきますが、大したものは作っていません。「ふーん、そんなこと考える人がいるんだ」くらいのテンションで読んでもらえれば嬉しいです。
使い方
まずは以下のスクリプトを
docker-compose.yml
という名前で保存1。docker-composeは使えるようにしておいてください。docker-compose.ymlversion: "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で画面を作る作業は勉強になりました。
見たことないdockerイメージが指定されていると思いますが、私が作成したものです。DockerHubで公開しているので普通に
docker pull
できるはずです。 ↩
- 投稿日:2020-07-09T00:39:50+09:00
なぜ 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などは不要です。
- 投稿日:2020-07-09T00:13:48+09:00
【風のタクト】未完の名作となった海戦ゲームをTypescriptで今風に作ってみた
前説
先日、人生初の作品を、人生初のRustプログラミングで再現してみたという記事を読み、自分自身の人生初の作品は何だったのだろうかと思い返した。
入社してから手掛けたものは、いずれも既存のプログラムだし(何より公開できない)、敢えて挙げるなら入社1年目の忘年会で余興のために作ったビンゴマシン1だろうか・・・いや、それよりも前・・・学生時代に作ったものがあった。
正直なところ、極めて不真面目な学生だった私は、3年次に情報系のゼミに配属されるまでプログラミングというものに触れたことがなかった2。
それ故に担当の教授はさぞ頭を抱えたことだろう。なにせ覚えたてのSQL
を嬉々として本番環境で試し、危うくマスタデータを消し飛ばすような問題児3だ。「何でもいいから言語をひとつ覚えて作品を作ってみなさい」
そんなミッションを課され、とりあえずカッコいい
Web
ページを作ろうと思い立った私4はHTML
とCSS
に手を出し、そして最後にJavascript
を覚えた5。そしてようやく作りたいものが浮かんだ。
「ゼルダの伝説 風のタクト6」の海戦ゲームである。
割と有名なゲームなので知っている方もいるかもしれないが、初見の方のためにルールを説明しておくと・・・
- 8×8マスの中に3隻の船7が隠れている
- 3隻の船はそれぞれ3マス、4マス、5マスの長さで、縦もしくは横向きである(=斜めはない)
- プレイヤーは大砲を撃ち、対象の完全撃沈を目指す
- 大砲は最大24発まで発射可能で、24発以内に完全撃沈すれば勝利
とまあこんな感じのゲームである。
そこで分からないなりに頭を働かせ、上記の言語を駆使してなんとか完成はさせたのだが、このゲームは初級者にはちょっと厳しめのポイントがいくつかある。
ぱっと挙がるだけでも下記の通りである。
- ランダムに生成される3隻の船は盤面内に必ず収まっていなければならない
- 船同士の座標が被ってはならない
このあたりは勘所を押さえていないとドツボにハマりがちで、ご多分に漏れず私もハマった。
「盤外の新世界へとオンザクルーズする船」や「コバンザメの如く他船に重なる不届き者」が発生し、「世はまさに大海賊時代」の様相を呈していたが、残念なことに全てのバグを改修しきれずに時間切れ。
そう、未完のまま終わってしまったのである。そこで今回、無念の思いを抱えて電子の海を彷徨う海賊たちの怨念、もといバグ供養の意味も込めて、私が普段愛用している
Typescript
とReact
を使って、ついでに最近かじったNeumorphism
(ニューモフィズム)とかいうハイカラなデザインもゴリゴリに混ぜ込んでみる。完成品
先に動くものを見た方が分かりがいいと思うので、画面キャプチャを載せる。
完成品はGitHub Pagesのこちらのページで公開中(PC推奨。スマホも対応予定)。
いちおう上記で挙げたバグを回避しつつ、なるべくシンプルな作りに仕上げた。
ソースコード
ソースもGitHubのこちらのリポジトリで公開しているので、コアとなる
GameBoard.tsx
とTargetInfo.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.tsximport 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
やらと宗教戦争を繰り広げている。
HTML
とJavascript
がフュージョンしたような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 Pages
がmaster
ブランチのdocs
を対象とする制約があるため。package.json{ "scripts": { "build": "react-scripts build && mv build docs" }, "homepage": "https://【GitHubユーザ名】.github.io/【リポジトリ名】" }npm run buildまとめ
所要時間だが、それなりに見た目を整えて公開するまでだいたい3,4時間といったところだった。
過去の自分の作品を作り直してみると、自身の成長度合いを知ることができ、非常に有意義な時間だったといえる。ロジック的には大したものではないが、当時の自分が逆立ちしても解決できなかったようなバグをあっさりと解消できたのはなかなかに痛快だった。
また、デザインもなるべく今の時代に即した形にしているため、何年か経った後に同じ物を時々のデザインで作ると、デザイントレンドの変遷が視覚化できて面白いのではないかと思った。
これがまた見返してみると酷いソースだった。忘年会後に「稀に-1番が抽選される」というビンゴの常識を覆すバグを含んでいたことが発覚したのは記憶に新しい。 ↩
厳密には「プログラミング基礎」という講義を受講していた。最初こそ簡単だと思っていたが、「オブジェクト思考」云々のくだりで考えることを止めた。「車クラスを継承した救急車クラス?走れればなんでもええやん」という安易な思考停止は、数年越しに自分を苦しめることとなる。 ↩
教本片手に
UPDATE
文を実行すると、目の前が真っ白になった。結果的に全データの削除フラグを立ててしまっただけで事なきを得たのだが、周りの私を見る目も真っ白になった。 ↩思い立ったというか、この時は
Web
以外のクラサバやスマホアプリ等々のシステムを知らなかった。世の中知らない事だらけである。 ↩独特のグラフィックと広大なフィールド、さらにニテン堂等の奥深いやり込み要素に当時少年だった私は度肝を抜かれた。そして予約特典の「時のオカリナ裏」のあまりの難易度に更に度肝を抜かれた。 ↩
今にして思うと、ニュアンスから察するに教授が課した「言語」とは
Java
やらC#
やらのことだったと思うのだが、きっと違ったのだろう。 ↩正確には「敵艦隊」。
GC
版では「敵艦隊」だが、リマスターされたWii U
版では「巨大イカ」に変更されている。背景には倫理的な理由があったのかもしれないが、イカ相手に大砲をぶっ放すのは動物愛護的にはどうなのか。 ↩
Javascript
で何かしらを作った事がある人は「number
だと思って扱っていた変数がいつの間にかstring
になっていた」という経験をしたことがあると思う。酷い時にはundefined
になっていて、原因もとい犯人探しに奔走することも数知れず。そんな理不尽な出来事も無問題。そうTypescript
ならね。 ↩一度覚えてしまえば何てことはないのだが、覚え始めの頃は古代文明の碑文のように思えた。「
Store
とState
とReducer
がいて、Action
をDispatch
すればいいんだよ」というルー大柴も裸足で逃げ出すような説明をされればそう思うのも当然か。 ↩今回の作品開発にあたって
box-shadow
をしっかり勉強することになったが、未だにリファレンスを見ながらでないと実装がおぼつかない。これをサラッとできるってんだからデザイナーさんってすげえや。 ↩
- 投稿日:2020-07-09T00:13:48+09:00
【風のタクト】海戦ゲームをTypescriptで今風に作ってみた
前説
先日、人生初の作品を、人生初のRustプログラミングで再現してみたという記事を読み、自分自身の人生初の作品は何だったのだろうかと思い返した。
入社してから手掛けたものは、いずれも既存のプログラムだし(何より公開できない)、敢えて挙げるなら入社1年目の忘年会で余興のために作ったビンゴマシン1だろうか・・・いや、それよりも前・・・学生時代に作ったものがあった。
正直なところ、極めて不真面目な学生だった私は、3年次に情報系のゼミに配属されるまでプログラミングというものに触れたことがなかった2。
それ故に担当の教授はさぞ頭を抱えたことだろう。なにせ覚えたてのSQL
を嬉々として本番環境で試し、危うくマスタデータを消し飛ばすような問題児3だ。「何でもいいから言語をひとつ覚えて作品を作ってみなさい」
そんなミッションを課され、とりあえずカッコいい
Web
ページを作ろうと思い立った私4はHTML
とCSS
に手を出し、そして最後にJavascript
を覚えた5。そしてようやく作りたいものが浮かんだ。
「ゼルダの伝説 風のタクト6」の海戦ゲームである。
割と有名なゲームなので知っている方もいるかもしれないが、初見の方のためにルールを説明しておくと・・・
- 8×8マスの中に3隻の船7が隠れている
- 3隻の船はそれぞれ3マス、4マス、5マスの長さで、縦もしくは横向きである(=斜めはない)
- プレイヤーは大砲を撃ち、対象の完全撃沈を目指す
- 大砲は最大24発まで発射可能で、24発以内に完全撃沈すれば勝利
とまあこんな感じのゲームである。
そこで分からないなりに頭を働かせ、上記の言語を駆使してなんとか完成はさせたのだが、このゲームは初級者にはちょっと厳しめのポイントがいくつかある。
ぱっと挙がるだけでも下記の通りである。
- ランダムに生成される3隻の船は盤面内に必ず収まっていなければならない
- 船同士の座標が被ってはならない
このあたりは勘所を押さえていないとドツボにハマりがちで、ご多分に漏れず私もハマった。
「盤外の新世界へとオンザクルーズする船」や「コバンザメの如く他船に重なる不届き者」が発生し、「世はまさに大海賊時代」の様相を呈していたが、残念なことに全てのバグを改修しきれずに時間切れ。
そう、未完のまま終わってしまったのである。そこで今回、無念の思いを抱えて電子の海を彷徨う海賊たちの怨念、もといバグ供養の意味も込めて、私が普段愛用している
Typescript
とReact
を使って、ついでに最近かじったNeumorphism
(ニューモフィズム)とかいうハイカラなデザインもゴリゴリに混ぜ込んでみる。完成品
先に動くものを見た方が分かりがいいと思うので、画面キャプチャを載せる。
完成品はGitHub Pagesのこちらのページで公開中(PC推奨。スマホも対応予定)。
いちおう上記で挙げたバグを回避しつつ、なるべくシンプルな作りに仕上げた。
ソースコード
ソースもGitHubのこちらのリポジトリで公開しているので、コアとなる
GameBoard.tsx
とTargetInfo.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.tsximport 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
やらと宗教戦争を繰り広げている。
HTML
とJavascript
がフュージョンしたような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 Pages
がmaster
ブランチのdocs
を対象とする制約があるため。package.json{ "scripts": { "build": "react-scripts build && mv build docs" }, "homepage": "https://【GitHubユーザ名】.github.io/【リポジトリ名】" }npm run buildまとめ
所要時間だが、それなりに見た目を整えて公開するまでだいたい3,4時間といったところだった。
過去の自分の作品を作り直してみると、自身の成長度合いを知ることができ、非常に有意義な時間だったといえる。ロジック的には大したものではないが、当時の自分が逆立ちしても解決できなかったようなバグをあっさりと解消できたのはなかなかに痛快だった。
また、デザインもなるべく今の時代に即した形にしているため、何年か経った後に同じ物を時々のデザインで作ると、デザイントレンドの変遷が視覚化できて面白いのではないかと思った。
これがまた見返してみると酷いソースだった。忘年会後に「稀に-1番が抽選される」というビンゴの常識を覆すバグを含んでいたことが発覚したのは記憶に新しい。 ↩
厳密には「プログラミング基礎」という講義を受講していた。最初こそ簡単だと思っていたが、「オブジェクト思考」云々のくだりで考えることを止めた。「車クラスを継承した救急車クラス?走れればなんでもええやん」という安易な思考停止は、数年越しに自分を苦しめることとなる。 ↩
教本片手に
UPDATE
文を実行すると、目の前が真っ白になった。結果的に全データの削除フラグを立ててしまっただけで事なきを得たのだが、周りの私を見る目も真っ白になった。 ↩思い立ったというか、この時は
Web
以外のクラサバやスマホアプリ等々のシステムを知らなかった。世の中知らない事だらけである。 ↩独特のグラフィックと広大なフィールド、さらにニテン堂等の奥深いやり込み要素に当時少年だった私は度肝を抜かれた。そして予約特典の「時のオカリナ裏」のあまりの難易度に更に度肝を抜かれた。 ↩
今にして思うと、ニュアンスから察するに教授が課した「言語」とは
Java
やらC#
やらのことだったと思うのだが、きっと違ったのだろう。 ↩正確には「敵艦隊」。
GC
版では「敵艦隊」だが、リマスターされたWii U
版では「巨大イカ」に変更されている。背景には倫理的な理由があったのかもしれないが、イカ相手に大砲をぶっ放すのは動物愛護的にはどうなのか。 ↩
Javascript
で何かしらを作った事がある人は「number
だと思って扱っていた変数がいつの間にかstring
になっていた」という経験をしたことがあると思う。酷い時にはundefined
になっていて、原因もとい犯人探しに奔走することも数知れず。そんな理不尽な出来事も無問題。そうTypescript
ならね。 ↩一度覚えてしまえば何てことはないのだが、覚え始めの頃は古代文明の碑文のように思えた。「
Store
とState
とReducer
がいて、Action
をDispatch
すればいいんだよ」というルー大柴も裸足で逃げ出すような説明をされればそう思うのも当然か。 ↩今回の作品開発にあたって
box-shadow
をしっかり勉強することになったが、未だにリファレンスを見ながらでないと実装がおぼつかない。これをサラッとできるってんだからデザイナーさんってすげえや。 ↩