20210124のReactに関する記事は13件です。

アプリを一定時間操作しなかったときに認証画面を表示する[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]);

lockupdateTimerunlock という関数を用意し、それぞれuseCallbackによってメモ化しています。
lockは単純にロック状態に変更(isLockedtrueに)する処理、
updateTimerは何かしら操作が行われた際に呼ばれタイマーを更新する処理、
unlockはロック状態を解除(isLockedfalseに)し、タイマーをまたセットする処理です。
メモ化しているので、第二引数に依存する変数をいれることによってきっちりと各関数自体の更新がかかるようにします(また、その依存関係によってこの定義順になっています)。

ちなみに、このままではコンポーネントが最初にマウントされた際にタイマーがセットされません。
要件次第ですが、最初から何も操作していない場合にも一定時間後にロックする場合、以下のように 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.tsx
  const [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.tsx
  const 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-authentication
LockScreenProvider.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画面はセキュリティのためかスクショできず)
ロック解除 - Android
iOS パスコード
ロック解除 - iOS
iOS Touch ID
ロック解除 - iOS
このような認証画面が表示され、認証が成功するとロック画面が消えるようになりました。
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.tsx
import { 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.tsx
const STORAGE_KEY = "APP_LOCKED";

/* 省略 */

  // ロック状態
  const [isLockedStr, setIsLockedStr] = useStorage(STORAGE_KEY, "false");
  const setIsLocked = useCallback((isLocked) => {
    setIsLockedStr(isLocked.toString());
  }, []);
  const isLocked = isLockedStr === "true";
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

フロントサーバ、バックサーバの分離された構成でミニマムなサービスを作る。

はじめに

本記事はモノリシックでない分離されたアーキテクチャ(マイクロサービス)について理解を深めるため、
ミニマムなアプリを作ってみたときのまとめ記事です。
※当方初心者のため、間違いありましたら是非ともご指摘お願いいたします。

著者について

以下著者のスペック

  • エンジニア一年目
  • WebアプリといえばMVCしか知らない
  • マイクロサービス?なにそれおいしいの

背景

基本情報などの勉強中、よくこんな図が出てきて混乱していました。

image.png

  • Webアプリのアーキテクチャ?、MVCしかしらんけど?
  • アプリのサーバって一つだけじゃないの?
  • でもReactとかは独立してサーバーが立っているぽい、、
  • どうやってバックエンドと連携させるんやろ、、?

このような疑問を持った私は、「わかんないなら触ってみればいいじゃん!」と意気込み、
フロントエンドとバックエンドの機能をサーバごとに切り離したミニマムなアプリを作ろうと決めました。

開発

概要

画面を担当するフロントサーバとロジックを担当するバックサーバの二つのサーバを立てて、
インターネットのニュースを検索できるアプリを作る。

環境

  • Node.js v14.15.1

    • サーバサイドのJavaScript
    • こちらの記事が非常にわかりやすくおすすめ
    • 今回は超簡単なロジックを実装するのみに使う
  • Express v4.17.1

    • Node.jsで動く軽量なWebフレームワーク
    • Webサーバを立てるのも非常に簡単
    • 今回はAPIのルーティング等に使う
  • Yarn v1.22.4

    • npmと互換性があるパッケージ管理システム
    • 一度インストールしたパッケージをキャッシュするためインストールが高速

目標

画像のような簡単なニュース検索アプリを作る。
検索条件を入れ、「Search」ボタンを押下すれば、検索条件にヒットするニュースを検索する。
image.png

構成

本アプリの構成を以下画像に示します。
image.png

重要であるのはフロントサーバとバックサーバを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.js
const 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.js
    const 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

※コードレビュー歓迎

終わりに

業務でも趣味でもモノリシックなアーキテクチャしか触ったことがなく、
ツイッター上でマイクロサービスなどの用語を理解するのに時間がかかりました。
※現在は完全に理解した程度

技術理解のために実際に触れてみるのはやはり強いですね、、、
本記事がどなたかのお役に立てれば幸いです。
※間違いありましたら是非ご指摘お願いいたします。

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

Reactでの認証時にJWTをCookieに設定する方法

SPAでの認証といえばJWTを使うことが多いと思いますが、
localStorageに保存するとセキュリティリスクが高いとかで、
CookieにHttpOnlyな値として保存するのが良いとしばしば言われることもあります。
今回はReact × ExpressでJWTをCookieに保存する具体的な方法を紹介します。

(そもそもJWTを使うべきかとか、localStorageを使うことのリスクなどについては要件次第なのであまり言及しません)

調査にあたっては以下の記事を参考にしました。
React Authentication: How to Store JWT in a Cookie

記事の方法そのままでは自分の環境では上手くいかなかったので、ハマりポイントも含めて手順を解説します。

最終的に出来上がったもの

動作環境

以下のDockerイメージを使用して挙動を確認しました。
node:15.5.1-alpine3.12

準備編

まずはlocalStorageにJWTを保存して動くサンプルアプリケーションを用意します。
上記の参考記事を見てもらっても良いですが、
こちらで用意した以下のリポジトリを見てもらっても良いです。
本記事ではこちらに準じて進めます。

Reactの部分だけTypeScriptを使用 + Dockerを使った構成
https://github.com/Kanatani28/jwt-how-to-use

(ちなみに自前でプロジェクトを作成したい場合はcreate-react-appでプロジェクトを作成して、各種ライブラリをインストールしてください。)

ソースコードは以下のようになっています。

App.tsx
import 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.js
const 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ボタンを押すと、今度は正常に通信できるはずです。

  • JWTなしで通信
    スクリーンショット 2021-01-21 22.37.22.png

  • JWTありで通信
    スクリーンショット 2021-01-21 22.37.33.png

localStorageを確認してみる

Chromeの開発者ツール > Applicationを開くとlocalStorageに取得したtokenが設定されているのが確認できます。

スクリーンショット 2021-01-21 23.38.45.png

localStorageに保存されているので、当然JavaScriptで取得することができます。

localStorage.getItem("token")

この状態があまりよろしくないので修正していきます。

修正編

JWTをCookieに保存する

まず最初にserver.jsのJWTを発行する部分を修正していきます。

そもそもCookieの仕組みって?

図にすると以下のようになります。
(知ってるよって人はスキップしてください)

スクリーンショット 2021-01-22 17.19.04.png

サーバーからのレスポンスヘッダーにSet-Cookieという値が設定されていた場合、
クライアントのCookieにその値がセットされます。
以降そのサーバーとの通信ではセットされたCookieの値が付与されることになります。
フルスタックなフレームワークだとこういった仕組みを提供しているものが多いです。

Set-Cookieヘッダーを付与するようにする

Cookieをセットするためには、サーバーのレスポンスにSet-Cookieヘッダーを含める必要があります。
Cookieを使うため、JWT取得時に以下のようにSet-Cookieヘッダーを含めてレスポンスを返すようにします。

server.js
app.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:3000localhost:3001
クロスオリジンでアプリケーションを起動しています。
クロスオリジンでCookieを使用する場合、いくつか設定が必要になります。

server.jsのcorsを設定している部分を以下のように修正します。

server.js
app.use(cors({
  credentials: true,
  origin: "http://localhost:3000"
}));

これでlocalhost:3000で起動しているアプリケーションともCookieをやり取りすることができるようになります。

また、App.tsxの方にも以下を追記します。

App.tsx
axios.defaults.withCredentials = true;

今回はサーバーとの通信にaxiosを使用していますが、
axiosはデフォルトではCookieを使う設定になっていないので、
上記のようにwithCredentialsをtrueにすることで通信時にCookieを送信できるようになります。

ここまで設定できたら再度アプリケーションを動かしてみましょう。
Get JWTボタンを押すとJWTが取得でき、開発者ツールで確認すると
Cookieにtokenが設定できているはずです。

スクリーンショット 2021-01-24 17.47.38.png

Cookieに設定されたtokenを検証するようにする

server.jsでApp.tsxからのリクエスト時にCookieに設定されたtokenを検証する処理を追記・修正します。

まずは新しくcookie-parserというライブラリを追加します。

docker-compose exec front yarn add cookie-parser

次にserver.jsを以下のように修正します。

server.js
const 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.js
app.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.tsx
function 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.js
const 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.tsx
function 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してみてください。今回は一画面にすべて詰め込んでいるのでこんな感じになってしまいます。

スクリーンショット 2021-01-24 17.26.40.png

最後に

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属性でどこまで安全になるのか

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

supabaseで簡単にログイン機能実装(Next.js+TypeScript)

はじめに

最近、supabaseという便利なものを見つけたので少し使ってみました!今回は認証だけ使っています。

今回のコード↓
https://github.com/NozomuTsuruta/next-supabase-auth-example

参考:https://app.supabase.io/

supabaseとは

Firebaseのようにデータベースや認証を簡単に使えるサービスです。データベースはFirebaseはNoSQLなのに対し、supabaseはPostgreSQLが採用されています。個人的に新しく覚えることが少なくてわかりやすいです。比較的新しいのでFirebaseほどたくさんの機能はありませんが、今後どんどん追加されていくみたいです。

導入

まずはインストールします。

## npm
npm install @supabase/supabase-js

## yarn
yarn add @supabase/supabase-js

https://app.supabase.io/にアクセスして、アカウントを作成し、新しいプロジェクトを作成します。
作成した後、そのプロジェクトを開き、SettingsのAPIのページに行くと、URLとAPI_KEYS(public)があると思います。
image.png

それをコピーして、next.config.jsに追加します。

next.config.js
module.exports = {
  env: {
    SUPABASE_URL: "[URL]",
    SUPABASE_KEY: "[PUBLIC_API_KEY]",
  },
};

実際に使ってみる

ディレクトリ構成↓
image.png

supabaseの設定ファイル

下のように先ほどnext.config.jsに追加した値をここで使います。

src/util/supabase.ts
import { 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.tsx
import { 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.tsx
import { 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で設定できます。
image.png

src/pages/signup.tsx
import { 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.tsx
import { 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.tsx
import { NextPage } from "next";

const Home: NextPage = () => {
  return <h1>Hello</h1>;
};

export default Home;

_app.tsx

onAuthStateChangeでログイン状態を監視できます。簡単にルーティングも実装しました。

src/pages/_app.tsx
import { 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を書かなかったので見た目はだいぶ酷いと思います?‍♂️
今回データベースの方は使いませんでしたが、とても便利なので使ってみてください!

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

[React] Reactの学習をします(2-4)OR マッパーによるデータベースの利用

Reactの学習をします(2-4)

引き続き、Reactの学習をしています。

前回の記事 : [React] Reactの学習をします(2-3)React Router を用いた複数ページ Web アプリの開発

教材

Reactチュートリアル2:レビューサイトを作ろう

引き続き、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 で公開します。いくつかの設定ファイルを追加しましょう。

(つづく)

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

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はビルドツールです!!!と言い張りましょう。

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

Laravel8 + React + web audio API で音を出す

目的

素人向けの記事がなかったので作成。
webで音声周りでやりたいことができたのでweb audio APIを使う。
この記事で公開されている「Web Audio APIで単一サウンドを再生」を行う。

(※) サーバー周りをまだ試していないのでLaravelの解説はしない。あくまで動いたよ!という報告だけ。

前提

  • Laravel8 + ReactでExample.jsを表示できたよ!という状態

手順

まずはnpmでインストール。しようとしたが、以下のようなエラーが出る。

$ npm install react-webaudio
npm 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-EXPERIMENTAL

Laravel uiのReactバージョンが16.14.0でweb Audioで要求されているバージョンが16.2.0だよ!ということらしい。
マイナーバージョンの差だし大丈夫でしょ!
動かなかったら考える理論で強制インストール。
ダメだった場合はバージョンを指定してReactをインストールし直しましょう。

$ npm install --legacy-peer-deps react-webaudio

今回は音を鳴らすだけなので上記の記事を参考に、少し改変して Example.jsを以下のように修正。

Example.js
import 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なので触って試してみたい。

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

5分で学ぶ React Hooks

この記事の目的

公式ページが Hooks がいかに素晴らしいかなどが書いてあって長いので、必要最低限な情報をまとめた。
これを読めば Hooks を知らない人でもレビューはできるようになるはず。(願望)

Hooks とは

React 16.8 で追加された機能。
ステートフック、副作用フック、コンテキストフック、リデューサフックなどがある。

Hooks のメリット

Class でゴリゴリ書かないといけないものが Function で書けるようになった。
シンプルに書ける、ボイラープレートを減らせる。

主に使うメソッド

ステートフック(useState)

useStete.js
const [a, setA] = useState(0)

使い方
useState の引数に初期値を渡す。
a には get が、setA には set の機能がある。

副作用フック(useEffect)

useEffect.js
useEffect(() => {
  a();
  return () => {
    b();
  };
}, [c]);

useEffect の中の a() がライフサイクルの componentDidMountcomponentDidUpdate で実行される。
useEffect の返り値の b() がライフサイクルの componentWillUnmountcomponentDidUpdate で実行される。
componentDidUpdateb()a() の順番で実行する。

useEffect の第二引数を指定しなかった場合、上記のタイミングで実行されるが、指定した場合は指定した値が変わっていない場合には実行されないようにできる。上記例であれば c の値が更新された場合にのみ実行されるようになる。パフォーマンス改善の為に使う。

複雑な設計になった際に使うメソッド

ここから先はメソッドの存在と必要なタイミングについて軽く触れるだけ。

コンテキストフック(useContext)

useContext.js
const value = useContext(MyContext);

親コンポーネントで指定した値を子コンポーネントが useContext 経由で取得できる。子コンポーネントに値をいちいち渡さなくて良くなる。
https://ja.reactjs.org/docs/hooks-reference.html#usecontext

リデューサフック(useReducer)

useReducer.js
const [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

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

【React custom hooks】文字を順番に表示するカスタムフック

概要

本当はcssでやったほうがパフォーマンスはいいと思いますが、ちょっと面倒だったのでカスタムフックを用意してちゃちゃっと実装してみました。

完成形

qiita.gif

コード

// カスタムフック
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おまちしてます)

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

React JSとaws-amplify/authでCognitoにユーザー登録する

Cognitoユーザープールの作成

AWSのコンソールからCognito→ユーザープールの管理と進みます。

image.png

ユーザープールの作成を選択し、作成するユーザプールの設定に移ります。

image.png

今回はユーザープールの設定をカスタマイズせずにデフォルトの設定で作成しました。
画像の「デフォルトを確認する」→「プールの作成」で大丈夫です。

カスタマイズする場合は、パスワードの文字数などのルールや、ユーザーに紐づく属性の設定などができます。

アプリクライアントの作成

アプリケーションからCognitoにアクセスするためにはアプリクライアントが必要になります。
今回作成するReactアプリでもアプリクライアントIDを利用するため、アプリクライアントを作成します。

先程作成したユーザープールを選択肢、左カラムの「アプリクライアント」を選択します。

image.png

まず適当な、「アプリクライアント名」を設定します。
次に、「クライアントシークレットを生成」を無効にします。Javascriptからユーザー登録を行う際はこの設定が必要です。
他はデフォルトのママそのまま画面一番下の「アプリクライアントの作成」ボタンで作成しました。

image.png

アイデンティティプールの作成

少しわかりにくいですが、画面上部の反転している「フェデレーティッドアイデンティティ」リンクをクリックします

image.png

その後ID プール名には適当な名前を入れて、下部の認証プロバイダーエリアでCognitoを選択肢、先ほど作成したユーザープールとアプリクライアントIDを設定して「プールの作成」をします。

image.png

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.tsx
import 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.tsx
import 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でユーザープールの「ユーザーとグループ」を見るとユーザーが作成されています。

image.png

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

[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.js
import 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;


10 10
numColumns={2}, width指定なし numColumns={2}, width={wp('80%')}

セルが正方形で描画される.
widthを指定した場合、縦横両方のサイズに反映される.

6. 変更後

App.js
import 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;

10 10
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

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

Reactをnpxで作成する時気をつけること

はじめに

Reactの環境をnpxで作成した時、Githubにpushしてフォルダーが開かない問題に直面したのでその対処法をここに残していきます。

ReactとTypeScriptの環境構築

bash
$ npx create-react-app . --template typescript

この時.gitが勝手に作られている。このままGithubにpushすると、フォルダーをsubmoduleとして管理し、作成したフォルダーがGithub内で開かなくなる。

対処法

bash
# 作成したフォルダーで
$ rm -rf .git

これでフォルダー内の.gitを削除する。

参考にしたサイト

GitHubでフォルダーを開けないのはなぜですか?

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

React + Contentful で「おみくじアプリ」を作ってみる

はじめに

「Vueはちょくちょく使ってるけど、React触ったことないんだよなぁ」
「Contentful…なんだか今後使う事ありそうだし、使ってみたいなぁ」
そんなフロントエンジニアが、React + Contentful で "おみくじアプリ" を作った際の備忘録です。
5.gif

やる事

  • React導入
  • Contentful導入
  • つなぎ込み
  • 実装

React導入

公式のドキュメント通り

npx create-react-app react-omikuji

で環境が出来ます。(爆速!最高!)

cd react-omikuji

で環境に移動して

npm start

でローカル環境が起動します。
新しい React アプリを作る - React公式ドキュメント

Contentful導入

サインアップ

公式ページからサインアップします。
freeだとユーザー数やAPIコール数に制限がありますが、一旦freeにて。
実案件で使う際は課金が必須になると思います。

スペース作成

今回は omikuji というIDでスペースを作成します。

おみくじ結果の登録

情報として以下が必要になります。

  • タイトル (例. 大吉)
  • 本文 (例. 最高にハッピーな1日になるでしょう)

なので、上記をコンテントモデルに追加します。
1.png
Field ID

  • タイトル => title
  • 本文 => description

とします。

続いて、おみくじの結果が複数必要になるので登録していきます。
2.png
このような形で6つ、結果を登録します。
3.png

つなぎ込み

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.js
import {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 をすれば表示されたはずです。
4.png
記述の通り0番目の内容が表示されていると思います。
ここまでエラーがなければつなぎ込みは完了です。

実装

仕様の確認

まずどのような仕様のおみくじにするのか確認です。

  • トップページ( react-omikuji/ )で「今日の運勢を占う」ボタンを押下すると、 react-omikuji/result ページに遷移し、結果が表示される
  • ランダムで結果が表示される

上記が問題なければなんでも良しとします。

ルーティング設定

ページ遷移の挙動を実現するためにreact-router-domをインストールします。

npm i react-router-dom

コンポーネント追加

トップページで使う Button と、結果ページの Result のコンポーネントを用意します。

components/Button.js
import 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.js
import 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.js
import {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/"

完成

完成です!
5.gif
(スタイルは適宜ご調整ください。)

npm start でローカル確認が問題なければ、 npm run build でビルドされます。

あとがき

「いやいや、おみくじ判定のタイミング!」
「aタグで戻るんかい!」
などなどありますが、あくまで仕様が

  • トップページ( react-omikuji/ )で「今日の運勢を占う」ボタンを押下すると、 react-omikuji/result ページに遷移し、結果が表示される
  • ランダムで結果が表示される

上記が問題なければなんでも良しとします。

という事で…

トークン丸見え問題・APIコール数問題などなどがあるので、ヘッドレスCMS使うならSSG一択なんでしょうかね…

リンク

新しい React アプリを作る - React公式ドキュメント
Getting started with React and Contentful - Contentful公式ドキュメント

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