- 投稿日:2021-01-24T23:34:57+09:00
アプリを一定時間操作しなかったときに認証画面を表示する[React / ReactNative / Expo]
この記事について
ユーザがアプリを一定時間操作していない場合に画面をロックし、PINコードや指紋・顔認証を求めるような機能をReactで実装してみます。
React Native + Expo でアプリを開発しているとして、認証機能自体はExpoのモジュール(Local Authentication)を使用します。
また、「ユーザが操作している」状態の定義は場合によるかと思いますが、ここでは画面内のどこかしらにタッチイベントが発生したかどうかで判定するとします。これをマウスイベントに置き換えるなどすれば、大枠の実装はWebなど他のコンテクストでも共通するものになるはずです。つまりReact Nativeに関してそれほど専門性のある記事ではなく、Reactの学習者に向けた内容になっています。
実装の流れ
1. インターフェースを考える
まず、この機能をコンポーネント化するとしたら、どのようなインタフェースにすればよいでしょう。要件からすれば、認証するまでアプリの機能を使用できなくする(ロックする)という以上、アプリの他の機能(ベースとなる機能)の存在を前提としています。
また、ロック画面を一番手前(z-index最大)に表示してアプリ全体を覆ってしまえば実質的に他の機能は使用できなくなりますが、場合によっては機能全体、あるいは一部分を非表示にしたりする、あるいは一部の処理を無効にするなどの必要が出てくることが想定できます。つまりベースとなる機能自体に影響を与えるポテンシャルを持っている必要がある。そのためには、アプリの比較的根本の部分で、ベースとなる機能を包括(ラップ)するようなコンポーネントになるはずです。
コンポーネントの名前を仮に
LockScreenProvider
とし、JSXで表記するとしたらこのようなイメージです。使用イメージ<LockScreenProvider> <App /> /* ベースとなる機能をchildrenとして渡す */ </LockScreenProvider>TypeScriptの
interface
としてコンポーネントのpropsを定義するとしたら以下のようになります。
ロック画面を表示するまでの時間を定義しつつ、propとして任意に渡せるようにもしてみます。Props定義interface Props { children: React.ReactElement; timeout?: number; // タイムアウト時間(最後に操作してからロック画面を表示するまでの時間,ms) } /** * デフォルトのタイムアウト時間(ms) */ const DEFAULT_TIMEOUT = 30000;さて、先ほど他の機能の「一部分を非表示にしたりする」と書きました。そもそもロック画面を不透明にしアプリを覆ってしまえば実質的にすべての他の機能が非表示になるため、必要となる場面は限られるかもしれませんが、もしこのような処理を行うためにはどうすればよいでしょうか。
一つの方法としては、子要素としてReact要素ではなく「React要素を返す関数」をとり、ロック状態を引数として渡すようなインターフェースが考えられます。使用イメージ(子要素として関数を渡す)<LockScreenProvider> {isLocked => <App isLocked={isLocked} />} </LockScreenProvider>Props定義(子要素として関数を渡す)interface Props { children: (isLocked: boolean) => React.ReactElement; timeout?: number; }子要素(例では
App
)はこのisLocked
の値によって表示や処理を変えればよいわけです。
コンポーネント単体で機能が完結するため使い勝手は良いですが、この記事ではReactのContext / Provider APIを使ったもう一つのパターンを採用し、以下のように実装してみます。Contextを作成し、そのContextを使用するためのフックを先ほどのLockScreenProviderのサブ機能としてexportしておきます。
LockScreenProvider.tsx(部分)/** * useIsLocked * 下階層でロック状態を参照できるようにContext化する */ const LockScreenContext = React.createContext<boolean>(false); export function useIsLocked() { return useContext(LockScreenContext); } /* 省略 */ const isLocked = true; // ロック状態を任意のロジックで管理する return ( <LockScreenContext.Provider value={isLocked}>{/* ProviderとしてContextの値をセットする */} {/* 省略(ここにロック画面などが入る) */} {children}{/* 子要素をそのまま返す */} </LockScreenContext.Provider> );下の階層でこのロック状態を取得するには、この
useIsLocked
を使用します。先ほどの子要素を関数としてとるパターンの場合は孫階層でロック状態を知ろうとするとpropsを順々に経由しなければなりませんが、Contextを使用することで、そのContextのProvider以下の階層であればどこでも直接的に値を参照できるようになります。
最初の例と同じく通常のReact要素として子要素を包みます。
Providerの使用イメージ(最初の例と同じ)<LockScreenProvider> <App /> </LockScreenProvider>下の階層(
App
の子孫要素)では、どこでも先ほどのuseIsLocked
を使用しロック状態を取得することができます。下の階層でContextの値を参照するimport { useIsLocked } from "./LockScreenProvider.tsx"; /* 省略 */ const isLocked = useIsLocked(); // ロック状態を取得する2. ロック状態をコントロールする処理
では、実際に「アプリを一定時間操作していない」ときにロック画面にする処理を実装していきます。
まずはロック状態をstateとして管理しましょう。
この値をロック画面の表示・非表示の制御に使用し、Contextの値としてコンポーネント配下に渡すことになります。LockScreenProvider.tsx(部分)// ロック状態 const [isLocked, setIsLocked] = useState<boolean>(false);一般的にJavaScriptにおいて、最後に何かをしてから一定時間経ったら、といった処理をする場合には
setTimeout
およびclearTimeout
を使用します。
より確実に、厳密な時間を見る必要がある場合はrequestAnimationFrame
で(およそ60FPSの)毎フレーム時刻を見て差分を測る方法を採りますが、ここではその用途からしてそこまでの精度は求めないということにします。まずは、後に
clearTimeout
でタイマーを破棄するための識別子として、setTimeout
の返り値を保持できるようにしておきます。
型はブラウザではnumber
ですが、ReactNativeの開発環境なのでNodeJS.Timeout
としています。一般的に、(Class Componentは例外として)コンポーネント内で値を保持するためには先ほどと同じように
useState
を使うので、素直にそうしておきましょう。この部分は後々修正します。LockScreenProvider.tsx(部分)// タイマーの返り値を保持する const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);「一定時間〜しなかった時に」の部分の処理を実装します。
以下のように、「最後に何かをする」というトリガーになる処理の際にclearTimeout
およびsetTimeout
を使用し、タイマーによる処理を破棄、再予約する形になります。LockScreenProvider.tsx(部分)// ロックする関数 const lock = useCallback(() => setIsLocked(true), []); // タイマー処理を更新(破棄・再予約)する const updateTimer = useCallback(() => { if (isLocked) { return; } // 既存のタイマーをクリア clearTimeout(timer); // タイマーを再予約 setTimer(setTimeout(lock, timeout)); }, [isLocked, timer, lock, timeout]); // ロックを解除する関数 const unlock = useCallback(() => { setIsLocked(false); updateTimer(); // 再度タイマーをセット }, [updateTimer]);
lock
、updateTimer
、unlock
という関数を用意し、それぞれuseCallback
によってメモ化しています。
lock
は単純にロック状態に変更(isLocked
をtrue
に)する処理、
updateTimer
は何かしら操作が行われた際に呼ばれタイマーを更新する処理、
unlock
はロック状態を解除(isLocked
をfalse
に)し、タイマーをまたセットする処理です。
メモ化しているので、第二引数に依存する変数をいれることによってきっちりと各関数自体の更新がかかるようにします(また、その依存関係によってこの定義順になっています)。ちなみに、このままではコンポーネントが最初にマウントされた際にタイマーがセットされません。
要件次第ですが、最初から何も操作していない場合にも一定時間後にロックする場合、以下のようにuseCallback
を使用し、マウント時にタイマーをセットする処理を追加します。LockScreenProvider.tsx(部分)// 最初にタイマーをセット useEffect(() => { // 第二引数に空の配列をいれているため、マウント時に呼ばれる setTimer(setTimeout(lock, timeout)); }, []);そして、肝心のイベントハンドリング、およびロック画面の定義を行います。
View
,Modal
,Text
などはReact Nativeの要素です。スタイリングなども適宜読みかえてください。
タッチイベントが発生した時に先ほどのupdateTimer
を呼び、タイマー処理によってセットされたisLocked
フラグを使ってロック画面(例ではModal
部分)の表示を切り替えます。
ロック画面には、解除(unlock
)するボタンも入れておきます。このボタンにはこの記事の第二のテーマとして後ほど認証機能をつけましょう。LockScreenProvider.tsx(部分)return ( <LockScreenContext.Provider value={isLocked}> <View style={styles.wrapper} onTouchStart={updateTimer} onTouchMove={updateTimer} onTouchEnd={updateTimer} > <Modal visible={isLocked} transparent> <View style={styles.modal}> <Text style={styles.title}> しばらく操作されていないため{"\n"} 画面がロックされています </Text> <TouchableOpacity onPress={unlock} style={styles.button}> <Text style={styles.buttonTitle}>ロックを解除する</Text> </TouchableOpacity> </View> </Modal> {children}{/* 子要素をそのまま */} </View> </LockScreenContext.Provider> );この時点でアプリを起動して確認してみます。
適当な子要素を作り表示しています。
タッチ操作を行い、一定時間(先ほどデフォルトの時間を30秒としました)そのまま放置すると、
ロック画面が表示されました。
「ロックを解除する」ボタンで解除し、また放置すると、再びロック画面になり、
子要素でuseIsLocked
を使用しロック状態を取得できているのも確認できました。
これで概ね処理の流れができました。ところで、このコンポーネントにはこの時点でひとつ問題点があります。
useState
で保持しているsetTimeout
の返り値。これはタッチ操作の度に書き換えられます。
試しにtimer
をコンソールに出してみると...
指でなぞるタッチ操作により連続で値が書き換わり、コンポーネントの関数自体が再評価されているのがわかります。
この値を画面に表示する必要があるような場合にはしょうがないので、適切にこのコンポーネント配下のレンダリングをチューニングする他ありませんが、このtimer
の値はclearTimeout
のために保持しているにすぎず、画面表示には何の必要もありません。パフォーマンスの観点から好ましくないことは明白です。では、この問題点を解決するにはどのような方法があるでしょうか。
ひとつには、あまり推奨されるべきではありませんが、この変数timer
をこの関数コンポーネントのスコープ外に定義してしまう方法があります。このコンポーネントをシングルトン(実行時単一のインスタンスしか存在しないクラス)として使用すると決めているのであれば有効な手段です。
この場合、timer
を更新したとしても関数は再評価されません。LockScreenProvider.tsx/* 省略 */ let timer: NodeJS.Timeout | null = null; /** * LockScreenProvider * [Context Provider] */ function LockScreenProvider({ children, timeout = DEFAULT_TIMEOUT }): React.FunctionComponent<Props> { /* 省略 */ // タイマー処理を更新(破棄・再予約)する const updateTimer = useCallback(() => { if (isLocked) { return; } // 既存のタイマーをクリア clearTimeout(timer); // タイマーを再予約 timer = setTimeout(lock, timeout)); }, [isLocked, lock, timeout]); /* 省略 */もう一つ、あまり良くない例を挙げます。
useState
を使いつつ以下のようにすればどうでしょうか?LockScreenProvider.tsxconst [timerObj] = useState<{ timer: NodeJS.Timeout | null }>({ timer: null }); // タイマー処理を更新(破棄・再予約)する const updateTimer = useCallback(() => { if (isLocked) { return; } // 既存のタイマーをクリア clearTimeout(timerObj.timer); // タイマーを再予約 timerObj.timer = setTimeout(lock, timeout); }, [isLocked, timerObj, lock, timeout]);
set
関数を使わず、timerObj
自体が参照しているオブジェクト自体は同じであり、その中身(プロパティ)を書き換えているだけなので、関数コンポーネントの再評価は行われません。ここでstateとして扱っている
timerObj
のように、中身だけを変化することができるオブジェクトのことをミュータブル(mutable)なオブジェクトと呼びます。反対に、中身を変化させることはできず、変化させるためには新しく作成したものに置き換えなければいけないような制約を持つオブジェクトをイミュータブル(immutable)なオブジェクトと呼びます。上記の実装では、
set
関数を使用せずにstateを書き換えている点や、stateとして扱うデータをミュータブルなものとして扱っている点が、Reactの作法からは外れてしまっています。ではどうすればよいかというと、構造的にはほとんど上記の例と変わりはないのですが...、
このような場合、つまりReactの作法に則った上でコンポーネント内部においてミュータブルなオブジェクトを保持したい場合には、下記のようにuseRef
を使用します。LockScreenProvider.tsxconst timerRef = useRef<NodeJS.Timeout | null>(null); // タイマー処理を更新(破棄・再予約)する const updateTimer = useCallback(() => { if (isLocked) { return; } // 既存のタイマーをクリア clearTimeout(timerRef.current); // タイマーを再予約 timerRef.current = setTimeout(lock, timeout); }, [isLocked, timerRef, lock, timeout]);refというと子コンポーネントやDOM要素などを参照するためのものというイメージがあるかもしれませんが、フックAPI以降その本質はこのように「ミュータブルなオブジェクト」であるという点にあります。
上記の例のように、refオブジェクトの
current
プロパティには任意のタイミングで値を上書きすることができます。
これにより、タッチイベントが発生するたびにタイマーの識別子を上書きしても関数コンポーネント自体の再評価は行われなくなります。また、
updateTimer
が置き換わるタイミングが変わったことから、unlock
関数と初期表示時のタイマーセット、ロック解除後のタイマー再セットのロジックをよりシンプルに書き換えることができます。LockScreenProvider.tsx// ロックを解除する関数 const unlock = useCallback(() => setIsLocked(false), []); // ロック解除時にタイマー処理を更新 // 条件からして初期表示時にも呼ばれる useEffect(() => { if (!isLocked) { updateTimer(); } }, [isLocked, updateTimer]);ではここまでで、「アプリを一定時間操作しなかったときに」ロック画面を表示するロジックは完成しました。Reactを使用したアプリケーション開発においてコンテクストに関わらず共通するような部分は以上になります。
3. 認証機能を実装する
Expoのモジュールを使用して認証画面を実装してみます。
まずはパッケージをインストールしましょう。インストール$ expo install expo-local-authenticationLockScreenProvider.tsx(部分)import * as LocalAuthentication from "expo-local-authentication";モジュールは認証画面を表示するメソッドに加えて以下のチェック用のメソッド3つから成ります。非常にシンプルで、パーミッションも必要ありません。
LocalAuthentication.hasHardwareAsync()
その端末において指紋認証あるいは顔認証のセンサーが使用できるかどうかを返します。
実際に指紋や顔データの登録がなくても、ハードウェア自体にセンサーがあればtrue
を返すようです。
LocalAuthentication.supportedAuthenticationTypesAsync()
その端末において指紋認証、顔認証、Iris(虹彩認証)の中で使用できるものを返します。
返り値は定数の配列になっています。
LocalAuthentication.isEnrolledAsync()
その端末に指紋認証、あるいは顔認証のデータが登録されているかどうかを返します。認証画面の表示には
LocalAuthentication.authenticateAsync(options)
を使用します。
生体認証用のデータが無い場合は自動的にパスコード(PINコード)認証にフォールバックされ、さらにパスコードが登録されていない場合には返り値の中にエラーメッセージ(passcode_not_set
)が入ります。
上記のチェック用メソッドによって認証方法を切り分けてもよいですが、使用可能な認証方法が自動的に選択されるようになっているため、特別な対応が必要であるとしたら生体認証用データもパスコードも登録されていない場合にどうするかという点のみでしょう。
その場合はそもそもロック画面を表示しない、あるいは独自の認証方法を適用するといったことが考えられます(この記事では割愛します)。では、これまでに作成した
LockScreenProvider
に認証機能をつけてみます。
先ほどのunlock
関数に以下のように処理を追加します。LockScreenProvider.tsx(部分)// センサーや生体認証データが無いために認証ができない場合の処理 const fallbackAuthentication = useCallback(() => { setIsLocked(false); // 仮でそのままロック解除とする }, []); // ロック/ロック解除処理 const lock = useCallback(() => setIsLocked(true), []); const unlock = useCallback(async () => { const hasHardware = await LocalAuthentication.hasHardwareAsync(); if (!hasHardware) { fallbackAuthentication(); return; } const { success, error } = await LocalAuthentication.authenticateAsync({ promptMessage: "ロック画面を解除します", }); if (success) { setIsLocked(false); } else if (error !== "user_cancel") { fallbackAuthentication(); } }, [fallbackAuthentication]);認証データがパスコードを含め何も無い場合にはフォールバックの処理を行うようにし、オプションとして
promptMessage
にメッセージを渡します。(その他のオプションはこちら)
ロック画面で解除するボタンを押すと、Android 指紋認証(PIN画面はセキュリティのためかスクショできず)
iOS パスコード
iOS Touch ID
このような認証画面が表示され、認証が成功するとロック画面が消えるようになりました。
Androidでは指紋認証かPINコードかユーザが選択でき(それゆえに、オプションのdisableDeviceFallback
は効果がありません)、iOSではTouch IDの登録が無ければパスコード入力画面、あればTouch IDのダイアログが表示されます。サンプルソース
以上でアプリを一定時間操作しなかったときに認証画面を表示する実装ができました。
この記事の実際のソースはこちらにまとめています。
https://github.com/mildsummer/expo-lock-screen-exampleコンポーネントの最終的な全ソースはこちら。
LockScreenProvider.tsx/** * LockScreenProvider * 一定時間操作していないときにロック画面を表示する */ import React, { useCallback, useContext, useEffect, useRef, useState } from "react"; import { Modal, Text, ViewStyle, StyleSheet, View, TextStyle, TouchableOpacity } from "react-native"; import * as LocalAuthentication from "expo-local-authentication"; // props // 型定義部 interface Props { children: React.ReactElement; timeout?: number; } // styles // 型定義部 interface Styles { modal: ViewStyle; title: TextStyle; button: ViewStyle; buttonTitle: TextStyle; wrapper: ViewStyle; } // スタイリング const styles: Styles = StyleSheet.create<Styles>({ modal: { flex: 1, width: "100%", justifyContent: "center", alignItems: "center", backgroundColor: "rgba(0, 0, 0, 0.8)" }, title: { marginBottom: 20, fontSize: 20, lineHeight: 20 * 1.4, color: "#FFFFFF", textAlign: "center", }, button: { padding: 12, }, buttonTitle: { fontSize: 20, color: "#007aff", }, wrapper: { flex: 1, width: "100%" }, }); /** * デフォルトのタイムアウト時間(ms) */ const DEFAULT_TIMEOUT = 3000; /** * useIsLocked * 下階層でロック状態を参照できるようにContext化する * (ロック状態のモーダルを半透明にしつつ、ロック状態ではユーザ状態を非表示にするなど) * [Context] */ const LockScreenContext = React.createContext<boolean>(false); export function useIsLocked() { return useContext(LockScreenContext); } /** * LockScreenProvider * [Context Provider] */ function LockScreenProvider({ children, timeout = DEFAULT_TIMEOUT }): React.FunctionComponent<Props> { // setTimeoutで返る値を保持 // (stateだとsetの際にrenderされてしまうがその必要が無いものはrefを使用) const timerRef = useRef<NodeJS.Timeout | null>(null); // ロック状態 const [isLocked, setIsLocked] = useState<boolean>(false); // センサーや生体認証データが無いために認証ができない場合の処理 const fallbackAuthentication = useCallback(() => { setIsLocked(false); // 仮でそのままロック解除する }, []); // ロック/ロック解除処理(memo化) const lock = useCallback(() => setIsLocked(true), []); const unlock = useCallback(async () => { const hasHardware = await LocalAuthentication.hasHardwareAsync(); if (!hasHardware) { fallbackAuthentication(); return; } const { success, error } = await LocalAuthentication.authenticateAsync({ promptMessage: "ロック画面を解除します", }); if (success) { setIsLocked(false); } else if (error !== "user_cancel") { fallbackAuthentication(); } }, [fallbackAuthentication]); // タイマー処理を更新する(memo化) // タッチ操作時に連続で呼ばれる const updateTimer = useCallback(() => { if (isLocked) { return; } // 既存のタイマーをクリア clearTimeout(timerRef.current); // タイマーを再セット timerRef.current = setTimeout(lock, timeout); }, [isLocked, timerRef, lock, timeout]); // ロック解除時にタイマー処理を更新 // 条件からして初期表示時にも呼ばれる useEffect(() => { if (!isLocked) { updateTimer(); } }, [isLocked, updateTimer]); return ( <LockScreenContext.Provider value={isLocked}> <View style={styles.wrapper} onTouchStart={updateTimer} onTouchMove={updateTimer} onTouchEnd={updateTimer} > <Modal visible={isLocked} transparent> <View style={styles.modal}> <Text style={styles.title}> しばらく操作されていないため{"\n"} 画面がロックされています </Text> <TouchableOpacity onPress={unlock} style={styles.button}> <Text style={styles.buttonTitle}>ロックを解除する</Text> </TouchableOpacity> </View> </Modal> {children} </View> </LockScreenContext.Provider> ); } export default React.memo(LockScreenProvider);追記(ロック状態の永続化)
このようなロジックでアプリをロックしたとしても、一度アプリを落として再起動するという方法で悪意のある操作をされてしまったら意味がありません。
そういった場合に対応するとしたら、以下のようにAsyncStorageと組み合わせてロック状態を永続化するといったことも必要になります。他に永続化の仕組みを使っていれば必要ありませんが、簡単にAsyncStorageを使うユーティリティ的なフックを書いてみます。
useStorage.tsximport { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; /** * useStateのようなI/FでAsyncStorageを使用するhook * @param {string} key Storageに保存するためのキー * @param {string} initialValue 初期値 */ export default function useStorage(key: string, initialValue: string): [string, Dispatch<SetStateAction<string>>] { const keyRef = useRef<string>(key); const [value, _set] = useState<string>(initialValue); // 最初にAsyncStorageから取得 useEffect(() => { AsyncStorage.getItem(key, (error, result) => { if (result !== null) { _set(result); } }); }, [key]); // 値をセットする関数 // AsyncStorageにも反映 const set = useCallback((newValue: string) => { _set(newValue); AsyncStorage.setItem(key, newValue); }, [key]); // 念のため、keyが変わった場合に元のkeyのvalueを削除 useEffect(() => { if (keyRef.current !== key) { AsyncStorage.removeItem(key); } keyRef.current = key; }, [keyRef, key]); return [value, set]; }
string
型でしか保存できない点が面倒ですがこのような形でisLocked
を保存します。これにより、ロック画面になった状態でアプリを再起動すると即時ロック状態が引き継がれるようになりました。LockScreenProvider.tsxconst STORAGE_KEY = "APP_LOCKED"; /* 省略 */ // ロック状態 const [isLockedStr, setIsLockedStr] = useStorage(STORAGE_KEY, "false"); const setIsLocked = useCallback((isLocked) => { setIsLockedStr(isLocked.toString()); }, []); const isLocked = isLockedStr === "true";
- 投稿日:2021-01-24T18:15:52+09:00
フロントサーバ、バックサーバの分離された構成でミニマムなサービスを作る。
はじめに
本記事はモノリシックでない分離されたアーキテクチャ(マイクロサービス)について理解を深めるため、
ミニマムなアプリを作ってみたときのまとめ記事です。
※当方初心者のため、間違いありましたら是非ともご指摘お願いいたします。著者について
以下著者のスペック
- エンジニア一年目
- WebアプリといえばMVCしか知らない
- マイクロサービス?なにそれおいしいの
背景
基本情報などの勉強中、よくこんな図が出てきて混乱していました。
- Webアプリのアーキテクチャ?、MVCしかしらんけど?
- アプリのサーバって一つだけじゃないの?
- でもReactとかは独立してサーバーが立っているぽい、、
- どうやってバックエンドと連携させるんやろ、、?
このような疑問を持った私は、「わかんないなら触ってみればいいじゃん!」と意気込み、
フロントエンドとバックエンドの機能をサーバごとに切り離したミニマムなアプリを作ろうと決めました。開発
概要
画面を担当するフロントサーバとロジックを担当するバックサーバの二つのサーバを立てて、
インターネットのニュースを検索できるアプリを作る。環境
Node.js v14.15.1
- サーバサイドのJavaScript
- こちらの記事が非常にわかりやすくおすすめ
- 今回は超簡単なロジックを実装するのみに使う
Express v4.17.1
- Node.jsで動く軽量なWebフレームワーク
- Webサーバを立てるのも非常に簡単
- 今回はAPIのルーティング等に使う
Yarn v1.22.4
- npmと互換性があるパッケージ管理システム
- 一度インストールしたパッケージをキャッシュするためインストールが高速
目標
画像のような簡単なニュース検索アプリを作る。
検索条件を入れ、「Search」ボタンを押下すれば、検索条件にヒットするニュースを検索する。
構成
重要であるのはフロントサーバとバックサーバをAPIで疎結合している点です。
フロントサーバはバックサーバのAPIを呼び出し、返却されたレスポンスをもとに画面描画をします。手順
バックサーバの実装
Node.js, Expressの解説は目的ではないため、重要な部分(独自APIを実装する部分)のみ示します。
以下はExpressでサーバを起動する部分です。backend/server.js'use strict'; const express = require('express'); const app = express(); const cors = require('cors'); const dotenv = require('dotenv'); dotenv.config({path: './.env'}); const morgan = require('morgan'); // CORS(クロスオリジンリソース共有)を許可 app.use(cors()); require('./routes/news')(app); // アクセスロガーを実装 app.use(morgan('dev')); // サーバをポート3000で起動 app.listen(process.env.PORT, () => console.log('listening on port ' + process.env.PORT)); module.exports = app;ここではCORS(クロスオリジン間リソース共有)を有効にしています。
CORSについては自分もよく理解しきれていないですがMDNに以下のような説明があります。オリジン間リソース共有Cross-Origin Resource Sharing (CORS) は、追加の HTTP ヘッダーを使用して、あるオリジンで動作しているウェブアプリケーションに、異なるオリジンにある選択されたリソースへのアクセス権を与えるようブラウザーに指示するための仕組みです。ウェブアプリケーションは、自分とは異なるオリジン (ドメイン、プロトコル、ポート番号) にあるリソースをリクエストするとき、オリジン間 HTTP リクエストを実行します。
https://developer.mozilla.org/ja/docs/Web/HTTP/CORSこちらの記事が詳しいため参考にしてください。
次はインターネットからニュースの情報を取得する処理です。
ここではNews APIというAPIを本アプリ用にラップしています。backend/routes/news.jsconst NewsAPI = require('newsapi'); const newsapi = new NewsAPI(process.env.NEWS_API_ACCESS_KEY); const morgam = require('morgan'); const router = require('express').Router(); module.exports = (app) => { router.route('/') .get((req, res) => res.json({message: 'This is a index page.'})); router.route('/news') .get((req, res) => { newsapi.v2.topHeadlines({ // 検索条件が指定されなかった場合はデフォルトの条件を指定する。 country: req.query.country || 'jp', category: req.query.category || 'general', q: req.query.q || '', pageSize: Number(req.query.pageSize) || 30 }).then(news => res.json(news)); }); //bind access logger app.use(morgam('dev')); app.use(router); };フロントサーバの実装
こちらもReactの解説は目的でないため、重要な部分(バックサーバと通信する部分)のみ示します。
またcreate-react-appを使用してテンプレを作成しました。以下はバックサーバのAPIを呼び出し、返却されたニュース一覧のJSONを画面上の変数に渡しています。
frontend/src/App.jsconst handleSubmit = async event => { // submitボタンを押すとブラウザのデフォルトでリロードされてしまうため // デフォルトの動作をさせないよう設定する event.preventDefault(); // バックサーバのAPIを呼び出す let articlesArr = await axios.get(endPoint + '/news', { // 画面に入力された検索条件を独自APIのリクエストに乗せる params: { country: country.value, category: category.value, q: keyword, pageSize: pageSize.value } }) // データが返却されたら変数articlesArrにデータを代入する。 .then(res => res.data.articles); // 画面上の変数にデータを代入する。 setArticles(articlesArr); };完成品
ソースは以下においてあります。
https://github.com/yasuaki640/news-api-app※コードレビュー歓迎
終わりに
業務でも趣味でもモノリシックなアーキテクチャしか触ったことがなく、
ツイッター上でマイクロサービスなどの用語を理解するのに時間がかかりました。
※現在は完全に理解した程度技術理解のために実際に触れてみるのはやはり強いですね、、、
本記事がどなたかのお役に立てれば幸いです。
※間違いありましたら是非ご指摘お願いいたします。
- 投稿日:2021-01-24T17:44:40+09:00
Reactでの認証時にJWTをCookieに設定する方法
SPAでの認証といえばJWTを使うことが多いと思いますが、
localStorageに保存するとセキュリティリスクが高いとかで、
CookieにHttpOnlyな値として保存するのが良いとしばしば言われることもあります。
今回はReact × ExpressでJWTをCookieに保存する具体的な方法を紹介します。(そもそもJWTを使うべきかとか、localStorageを使うことのリスクなどについては要件次第なのであまり言及しません)
調査にあたっては以下の記事を参考にしました。
React Authentication: How to Store JWT in a Cookie記事の方法そのままでは自分の環境では上手くいかなかったので、ハマりポイントも含めて手順を解説します。
最終的に出来上がったもの
JWTをCookieに保存する
https://github.com/Kanatani28/jwt-how-to-use/tree/fix_using_cookiesCSRF対策
https://github.com/Kanatani28/jwt-how-to-use/tree/fix_using_csrf_protection動作環境
以下のDockerイメージを使用して挙動を確認しました。
node:15.5.1-alpine3.12準備編
まずはlocalStorageにJWTを保存して動くサンプルアプリケーションを用意します。
上記の参考記事を見てもらっても良いですが、
こちらで用意した以下のリポジトリを見てもらっても良いです。
本記事ではこちらに準じて進めます。Reactの部分だけTypeScriptを使用 + Dockerを使った構成
https://github.com/Kanatani28/jwt-how-to-use(ちなみに自前でプロジェクトを作成したい場合は
create-react-app
でプロジェクトを作成して、各種ライブラリをインストールしてください。)ソースコードは以下のようになっています。
App.tsximport React, { useState } from 'react'; import axios from 'axios'; import './App.css'; const apiUrl = 'http://localhost:3001'; axios.interceptors.request.use( // allowedOriginと通信するときにトークンを付与するようにする設定 config => { const { origin } = new URL(config.url as string); const allowedOrigins = [apiUrl]; const token = localStorage.getItem('token'); if (allowedOrigins.includes(origin)) { config.headers.authorization = `Bearer ${token}`; } return config; }, error => { return Promise.reject(error); } ); type Food = { id: number description: string } function App() { const storedJwt = localStorage.getItem('token'); const [jwt, setJwt] = useState(storedJwt || null); const [foods, setFoods] = useState<Food[]>([]); const [fetchError, setFetchError] = useState(null); const getJwt = async () => { const { data } = await axios.get(`${apiUrl}/jwt`); localStorage.setItem('token', data.token); setJwt(data.token); }; const getFoods = async () => { try { const { data } = await axios.get(`${apiUrl}/foods`); setFoods(data); setFetchError(null); } catch (err) { setFetchError(err.message); } }; return ( <> <section style={{ marginBottom: '10px' }}> <button onClick={() => getJwt()}>Get JWT</button> {jwt && ( <pre> <code>{jwt}</code> </pre> )} </section> <section> <button onClick={() => getFoods()}> Get Foods </button> <ul> {foods.map((food, i) => ( <li>{food.description}</li> ))} </ul> {fetchError && ( <p style={{ color: 'red' }}>{fetchError}</p> )} </section> </> ); } export default App;server.jsconst express = require('express'); const jwt = require('express-jwt'); const jsonwebtoken = require('jsonwebtoken'); const cors = require('cors'); const app = express(); app.use(cors()); const jwtSecret = 'secret123'; app.get('/jwt', (req, res) => { // JWTを生成する(今回は固定値で作成している) res.json({ token: jsonwebtoken.sign({ user: 'johndoe' }, jwtSecret) }); }); app.use(jwt({ secret: jwtSecret, algorithms: ['HS256'] })); const foods = [ { id: 1, description: 'burritos' }, { id: 2, description: 'quesadillas' }, { id: 3, description: 'churos' } ]; app.get('/foods', (req, res) => { res.json(foods); }); app.listen(3001); console.log('App running on localhost:3001');アプリケーション概要
server.jsには
/jwt
と/foods
という2つのエンドポイントを用意しています。
/jwt
はJWTを、/foods
はJSONデータを返します。
App.tsxではボタンを2つ用意し、それぞれボタンを押したタイミングでサーバーと通信するようにしています。
docker-compose up
を実行するとlocalhost:3000
でReactのアプリケーションが立ち上がり、
その後docker-compose exec front node src/server.js
を実行すると
localhost:3001
でNode.jsのアプリケーションが立ち上がります。
localhost:3000
にアクセスすると以下のような画面が表示されるはずです。いきなりGet Foodsボタンを押すと401エラーが表示され、
Get JWTでJWTを取得後、Get Foodsボタンを押すと、今度は正常に通信できるはずです。localStorageを確認してみる
Chromeの開発者ツール > Applicationを開くとlocalStorageに取得したtokenが設定されているのが確認できます。
localStorageに保存されているので、当然JavaScriptで取得することができます。
localStorage.getItem("token")この状態があまりよろしくないので修正していきます。
修正編
JWTをCookieに保存する
まず最初にserver.jsのJWTを発行する部分を修正していきます。
そもそもCookieの仕組みって?
図にすると以下のようになります。
(知ってるよって人はスキップしてください)サーバーからのレスポンスヘッダーにSet-Cookieという値が設定されていた場合、
クライアントのCookieにその値がセットされます。
以降そのサーバーとの通信ではセットされたCookieの値が付与されることになります。
フルスタックなフレームワークだとこういった仕組みを提供しているものが多いです。Set-Cookieヘッダーを付与するようにする
Cookieをセットするためには、サーバーのレスポンスにSet-Cookieヘッダーを含める必要があります。
Cookieを使うため、JWT取得時に以下のようにSet-Cookieヘッダーを含めてレスポンスを返すようにします。server.jsapp.get('/jwt', (req, res) => { const token = jsonwebtoken.sign({ user: 'johndoe' }, jwtSecret); // Set-Cookieヘッダーにtokenをセットする処理 res.cookie('token', token, { httpOnly: true }); res.json({ token }); });今回はHttpOnlyをtrueとしているため、
document.cookie
のようなJavaScriptからはアクセスできず、
基本的にはHTTP通信するときのみ参照できるようになっています。
(HttpOnlyを設定していない場合はセキュリティ的にはlocalStorageに保存する方法と大差ないかと思います)CORS対応する(ハマりポイント)
こちらは元記事にはなかった手順になります。
SPAではよくある構成かと思いますが、今回はlocalhost:3000
とlocalhost:3001
と
クロスオリジンでアプリケーションを起動しています。
クロスオリジンでCookieを使用する場合、いくつか設定が必要になります。server.jsのcorsを設定している部分を以下のように修正します。
server.jsapp.use(cors({ credentials: true, origin: "http://localhost:3000" }));これで
localhost:3000
で起動しているアプリケーションともCookieをやり取りすることができるようになります。また、App.tsxの方にも以下を追記します。
App.tsxaxios.defaults.withCredentials = true;今回はサーバーとの通信にaxiosを使用していますが、
axiosはデフォルトではCookieを使う設定になっていないので、
上記のようにwithCredentialsをtrueにすることで通信時にCookieを送信できるようになります。ここまで設定できたら再度アプリケーションを動かしてみましょう。
Get JWTボタンを押すとJWTが取得でき、開発者ツールで確認すると
Cookieにtokenが設定できているはずです。Cookieに設定されたtokenを検証するようにする
server.jsでApp.tsxからのリクエスト時にCookieに設定されたtokenを検証する処理を追記・修正します。
まずは新しく
cookie-parser
というライブラリを追加します。docker-compose exec front yarn add cookie-parser次にserver.jsを以下のように修正します。
server.jsconst cookieParser = require('cookie-parser'); // 略 app.use(cookieParser()); app.use(jwt({ secret: jwtSecret, algorithms: ['HS256'], getToken: req => req.cookies.token }));expressではcookie-parserを使用することでRequestに含まれるCookieを簡単に取得できるようになります。(
req.cookies.token
の部分)
また、検証もexpress-jwtを使うことで手軽にできるようになります。
getTokenで設定した関数でトークンを取得し、secretに設定した値を使って検証するといったような形です。次にApp.tsxの方で不要になったlocalStorageを使用する部分を削除しておきます。
この部分は参考記事ではこの対応はしていませんが、
localStorageとCookieどちらが使われているかわかりにくくなるかもしれないので念のために消しておきます。また、この修正でlocalStorageからJWTを読み込まないようにしたので
画面表示時にJWTが表示されることがなくなります。
HttpOnlyなCookieを使ったのでdocument.cookie
のようなJavaScriptからは取得できないようになっています。App.tsx// 略 // Bearerで送る必要がなくなったので不要 // axios.interceptors.request.use( // config => { // const { origin } = new URL(config.url as string); // const allowedOrigins = [apiUrl]; // const token = localStorage.getItem('token'); // if (allowedOrigins.includes(origin)) { // config.headers.authorization = `Bearer ${token}`; // } // return config; // }, // error => { // return Promise.reject(error); // } // ); // 略 function App() { // localStorageにセットしなくなったので不要 // const storedJwt = localStorage.getItem('token'); // 初期値はnullにしている const [jwt, setJwt] = useState<string | null>(null); // 略 const getJwt = async () => { const { data } = await axios.get(`${apiUrl}/jwt`); // localStorageにセットする必要がないので不要 // localStorage.setItem('token', data.token); setJwt(data.token); }; // 略以上でJWTをCookieに保存してサーバーとやりとりできるようになりました。
CSRF対策
localStorageはXSSによる攻撃を受けやすいのに対して、
Cookieの場合はCSRFによる攻撃を受けやすいと言われています。なのでCookieを使ったtokenのやり取りにはCSRFへの対策とセットで行なう必要があります。
サンプルアプリケーションのアップデート
server.jsにPOSTリクエストを受け付けるエンドポイントを追加します。
server.jsapp.post('/foods', (req, res) => { foods.push({ id: foods.length + 1, description: 'new food' }); res.json({ message: 'Food created!' }); });実装は適当ですが、新しくFoodを追加するようなAPIができたイメージですね。
成功した場合はFood created!というメッセージが返ってきます。また、App.tsxの方から、POSTリクエストを送信するように修正します。
App.tsxfunction App() { // 略 const [newFoodMessage, setNewFoodMessage] = useState(null); const createFood = async () => { try { const { data } = await axios.post(`${apiUrl}/foods`); setNewFoodMessage(data.message); setFetchError(null); } catch (err) { setFetchError(err.message); } }; // 略 return ( <> // 略 <section> <button onClick={() => createFood()}> Create New Food </button> {newFoodMessage && <p>{newFoodMessage}</p>} </section> </> ); }CSRFトークンを利用する
expressではcsurfというライブラリを使うことで
手軽にCSRF対策をすることができます。
まずはライブラリを追加します。docker-compose exec front yarn add cookie-parser
/csrf-token
にCSRFトークンを取得するエンドポイントを設定します。server.jsconst csrf = require('csurf') // 略 const csrfProtection = csrf({ cookie: true }); app.use(csrfProtection); app.get('/csrf-token', (req, res) => { res.json({ csrfToken: req.csrfToken() }); });これでCSRFトークンを発行できるようになったので、
App.tsxから利用するようにします。App.tsxfunction App() { // 略 useEffect(() => { const getCsrfToken = async () => { const { data } = await axios.get(`${apiUrl}/csrf-token`); axios.defaults.headers.post['X-CSRF-Token'] = data.csrfToken; }; getCsrfToken(); }, []); // 略 }画面表示時にCSRFトークンを取得し、axiosに設定するようにしています。
これでCSRFの対策ができました。※ちなみにCSRFトークン取得時にもCookieの値が検証されるので、403エラーが出る場合はJWT取得後に画面を更新してからCreateしてみてください。今回は一画面にすべて詰め込んでいるのでこんな感じになってしまいます。
最後に
Cookieの仕組みやCORSについての理解があればフロントエンドがReactからVueになろうが
バックエンドがExpressから他のFWになろうが知識を流用できるはずです。また、localStorageでもCookieでもXSS対策がされていない場合、難易度に差はあれど盗難のリスクが発生するのは同じなので
そもそもXSS対策がされているかどうかのチェックは必須といえるでしょう。クロスサイトスクリプティング(XSS)対策としてCookieのHttpOnly属性でどこまで安全になるのか
高い保守性やUXを保持しつつ安全なアプリケーションを目指していきたいですね。
参考
React Authentication: How to Store JWT in a Cookie
クロスサイトでCookieが設定できない場合に確認すること
CORSまとめ
express.jsのcors対応
Express cors middleware
MDN Web Docs Set-Cookie
クロスサイトスクリプティング(XSS)対策としてCookieのHttpOnly属性でどこまで安全になるのか
- 投稿日:2021-01-24T17:28:04+09:00
supabaseで簡単にログイン機能実装(Next.js+TypeScript)
はじめに
最近、supabaseという便利なものを見つけたので少し使ってみました!今回は認証だけ使っています。
今回のコード↓
https://github.com/NozomuTsuruta/next-supabase-auth-examplesupabaseとは
Firebaseのようにデータベースや認証を簡単に使えるサービスです。データベースはFirebaseはNoSQLなのに対し、supabaseはPostgreSQLが採用されています。個人的に新しく覚えることが少なくてわかりやすいです。比較的新しいのでFirebaseほどたくさんの機能はありませんが、今後どんどん追加されていくみたいです。
導入
まずはインストールします。
## npm npm install @supabase/supabase-js ## yarn yarn add @supabase/supabase-jshttps://app.supabase.io/にアクセスして、アカウントを作成し、新しいプロジェクトを作成します。
作成した後、そのプロジェクトを開き、SettingsのAPIのページに行くと、URLとAPI_KEYS(public)があると思います。
それをコピーして、
next.config.js
に追加します。next.config.jsmodule.exports = { env: { SUPABASE_URL: "[URL]", SUPABASE_KEY: "[PUBLIC_API_KEY]", }, };実際に使ってみる
supabaseの設定ファイル
下のように先ほど
next.config.js
に追加した値をここで使います。src/util/supabase.tsimport { createClient } from "@supabase/supabase-js"; export const supabase = createClient( process.env.SUPABASE_URL as string, process.env.SUPABASE_KEY as string );あとは、
src/util/supabase.ts
からsupabase
をインポートして簡単に使えます。Formコンポーネント
いろんなページで使う用に作ります。
src/components/Form.tsximport { FC } from "react"; type IProps = { onSubmit: any; buttonText: string; inputList: { name: string; ref: any; type: string; }[]; }; export const Form: FC<IProps> = ({ onSubmit, buttonText, inputList }) => { return ( <form onSubmit={onSubmit}> {inputList.map((props) => ( <label key={props.name}> <span>{props.name}</span> <input {...props} /> </label> ))} <button type="submit">{buttonText}</button> </form> ); };それぞれのページを簡単に作っていきます。react-hook-formを使っているので使い方がわからない方はみてみてください!
react-hook-formの使い方を解説 v6.13.0 useController追加!サインインページ
pages/signin.tsximport { NextPage } from "next"; import { useForm } from "react-hook-form"; import { Form } from "../components/Form"; import { supabase } from "../util/supabase"; type IForm = { email: string; password: string; }; const Signin: NextPage = () => { const { register, handleSubmit } = useForm<IForm>(); const handleSignin = ({ email, password }: IForm) => { supabase.auth.signIn({ email, password }); }; const inputList = [ { type: "email", name: "email", ref: register }, { type: "password", name: "password", ref: register }, ]; return ( <Form onSubmit={handleSubmit(handleSignin)} inputList={inputList} buttonText="サインイン" /> ); }; export default Signin;サインアップページ
サインアップ後にそのまま登録するか、メールアドレス確認するかどうかはsupabaseで設定できます。
src/pages/signup.tsximport { NextPage } from "next"; import { useForm } from "react-hook-form"; import { Form } from "../components/Form"; import { supabase } from "../util/supabase"; type IForm = { email: string; password: string; passwordConf: string; }; const Signup: NextPage = () => { const { register, handleSubmit } = useForm<IForm>(); const handleSignup = ({ email, password }: IForm) => { supabase.auth.signUp({ email, password }); }; const inputList = [ { type: "email", name: "email", ref: register }, { type: "password", name: "password", ref: register }, { type: "password", name: "passwordConf", ref: register }, ]; return ( <Form onSubmit={handleSubmit(handleSignup)} inputList={inputList} buttonText="サインアップ" /> ); }; export default Signup;パスワード再設定ページ
resetPasswordForEmail
の引数にとったメールアドレスに再設定用のメールが送信されます。src/pages/forgot.tsximport { NextPage } from "next"; import { useForm } from "react-hook-form"; import { Form } from "../components/Form"; import { supabase } from "../util/supabase"; type IForm = { email: string; }; const Forgot: NextPage = () => { const { register, handleSubmit } = useForm<IForm>(); const handleResetPassword = ({ email }: IForm) => { supabase.auth.api.resetPasswordForEmail(email); }; const inputList = [{ type: "email", name: "email", ref: register }]; return ( <Form onSubmit={handleSubmit(handleResetPassword)} inputList={inputList} buttonText="パスワード再設定メール送信" /> ); }; export default Forgot;ログイン後のページ
src/pages/index.tsximport { NextPage } from "next"; const Home: NextPage = () => { return <h1>Hello</h1>; }; export default Home;_app.tsx
onAuthStateChange
でログイン状態を監視できます。簡単にルーティングも実装しました。src/pages/_app.tsximport { AppProps } from "next/app"; import { supabase } from "../util/supabase"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; export default function App({ Component, pageProps }: AppProps) { const { pathname, push } = useRouter(); const [loading, setLoading] = useState(true); supabase.auth.onAuthStateChange((_, session) => { if (session?.user && (pathname === "/signin" || pathname === "/signup")) { push("/"); } else if (!session?.user && pathname !== "/signup") { push("/signin"); } }); useEffect(() => { (async () => { const user = supabase.auth.user(); if (user && (pathname === "/signin" || pathname === "/signup")) { await push("/"); } else if (!user && pathname !== "/signup") { await push("/signin"); } setLoading(false); })(); }, []); return ( <> {loading ? ( <h1>loading...</h1> ) : ( <> <button onClick={() => supabase.auth.signOut()}>ログアウト</button> <Component {...pageProps} /> </> )} </> ); }今回のコード↓
https://github.com/NozomuTsuruta/next-supabase-auth-example終わりに
ここまで読んでいただきありがとうございます!今回CSSを書かなかったので見た目はだいぶ酷いと思います?♂️
今回データベースの方は使いませんでしたが、とても便利なので使ってみてください!
- 投稿日:2021-01-24T17:08:06+09:00
[React] Reactの学習をします(2-4)OR マッパーによるデータベースの利用
Reactの学習をします(2-4)
引き続き、Reactの学習をしています。
前回の記事 : [React] Reactの学習をします(2-3)React Router を用いた複数ページ Web アプリの開発
教材
引き続き、likr さんが公開している「Reactチュートリアル2:レビューサイトを作ろう」という記事を教材に学習させて頂きます。
素晴らしい教材をありがとうございます。
成果物
このチュートリアル2につきましては、チュートリアルの通りに作成し既にNetlifyに公開させて頂いております。
こちらです → 日大文理ラーメンレビュー
学習日記
既に作成済ですが、少しずつチュートリアルの内容を読み返してみたいと思います。
※ 教材から箇条書き的に抜粋させて頂きます。
OR マッパーによるデータベースの利用
・次はデータベースを導入して、実際のデータをやり取りできるようにしていきましょう。
・ここからは、ローカルの開発環境からアクセスできる PostgreSQL のサーバーを用意しておく必要があります。
PostgreSQLを自分のPCにインストールしておきます。
PostgreSQL公式ページ : PostgreSQLのダウンロード
そして、review_app というデータベースを作成しました。
※ 私のPCはWindows10ですので、PowerShell にて下記コマンドを実行します。
CREATE DATABASE review_app;
・SQL で取り出した情報は JavaScript 等のプログラミング言語で扱いやすい状態になっていると便利でしょう。
・O/R マッパーは、SQL の組み立てやクエリ結果のオブジェクトへの変換を行うライブラリで、Node.js では Sequelize という有名な O/R マッパーがあります。
チュートリアルに書かれている通りに作業していきます。
Heroku による API サーバーの公開
・API サーバーは Heroku を、フロントエンドは Netlify を使って公開します。
※ heroku pg:push するとき、エラーが発生してうまくデータを送ることができませんでした。
※ PostgreSQL のバージョンの問題であるように思われたため、いくつかのバージョンで試行錯誤しました。
※ 最終的にバージョン11を利用することにしました。
現在、Heroku では、特に指定しないとバージョン12が導入されます。
→ Heroku PostgreSQL version-supportローカル側、Heroku側、ともにバージョン11にすることにしました。
Heroku でバージョンを指定して PostgreSQL を導入する方法については、こちらのブログの記事を参考にさせて頂きました。(ありがとうございます。)
→ [heroku]herokuのPostgreSQLでバージョンを指定するNetlify によるフロントエンドの公開
・続けてフロントエンドを Netlify で公開します。いくつかの設定ファイルを追加しましょう。
(つづく)
- 投稿日:2021-01-24T16:38:32+09:00
Nextjsプロジェクトのディレクトリ構成例をさらしてみる
はじめに
今までreact単体のSPA構成でやっていたものの、webpackとかbabelの設定を試行錯誤しながら構築するのが面倒になったので、これを機にNextjsでスパッと構築できるようにテンプレートを作成しています。
その過程でディレクトリ構成を整理したので一例として共有します。
ディレクトリ構成
project_root/ ├── docs/ ├── public/ ├── src/ │ ├── api/ │ │ ├── generated/ │ ├── components/ │ │ ├── atoms/ │ │ ├── molecules/ │ │ ├── organisms/ │ │ └── templates/ │ ├── foundations/ │ ├── hooks/ │ ├── lib/ │ ├── pages/ │ ├── stores/ │ ├── styles/ │ └── utils/ └── types/
- docs/
ドキュメントを配置する。 仕様書やOpenApi定義もここに配置する。
- public/
静的ファイルを配置する。 Nextjsで定義されたディレクトリ。see: https://nextjs.org/docs/basic-features/static-file-serving
- src/api/
WebApi等の外部APIのクライアントを配置する。 自動生成されたApiのラッパーを作る時もここに配置する。
- src/api/generated/
OpenApi Generator等で自動生成されたクライアントを配置する。
- src/components/
UIコンポーネントを配置する。 Atomic Designの分類に基づいてコンポーネント振り分ける。
- src/components/atoms/
部品となる汎用UIコンポーネントを配置する。 単体で存在できるUIならここに配置する。
- src/components/molecules/
複数のUIコンポーネントを組み合わせたUIコンポーネントを配置する。 特定のコンテキストに依存しないようして再利用を考慮する。 UI制御のロジックが必要であればhooksを提供する。
- src/components/organisms/
特定のコンテキストに依存したUIコンポーネントを配置する。 再利用はあまり考えなくてもいい。 storesやapiに密結合していてもいい。
- src/components/templates/
ページ全体のスケルトンになるUIコンポーネントを配置する。 ページのアクセス制御やレイアウト等を含んだコンポーネント。
- src/foundations/
UIに直接関係しない機能のみのコンポーネントを配置する。 デザインを含まなければ、汎用的でもコンテキストに密結合でもどちらでもいい。
- src/hooks/
汎用的なreact hooksユーティリティを配置。 特定のコンテキストに依存しないようにする。
- src/lib/
モジュール化されたロジックを配置する。 lib間は依存してもいい。 それ以外のレイヤーには依存しないようにする。
- src/pages/
Nextjsでルーティングされるページコンポーネントを配置する。see: https://nextjs.org/docs/routing/introduction
- src/stores/
グローバル状態を管理するhooksを配置する。
- src/styles/
全体のスタイルを配置する。 テーマや汎用的なスタイル要素を定義する。
- src/utils/
ユーティリティロジックを配置する。
- types/
typescriptのカスタム型定義を配置する。おわりに
あくまで一例なのでプロジェクトに合わせて足したり引いたりしてください。
ディレクトリの定義も自分はこうしているというだけなので一番しっくりくるようにいい塩梅でやってください。あと、Nextjsはいいぞ!
react単体だとCode Splittingとか色々頑張ってパフォーマンス調整をしていたのにNextjsは適当に作ってもだいたい速い。
あとSSGに方向を振ったおかげでSPA用のビルド環境として使えるのが強い。まだSSRのフレームワークとして認知されているので導入提案しても最初はえぇ・・・?みたいな反応をされることもあるかと思います。
そんな時は圧を込めてNextjsはビルドツールです!!!と言い張りましょう。
- 投稿日:2021-01-24T16:30:10+09:00
Laravel8 + React + web audio API で音を出す
目的
素人向けの記事がなかったので作成。
webで音声周りでやりたいことができたのでweb audio APIを使う。
この記事で公開されている「Web Audio APIで単一サウンドを再生」を行う。(※) サーバー周りをまだ試していないのでLaravelの解説はしない。あくまで動いたよ!という報告だけ。
前提
- Laravel8 + ReactでExample.jsを表示できたよ!という状態
手順
まずはnpmでインストール。しようとしたが、以下のようなエラーが出る。
$ npm install react-webaudionpm ERR! code ERESOLVE npm ERR! ERESOLVE unable to resolve dependency tree npm ERR! npm ERR! While resolving: XXXXXXXXXXXXXXXXX@undefined npm ERR! Found: react@16.14.0 npm ERR! node_modules/react npm ERR! dev react@"^16.2.0" from the root project npm ERR! npm ERR! Could not resolve dependency: npm ERR! peer react@"0.14.0" from react-webaudio@0.3.0-EXPERIMENTALLaravel uiのReactバージョンが16.14.0でweb Audioで要求されているバージョンが16.2.0だよ!ということらしい。
マイナーバージョンの差だし大丈夫でしょ!
動かなかったら考える理論で強制インストール。
ダメだった場合はバージョンを指定してReactをインストールし直しましょう。$ npm install --legacy-peer-deps react-webaudio今回は音を鳴らすだけなので上記の記事を参考に、少し改変して Example.jsを以下のように修正。
Example.jsimport React from 'react'; import ReactDOM from 'react-dom'; import {Button} from '@material-ui/core'; const audioController = (() => { const context = new AudioContext(); const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const channels = 2; const frameCount = audioCtx.sampleRate * 2.0; const buffer = audioCtx.createBuffer(channels, frameCount, audioCtx.sampleRate); for (let channel = 0; channel < channels; channel++) { const nowBuffering = buffer.getChannelData(channel); for (let i = 0; i < frameCount; i++) { nowBuffering[i] = Math.random() * 2 - 1; } } const source = audioCtx.createBufferSource(); source.buffer = buffer; source.connect(audioCtx.destination); source.start(); }); function Example() { return ( <div className="container"> <div className="row justify-content-center"> <div className="col-md-8"> <div className="card"> <Button onClick={audioController}>On</Button> </div> </div> </div> </div> ); } export default Example; if (document.getElementById('app')) { ReactDOM.render(<Example />, document.getElementById('app')); }コツは必ずアロー関数で宣言することである。
ビルドしたあとは画面に表示された「On」をクリックして「サーッ」と音が鳴ればOK。
色々できそうなAPIなので触って試してみたい。
- 投稿日:2021-01-24T16:02:43+09:00
5分で学ぶ React Hooks
この記事の目的
公式ページが Hooks がいかに素晴らしいかなどが書いてあって長いので、必要最低限な情報をまとめた。
これを読めば Hooks を知らない人でもレビューはできるようになるはず。(願望)Hooks とは
React 16.8 で追加された機能。
ステートフック、副作用フック、コンテキストフック、リデューサフックなどがある。Hooks のメリット
Class でゴリゴリ書かないといけないものが Function で書けるようになった。
シンプルに書ける、ボイラープレートを減らせる。主に使うメソッド
ステートフック(useState)
useStete.jsconst [a, setA] = useState(0)使い方
useState
の引数に初期値を渡す。
a
にはget
が、setA
にはset
の機能がある。副作用フック(useEffect)
useEffect.jsuseEffect(() => { a(); return () => { b(); }; }, [c]);
useEffect
の中のa()
がライフサイクルのcomponentDidMount
とcomponentDidUpdate
で実行される。
useEffect
の返り値のb()
がライフサイクルのcomponentWillUnmount
とcomponentDidUpdate
で実行される。
componentDidUpdate
はb()
→a()
の順番で実行する。
useEffect
の第二引数を指定しなかった場合、上記のタイミングで実行されるが、指定した場合は指定した値が変わっていない場合には実行されないようにできる。上記例であればc
の値が更新された場合にのみ実行されるようになる。パフォーマンス改善の為に使う。複雑な設計になった際に使うメソッド
ここから先はメソッドの存在と必要なタイミングについて軽く触れるだけ。
コンテキストフック(useContext)
useContext.jsconst value = useContext(MyContext);親コンポーネントで指定した値を子コンポーネントが
useContext
経由で取得できる。子コンポーネントに値をいちいち渡さなくて良くなる。
https://ja.reactjs.org/docs/hooks-reference.html#usecontextリデューサフック(useReducer)
useReducer.jsconst [state, dispatch] = useReducer(reducer, initialArg, init);
useStete
の変異種。setXXX
だとどんな値でも入れられてしまう為、値の変更方法を指定して意図せぬ値の変更を防ぐ為に使用する。
https://ja.reactjs.org/docs/hooks-reference.html#usereducerまとめ
前に Redux を使ったがその時よりもシンプルにかけていい感じ。
自分で実装したい人は公式を読みましょう。
https://reactjs.org/docs/hooks-intro.html
- 投稿日:2021-01-24T12:29:47+09:00
【React custom hooks】文字を順番に表示するカスタムフック
概要
本当はcssでやったほうがパフォーマンスはいいと思いますが、ちょっと面倒だったのでカスタムフックを用意してちゃちゃっと実装してみました。
完成形
コード
// カスタムフック const useDelayText = (delay, time, text) => { const [resultText, setResultText] = useState(''); // 無限ループに入っちゃうので、useEffectで制御 useEffect(() => { // 1文字ずつの表示時間を計算 const showIntervalTime = time / text.length; for (let index = 0; index < text.length; index++) { // 表示したい文字をスライス const t = text.slice(0, index + 1); // delayさせたいので、外側でsetTimeoutを入れる setTimeout(() => { // それぞれの文字数ごとにsetTimeoutでスライスした結果をsetしていく setTimeout(() => setResultText(t), showIntervalTime * (index + 1)); }, [delay]) } }, []); return resultText; } // 使い方 const First = memo(() => { const yusou = useDelayText(0, 1000, '車の輸送'); const tax = useDelayText(1000, 1000, '税込価格'); const sijou = useDelayText(2000, 1000, '市場最安値'); return ( <div className={styles.fv}> <p>{yusou}</p> <p className={styles.fv_yellow}>{tax}</p> <p className={styles.fv_red}>{sijou}</p> </div> ) });工夫点
はじめはdelayさせる予定はなかったんだけど、delayさせないと同時に文字出てくるわーってなりました。
そこで、delay用の引数をまずは用意して、そこからどうしようかなーって10秒ぐらい眺めていたら、
「もう1個外側に大きくsetTimeoutで囲えばよくね?」ってなってやってみたらうまく動きました。感想
汎用的に使えるかわかりませんし、cssで実装したほうがパフォーマンスはいいと思います。
そんなにパフォーマンスも意識せず、少ない文字数であればこっちでもいいかなーって気がしているので、
使ってみよう!って思って暗たらうれしいです!(LGTMおまちしてます)
- 投稿日:2021-01-24T12:22:31+09:00
React JSとaws-amplify/authでCognitoにユーザー登録する
Cognitoユーザープールの作成
AWSのコンソールからCognito→ユーザープールの管理と進みます。
ユーザープールの作成を選択し、作成するユーザプールの設定に移ります。
今回はユーザープールの設定をカスタマイズせずにデフォルトの設定で作成しました。
画像の「デフォルトを確認する」→「プールの作成」で大丈夫です。カスタマイズする場合は、パスワードの文字数などのルールや、ユーザーに紐づく属性の設定などができます。
アプリクライアントの作成
アプリケーションからCognitoにアクセスするためにはアプリクライアントが必要になります。
今回作成するReactアプリでもアプリクライアントIDを利用するため、アプリクライアントを作成します。先程作成したユーザープールを選択肢、左カラムの「アプリクライアント」を選択します。
まず適当な、「アプリクライアント名」を設定します。
次に、「クライアントシークレットを生成」を無効にします。Javascriptからユーザー登録を行う際はこの設定が必要です。
他はデフォルトのママそのまま画面一番下の「アプリクライアントの作成」ボタンで作成しました。アイデンティティプールの作成
少しわかりにくいですが、画面上部の反転している「フェデレーティッドアイデンティティ」リンクをクリックします
その後ID プール名には適当な名前を入れて、下部の認証プロバイダーエリアでCognitoを選択肢、先ほど作成したユーザープールとアプリクライアントIDを設定して「プールの作成」をします。
Reactでのプロジェクトの作成
新規プロジェクトを作成
$ npx create-react-app cognito-test --template typescript $ npm install --save @aws-amplify/auth $ npm install --save @material-ui/core $ cd cognito-test $ yarn start実装
index.tsxでベタベタと認証情報を直書きしていますが、環境変数い入れて取得するようにしましょう。
index.tsximport React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; import Auth from '@aws-amplify/auth'; Auth.configure({ identityPoolId: 'ap-northeast-1:d7712efb-c630-4ce5-a7b3-a43e50bc424c', region: 'ap-northeast-1', userPoolId: 'ap-northeast-1_7lh5yibbX', userPoolWebClientId: '77b77bvidr3lbemks4t8j5cfl7', }); ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') ); reportWebVitals();App.tsximport React from 'react'; import './App.css'; import {CardContent, CardActions, TextField, Button} from '@material-ui/core'; import { Auth } from '@aws-amplify/auth'; interface Props {} interface State { username: string; email: string; password: string; } class App extends React.Component<Props, State> { constructor(props: Props) { super(props); this.state = { username: "", email: "", password: "" }; } render() { const signup = async () => { const data = { username: this.state.username, password: this.state.password, attributes: { email: this.state.email } }; try { const { user } = await Auth.signUp(data); alert('success!') } catch (error) { console.log('error signing up:', error); } } const setUsername = (value: string) => { this.setState({ username: value }); } const setEmail = (value: string) => { this.setState({ email: value }); } const setPassword = (value: string) => { this.setState({ password: value }); } return ( <div className="App"> <CardContent> <TextField onChange={(e) => setUsername(e.target.value)} type="text" label="Username" placeholder="Username" margin="normal" /><br/> <TextField onChange={(e) => setEmail(e.target.value)} type="email" label="Email" placeholder="xxx@xx.xx" margin="normal" /><br/> <TextField onChange={(e) => setPassword(e.target.value)} type="password" label="Password" placeholder="Password" margin="normal" /> </CardContent> <CardActions style={{justifyContent: 'center'}}> <Button variant="contained" size="large" color="secondary" onClick={signup}>Login </Button> </CardActions> </div> ); } } export default App;アプリからユーザー登録
ブラウザで http://localhost:3000 を開くと作成した画面が表示されます。
ここから、ユーザー名、Email、パスワードを入力して「LOGIN」ボタンを押します。
パスワードのルールはCognitoのデフォルトで、英数大文字小文字、記号が必須で8文字以上となっています。
その後Cognitoでユーザープールの「ユーザーとグループ」を見るとユーザーが作成されています。
- 投稿日:2021-01-24T04:44:37+09:00
[React Native] react-native-draggable-gridviewのセルサイズ変更
0. 背景
react-native-draggable-gridviewを使うことで、ドラッグ可能なリスト形式のビューを表示できる(FlatListにドラッグ機能を追加したようなもの)
しかし、デフォルトではpropsでwidthしか指定できず、セルサイズを正方形でしか表示できない(widthは縦、横両サイズに反映される)1. 目的
react-native-draggable-gridviewのセルサイズを長方形で表示
→propsでheightを指定できるようにする2. 環境
- React : 16.8.6
- React Native : 0.63.4
3. 準備
react-native-draggable-gridviewのインストール
npm install react-native-draggable-gridview
react-native-responsive-screenのインストール(端末画面の比率でセルサイズを指定できる)
npm install react-native-responsive-screen
4. ソースコード変更
[プロジェクト名]/node_modules/react-native-draggable-gridview/src/index.tsx
を書き換える( ページ末尾に記載 )
→index.tsxの中身をページ末尾のソースコードで全て書き換える5. 変更前(デフォルト)
App.jsimport React from 'react'; import { View, Text, Alert } from 'react-native'; import { useState } from "react"; import GridView from 'react-native-draggable-gridview' import { widthPercentageToDP as wp, heightPercentageToDP as hp, } from 'react-native-responsive-screen'; function App() { const [data, setData] = useState([ { id: 0, name: 'AAA' }, { id: 1, name: 'BBB' }, { id: 2, name: 'CCC' }, { id: 3, name: 'DDD' }, { id: 4, name: 'EEE' } ]) return ( <> <GridView data={data} numColumns={2} delayLongPress={150} // width={wp('80%')} renderItem={item => ( <View style={{ flex: 1, margin: 1, justifyContent: 'center', backgroundColor: 'cyan' }}> <Text style={{ textAlign: 'center' }}>{item.name}</Text> </View> )} onPressCell={item => Alert.alert(item.name)} onReleaseCell={item => setData(item)} keyExtractor={item => item.id} /> </> ) } export default App;
numColumns={2}, width指定なし numColumns={2}, width={wp('80%')} セルが正方形で描画される.
widthを指定した場合、縦横両方のサイズに反映される.6. 変更後
App.jsimport React from 'react'; import { View, Text, Alert } from 'react-native'; import { useState } from "react"; import GridView from 'react-native-draggable-gridview' import { widthPercentageToDP as wp, heightPercentageToDP as hp, } from 'react-native-responsive-screen'; function App() { const [data, setData] = useState([ { id: 0, name: 'AAA' }, { id: 1, name: 'BBB' }, { id: 2, name: 'CCC' }, { id: 3, name: 'DDD' }, { id: 4, name: 'EEE' } ]) return ( <> <GridView data={data} numColumns={2} // numColumns={1} delayLongPress={150} height={hp('20%')} // weight={hp('80%')} renderItem={(item, index) => ( <View style={{ flex: 1, margin: 1, justifyContent: 'center', backgroundColor: 'cyan' }}> <Text style={{ textAlign: 'center' }}>{item.name}</Text> </View> )} onPressCell={item => Alert.alert(item.name)} onReleaseCell={item => setData(item)} keyExtractor={item => item.id} /> </> ) } export default App;
numColumns={2}, height={hp('20%')} numColumns={1}, height={hp('20%')}, weight={hp('80%')} heightを設定することで縦方向だけサイズを変更可能.
また、heightと同時にweightも設定可能.7. ソースコード
index.tsx/** * react-native-draggable-gridview */ import React, { memo, useRef, useState, useCallback } from 'react' import { Dimensions, LayoutRectangle } from 'react-native' import { View, ViewStyle, TouchableOpacity } from 'react-native' import { Animated, Easing, EasingFunction } from 'react-native' import { ScrollView, ScrollViewProps } from 'react-native' import { PanResponder, PanResponderInstance } from 'react-native' import _ from 'lodash' const { width: screenWidth, height: screenHeight} = Dimensions.get('window') interface GridViewProps extends ScrollViewProps { numColumns?: number containerMargin?: ContainerMargin width?: number height?: number data: any[] activeOpacity?: number delayLongPress?: number selectedStyle?: ViewStyle animationConfig?: AnimationConfig keyExtractor?: (item: any) => string renderItem: (item: any, index?: number) => JSX.Element renderLockedItem?: (item: any, index?: number) => JSX.Element locked?: (item: any, index?: number) => boolean onBeginDragging?: () => void onPressCell?: (item: any, index?: number) => void onReleaseCell?: (data: any[]) => void onEndAddAnimation?: (item: any) => void onEndDeleteAnimation?: (item: any) => void } interface AnimationConfig { isInteraction?: boolean useNativeDriver: boolean easing?: EasingFunction duration?: number delay?: number } interface ContainerMargin { top?: number bottom?: number left?: number right?: number } interface Point { x: number y: number } interface Item { item: any pos: Animated.ValueXY opacity: Animated.Value } interface State { scrollView?: ScrollView frame?: LayoutRectangle contentOffset: number numRows?: number cellWidth?: number cellHeight?: number grid: Point[] items: Item[] animation?: Animated.CompositeAnimation animationId?: number // Callback ID for requestAnimationFrame startPoint?: Point // Starting position when dragging startPointOffset?: number // Offset for the starting point for scrolling move?: number // The position for dragging panResponder?: PanResponderInstance } const GridView = memo((props: GridViewProps) => { const { data, keyExtractor, renderItem, renderLockedItem, locked, onBeginDragging, onPressCell, onReleaseCell, onEndAddAnimation, onEndDeleteAnimation, ...rest } = props const numColumns = rest.numColumns || 1 const top = rest.containerMargin?.top || 0 const bottom = rest.containerMargin?.bottom || 0 const left = rest.containerMargin?.left || 0 const right = rest.containerMargin?.right || 0 const width = rest.width || screenWidth const height = rest.height || screenHeight // 追加 const activeOpacity = rest.activeOpacity || 0.5 const delayLongPress = rest.delayLongPress || 500 const selectedStyle = rest.selectedStyle || { shadowColor: '#000', shadowRadius: 8, shadowOpacity: 0.2, elevation: 10, } const [selectedItem, setSelectedItem] = useState<Item>(null) const self = useRef<State>({ contentOffset: 0, grid: [], items: [], startPointOffset: 0, }).current //-------------------------------------------------- Preparing const prepare = useCallback(() => { if (!data) return // console.log('[GridView] prepare') const diff = data.length - self.grid.length if (Math.abs(diff) == 1) { prepareAnimations(diff) } else if (diff != 0) { onUpdateGrid() } else if ( _.findIndex(self.items, (v: Item, i: number) => v.item != data[i]) >= 0 ) { onUpdateData() } }, [data, selectedItem]) const onUpdateGrid = useCallback(() => { // console.log('[GridView] onUpdateGrid') const cellWidth = (width - left - right) / numColumns self.cellWidth = cellWidth self.cellHeight = height self.numRows = Math.ceil(data.length / numColumns) const grid: Point[] = [] for (let i = 0; i < data.length; i++) { const x = (i % numColumns) * cellWidth const y = Math.floor(i / numColumns) * height grid.push({ x, y }) } self.grid = grid onUpdateData() }, [data, selectedItem]) const onUpdateData = useCallback(() => { // console.log('[GridView] onUpdateData') // Stop animation stopAnimation() const { grid } = self self.items = data.map((item, i) => { const pos = new Animated.ValueXY(grid[i]) const opacity = new Animated.Value(1) const item0: Item = { item, pos, opacity } // While dragging if (selectedItem && selectedItem.item == item) { const { x: x0, y: y0 } = selectedItem.pos const x = x0['_value'] const y = y0['_value'] if (!self.animation) pos.setValue({ x, y }) selectedItem.item = item selectedItem.pos = pos selectedItem.opacity = opacity self.startPoint = { x, y } } return item0 }) }, [data, selectedItem]) const prepareAnimations = useCallback( (diff: number) => { const config = rest.animationConfig || { easing: Easing.ease, duration: 300, useNativeDriver: true, } const grid0 = self.grid const items0 = self.items onUpdateGrid() const { grid, items } = self const diffItem: Item = _.head( _.differenceWith( diff < 0 ? items0 : items, diff < 0 ? items : items0, (v1: Item, v2: Item) => v1.item == v2.item ) ) // console.log('[GridView] diffItem', diffItem) const animations = (diff < 0 ? items0 : items).reduce((prev, curr, i) => { // Ignore while dragging if (selectedItem && curr.item == selectedItem.item) return prev let toValue: { x: number; y: number } if (diff < 0) { // Delete const index = _.findIndex(items, { item: curr.item }) toValue = index < 0 ? grid0[i] : grid[index] if (index < 0) { prev.push(Animated.timing(curr.opacity, { toValue: 0, ...config })) } } else { // Add const index = _.findIndex(items0, { item: curr.item }) if (index >= 0) curr.pos.setValue(grid0[index]) toValue = grid[i] if (diffItem.item == curr.item) { curr.opacity.setValue(0) prev.push(Animated.timing(curr.opacity, { toValue: 1, ...config })) } } // Animation for position prev.push(Animated.timing(curr.pos, { toValue, ...config })) return prev }, []) if (diff < 0) { self.items = items0 self.grid = grid0 } // Stop animation stopAnimation() self.animation = Animated.parallel(animations) self.animation.start(() => { // console.log('[Gird] end animation') self.animation = undefined if (diff < 0) { self.items = items self.grid = grid onEndDeleteAnimation && onEndDeleteAnimation(diffItem.item) } else { onEndAddAnimation && onEndAddAnimation(diffItem.item) } }) }, [data, selectedItem] ) const stopAnimation = useCallback(() => { if (self.animation) { self.animation.stop() self.animation = undefined } }, []) prepare() //-------------------------------------------------- Handller const onLayout = useCallback( ({ nativeEvent: { layout }, }: { nativeEvent: { layout: LayoutRectangle } }) => (self.frame = layout), [] ) const animate = useCallback(() => { if (!selectedItem) return const { move, frame, cellWidth } = self const s = cellWidth / 2 let a = 0 if (move < top + s) { a = Math.max(-s, move - (top + s)) // above } else if (move > frame.height - bottom - s) { a = Math.min(s, move - (frame.height - bottom - s)) // below } a && scroll((a / s) * 10) // scrolling self.animationId = requestAnimationFrame(animate) }, [selectedItem]) const scroll = useCallback( (offset: number) => { const { scrollView, cellWidth, numRows, frame, contentOffset } = self const max = cellWidth * numRows - frame.height + top + bottom const offY = Math.max(0, Math.min(max, contentOffset + offset)) const diff = offY - contentOffset if (Math.abs(diff) > 0.2) { // Set offset for the starting point of dragging self.startPointOffset += diff // Move the dragging cell const { x: x0, y: y0 } = selectedItem.pos const x = x0['_value'] const y = y0['_value'] + diff selectedItem.pos.setValue({ x, y }) reorder(x, y) scrollView.scrollTo({ y: offY, animated: false }) } }, [selectedItem] ) const onScroll = useCallback( ({ nativeEvent: { contentOffset: { y }, }, }: { nativeEvent: { contentOffset: { y: number } } }) => (self.contentOffset = y), [] ) const onLongPress = useCallback( (item: string, index: number, position: Point) => { if (self.animation) return // console.log('[GridView] onLongPress', item, index) self.startPoint = position self.startPointOffset = 0 setSelectedItem(self.items[index]) onBeginDragging && onBeginDragging() }, [onBeginDragging] ) const reorder = useCallback( (x: number, y: number) => { if (self.animation) return const { numRows, cellWidth, cellHeight, grid, items } = self let colum = Math.floor((x + cellWidth / 2) / cellWidth) colum = Math.max(0, Math.min(numColumns, colum)) let row = Math.floor((y + cellHeight / 2) / cellHeight) row = Math.max(0, Math.min(numRows, row)) const index = Math.min(items.length - 1, colum + row * numColumns) const isLocked = locked && locked(items[index].item, index) const itemIndex = _.findIndex(items, (v) => v.item == selectedItem.item) if (isLocked || itemIndex == index) return swap(items, index, itemIndex) const animations = items.reduce((prev, curr, i) => { index != i && prev.push( Animated.timing(curr.pos, { toValue: grid[i], easing: Easing.ease, duration: 200, useNativeDriver: true, }) ) return prev }, [] as Animated.CompositeAnimation[]) self.animation = Animated.parallel(animations) self.animation.start(() => (self.animation = undefined)) }, [selectedItem] ) //-------------------------------------------------- PanResponder const onMoveShouldSetPanResponder = useCallback((): boolean => { if (!self.startPoint) return false const shoudSet = selectedItem != null if (shoudSet) { // console.log('[GridView] onMoveShouldSetPanResponder animate') animate() } return shoudSet }, [selectedItem]) const onMove = useCallback( (event, { moveY, dx, dy }: { moveY: number; dx: number; dy: number }) => { const { startPoint, startPointOffset, frame } = self self.move = moveY - frame.y let { x, y } = startPoint // console.log('[GridView] onMove', dx, dy, moveY, x, y) x += dx y += dy + startPointOffset selectedItem.pos.setValue({ x, y }) reorder(x, y) }, [selectedItem] ) const onRelease = useCallback(() => { if (!self.startPoint) return // console.log('[GridView] onRelease') cancelAnimationFrame(self.animationId) self.animationId = undefined self.startPoint = undefined const { grid, items } = self const itemIndex = _.findIndex(items, (v) => v.item == selectedItem.item) itemIndex >= 0 && Animated.timing(selectedItem.pos, { toValue: grid[itemIndex], easing: Easing.out(Easing.quad), duration: 200, useNativeDriver: true, }).start(onEndRelease) }, [selectedItem]) const onEndRelease = useCallback(() => { // console.log('[GridView] onEndRelease') onReleaseCell && onReleaseCell(self.items.map((v) => v.item)) setSelectedItem(undefined) }, [onReleaseCell]) //-------------------------------------------------- Render const _renderItem = useCallback( (value: Item, index: number) => { // Update pan responder if (index == 0) { self.panResponder = PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => false, onMoveShouldSetPanResponder: onMoveShouldSetPanResponder, onMoveShouldSetPanResponderCapture: onMoveShouldSetPanResponder, onShouldBlockNativeResponder: () => false, onPanResponderTerminationRequest: () => false, onPanResponderMove: onMove, onPanResponderRelease: onRelease, onPanResponderEnd: onRelease, }) } const { item, pos, opacity } = value // console.log('[GridView] renderItem', index, id) const { cellWidth, grid } = self const p = grid[index] const isLocked = locked && locked(item, index) const key = (keyExtractor && keyExtractor(item)) || (typeof item == 'string' ? item : `${index}`) let style: ViewStyle = { position: 'absolute', width: cellWidth, height: cellHeight, } if (!isLocked && selectedItem && value.item == selectedItem.item) style = { zIndex: 1, ...style, ...selectedStyle } return isLocked ? ( <View key={key} style={[style, { left: p.x, top: p.y }]}> {renderLockedItem(item, index)} </View> ) : ( <Animated.View {...self.panResponder.panHandlers} key={key} style={[ style, { transform: pos.getTranslateTransform(), opacity, }, ]} > <TouchableOpacity style={{ flex: 1 }} activeOpacity={activeOpacity} delayLongPress={delayLongPress} onLongPress={() => onLongPress(item, index, p)} onPress={() => onPressCell && onPressCell(item, index)} > {renderItem(item, index)} </TouchableOpacity> </Animated.View> ) }, [selectedItem, renderLockedItem, renderItem] ) // console.log('[GridView] render', data.length) return ( <ScrollView {...rest} ref={(ref) => (self.scrollView = ref)} onLayout={onLayout} onScroll={onScroll} scrollEnabled={!selectedItem} scrollEventThrottle={16} contentContainerStyle={{ marginTop: top, marginBottom: bottom, marginLeft: left, marginRight: right, }} > <View style={{ height: top + self.numRows * self.cellHeight + bottom, }} /> {self.items.map((v, i) => _renderItem(v, i))} </ScrollView> ) }) /** * swap * @param array * @param i * @param j */ const swap = (array: any[], i: number, j: number) => array.splice(j, 1, array.splice(i, 1, array[j])[0]) export default GridView
- 投稿日:2021-01-24T02:53:34+09:00
Reactをnpxで作成する時気をつけること
- 投稿日:2021-01-24T01:15:45+09:00
React + Contentful で「おみくじアプリ」を作ってみる
はじめに
「Vueはちょくちょく使ってるけど、React触ったことないんだよなぁ」
「Contentful…なんだか今後使う事ありそうだし、使ってみたいなぁ」
そんなフロントエンジニアが、React + Contentful で "おみくじアプリ" を作った際の備忘録です。
やる事
- React導入
- Contentful導入
- つなぎ込み
- 実装
React導入
公式のドキュメント通り
npx create-react-app react-omikujiで環境が出来ます。(爆速!最高!)
cd react-omikujiで環境に移動して
npm startでローカル環境が起動します。
新しい React アプリを作る - React公式ドキュメントContentful導入
サインアップ
公式ページからサインアップします。
freeだとユーザー数やAPIコール数に制限がありますが、一旦freeにて。
実案件で使う際は課金が必須になると思います。スペース作成
今回は omikuji というIDでスペースを作成します。
おみくじ結果の登録
情報として以下が必要になります。
- タイトル (例. 大吉)
- 本文 (例. 最高にハッピーな1日になるでしょう)
なので、上記をコンテントモデルに追加します。
Field IDは
- タイトル => title
- 本文 => description
とします。
続いて、おみくじの結果が複数必要になるので登録していきます。
このような形で6つ、結果を登録します。
つなぎ込み
Contentfulの公式にReactでContentfulを使うチュートリアルがあるので、そちらを参考にReactでContentfulが使えるようつなぎ込みます。
Getting started with React and Contentful - Contentful公式ドキュメント認証
https://graphql.contentful.com/content/v1/spaces/[YOUR_SPACE_ID]/explore?access_token=[YOUR_ACCESS_TOKEN]
上記に自身のスペースIDとアクセストークンを置き換えアクセスします。
するとGraphiQLの画面になるので、GraphQLクエリを以下の形式で記述します。{ omikujiCollection { items { title description } } }エラーがなければ認証完了です。
ReactでContentfulGraphQLを使用する
App.jsを以下に書き換えます。
App.jsimport {useState, useEffect} from "react"; import './App.css'; const query = ` { omikujiCollection { items { title description } } } ` function App() { const [page, setPage] = useState(null); useEffect(() => { window .fetch(`https://graphql.contentful.com/content/v1/spaces/[YOUR_SPACE_ID]/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer [YOUR_ACCESS_TOKEN]", }, body: JSON.stringify({ query }), }) .then((response) => response.json()) .then(({ data, errors }) => { if (errors) { console.error(errors); } setPage(data.omikujiCollection.items[0]); }); }, []); if (!page) { return "Loading..."; } return ( <div className="App"> <header className="App-header"> <p>{page.title}</p> <p>{page.description}</p> </header> </div> ); } export default App;これで
npm start
をすれば表示されたはずです。
記述の通り0番目の内容が表示されていると思います。
ここまでエラーがなければつなぎ込みは完了です。実装
仕様の確認
まずどのような仕様のおみくじにするのか確認です。
- トップページ(
react-omikuji/
)で「今日の運勢を占う」ボタンを押下すると、react-omikuji/result
ページに遷移し、結果が表示される- ランダムで結果が表示される
上記が問題なければなんでも良しとします。
ルーティング設定
ページ遷移の挙動を実現するためにreact-router-domをインストールします。
npm i react-router-domコンポーネント追加
トップページで使う
Button
と、結果ページのResult
のコンポーネントを用意します。components/Button.jsimport React from "react"; import { Link } from 'react-router-dom' export default class Button extends React.Component { render() { return ( <Link to="/result">{this.props.text}</Link> ) } }components/Result.jsimport React from "react"; export default class Result extends React.Component { render() { return ( <div> <p>{this.props.title}</p> <p>{this.props.description}</p> <a href="/react-omikuji/">トップに戻る</a> </div> ) } }実装
App.jsを以下に書き換えます。
App.jsimport {useState, useEffect} from "react"; import { BrowserRouter as Router, Route } from 'react-router-dom'; import Button from './components/Button'; import Result from './components/Result'; import './App.css'; const query = ` { omikujiCollection { items { title description } } } ` function App() { const [page, setPage] = useState(null); useEffect(() => { window .fetch(`https://graphql.contentful.com/content/v1/spaces/[YOUR_SPACE_ID]/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer [YOUR_ACCESS_TOKEN]", }, body: JSON.stringify({ query }), }) .then((response) => response.json()) .then(({ data, errors }) => { if (errors) { console.error(errors); } const number = Math.floor( Math.random() * ((data.omikujiCollection.items.length - 1) + 1 - 0) ) + 0 setPage(data.omikujiCollection.items[number]); }); }, []); if (!page) { return "Loading..."; } return ( <div className="App"> <Router basename='/react-omikuji/'> <Route exact path='/' render={() => <Button text='今日の運勢を占う'/>} /> <Route exact path='/result' render={() => <Result title={page.title} description={page.description}/>} /> </Router> </div> ); } export default App;ルートディレクトリ変更
トップページを
react-omikuji/
とするためにpackage.jsonに以下を追記します。package.json"homepage": "/react-omikuji/"完成
npm start
でローカル確認が問題なければ、npm run build
でビルドされます。あとがき
「いやいや、おみくじ判定のタイミング!」
「aタグで戻るんかい!」
などなどありますが、あくまで仕様が
- トップページ(
react-omikuji/
)で「今日の運勢を占う」ボタンを押下すると、react-omikuji/result
ページに遷移し、結果が表示される- ランダムで結果が表示される
上記が問題なければなんでも良しとします。
という事で…
トークン丸見え問題・APIコール数問題などなどがあるので、ヘッドレスCMS使うならSSG一択なんでしょうかね…
リンク
新しい React アプリを作る - React公式ドキュメント
Getting started with React and Contentful - Contentful公式ドキュメント