20210124のJavaScriptに関する記事は26件です。

ちょっとしたことを調べたいときに見るページを公開しました。

タイトル通り、ちょっとしたことを調べるときに使えそうなページを作りました。

  • BMI調べたいとか
  • テレビ買う時とかにインチって何センチだっけとか
  • 今年西暦/和暦何年で干支は何で自分は何歳なんだっけ

とかたま〜〜に気になるようなことを知ることができます。


webページとして公開したいけど、サーバー立てるのは面倒だったので、github pagesで公開しました。

というわけで慣れ親しんだPythonは使えないので、

JavaScript素人/jQuery初見、の状態でなんとか色々調べながら作成しました。

jQueryについてはいまだによく分かっていません。

もちろんcssもよく知らないのでカスみたいな見た目してます。 

こういうの追加してほしいとかあれば可能な限りで追加してみます。

あとcssもアドバイスあれば嬉しいです。

追記

内容なさすぎたし、コード書けって言われて確かにとなったので追記します。

コード全部載せるのはアレなんで、詳しく見たい方はgithubで見てください。

kokuyokugetter/tools - github

ここでは作成時に気にしてたことを一つだけ書きます。

今回こちらのサイトを作成する際には、レスポンシブデザインを気にして作成していました。

safariにはレスポンシブデザインモードがあり、そこで逐一タブレットやスマホでの表示をチェックしながら作成していました。

PCでは3列表示で、全コンテンツを一度に表示するように見れ、

スマホ表示ではそれだと小さすぎるので、一列に表示するようにしているのですが、

PCの場合の3列表示したいときに、普通に

  <div id="leftside">
    左中身
  </div>

  <div id="center">
      中央中身
  </div>

  <div id="rightside">
      右中身
  </div>

として

    #leftside {
        float:left;
        width:29%;
    }

    #center {

    }
    #rightside {
        float:right;
        width: 29%;
    }

とかってやるとrightsideの要素がcenterの要素の右下に来てしまいました。(確か)

centerが普通に表示した後に、float:rightが効いてるからでしょうか。

htmlでcenterとrightsideの要素を逆にすればちゃんとなりましたが、今度はスマホにしたときにcenterがrightsideの後ろにきて気持ち悪いです。

ということで、自分は以下のようにしました。

  <div id="leftside">
    左中身
  </div>

  <div id="center-right">
    <div id="centerside">
      中央中身
    </div>

    <div id="rightside">
      右中身
    </div>
  </div>

    #leftside {
        float:left;
        width:29%;
    }

    #center-right {
        float: right;
        width: 70%;
    }
    #centerside {
        float:left;
        width: 49%;

    }
    #rightside {
        float:right;
        width: 49%;
    }

centerとrightsideをまとめた要素を作ってそれを右寄せして、その中でまた左右に分けることで、2列表示のやり方を入れ子にすることで、上の問題を解決できました。

自分で思いついたのは偉いと自画自賛していますが、

CSSのスペシャリスト的に、もっといい3列表示の方法があれば教えてください。

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

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

ワイ「ハスケル子ちゃん、ライブラリのラップって本当に意味あるの?」(やってみた)

きっかけ

@Yametaro さんのワイ「なに!?ライブラリをラップするやと!?」という記事がきっかけです。
この中で、ハスケル子ちゃんが

ハスケル子「ラッパー関数を作って、それを使っていた場合は」
ハスケル子「1箇所だけの修正で済みます」

という発言をしています。
確かにその通りだと思いますが、その場面にあたったことがないので腑に落ちませんでした。
なので実践してみよう!というのがきっかけです。

どうやるか

問題はどうやるのかですが、HTTP通信を行うライブラリをラップして、使用するライブラリを切り替えるような実装にしました。

フレームワークはVueを使用し、jQueryからaxiosへと入れ替えていきます。

下準備

要件定義

現実的なプロジェクトの方がイメージをしやすいので、本を管理できるサイトという想定で進めていきます。
管理といっても本の名前を投稿するのみです。
また、エラーハンドリングについては今回は無視することとします。

API準備

json-serverを使用してローカルにモックAPIを作成します。

db.json
{
  "books": [
    {
      "id": 1,
      "title": "json-server"
    },
    {
      "id": 2,
      "title": "タイトル2"
    }
  ]
}

次のコマンドで起動しておきます。

コマンド
npx json-server --watch db.json
  \{^_^}/ hi!

  Loading db.json
  Done

  Resources
  http://localhost:3000/books

  Home
  http://localhost:3000

  Type s + enter at any time to create a snapshot of the database
  Watching...

リクエスト 説明
GET /books 本一覧の作成
POST /books 本の登録

本の登録に関しては/book/1の方がいいのですが、今回はREST APIの設計ではないので目をつぶることにします。

http://licalhost:3000/books にアクセスしてみて、jsonデータが取れたら準備OKです。

Vueの準備

vue-cliを使用してVueプロジェクトを作成します。
恥ずかしながらtypescriptを使用できないのでjavascriptで記述しています。
また、jQueryとaxiosもインストールしておきます。

npm i jquery
npm i axios

設計

イメージ

各モジュールをどのような構造にするのかイメージを膨らませます。

Untitled Diagram.png

各VueコンポーネントからAPIモジュールを呼び出します。このAPIモジュールは書籍APIのSDKの役割を担ってもらいます。
そして、APIモジュールはHTTPモジュールを呼び出します。
このHTTPモジュールが今回の課題であるライブラリをラップしているモジュールになります。
今回は実験なので、HTTPモジュールをjQuery用とaxios用にファイルを分割します。しかし、実際にはこのHTTPモジュールを編集してライブラリを切り替えるイメージです。

コンポーネント作成

Vueのコンポーネントについては今回本題ではないので解説は省略します。
完成した画像は以下の通りです。

image.png

jQueryでの実装

実装

ディレクトリ構造

/src
  /api
    /index.js
  /jquery
    /index.js

コード

jquery/index.js
import $ from "jquery"

/**
 * レスポンスデータの加工
 */
function processResponse(statusCode, body, headers) {
  return {
    statusCode,
    body,
    headers
  }
}

/**
 * ヘッダー情報をマップ型に加工
 * @param {*} raw_headers 未加工のjQueryヘッダー情報
 */
function processHeaderToMap(raw_headers) {
  var arr = raw_headers.trim().split(/[\r\n]+/);
  arr.reduce((accumulator, currentValue) => {
    var parts = currentValue.split(': ');
    var header = parts.shift();
    var value = parts.join(': ');
    accumulator[header] = value;
    return accumulator
  }, {})
}

/**
 * Http通信用クラス
 */
export default class {
  /**
   * Http通信を行うためのインスタンスを作成
   * APIが複数ある場合にも対応
   * @param {string} baseUrl ベースURL
   * @param {*} option オプションパラメーター
   * @param {string} option.dataType Content-typeにあたるデータタイプ
   * @param {string} option.headers デフォルトヘッダー情報
   */
  constructor(baseUrl, { dataType = "json", headers = {} }) {
    this.baseUrl = baseUrl
    this.dataType = dataType
    this.headers = headers
  }

  /**
   * getメソッドの呼び出し
   * @param {string} url URLパス
   * @param {object} query クエリデータ
   */
  get(url, query) {
    const baseUrl = this.baseUrl
    const dataType = this.dataType
    const headers = this.headers
    return new Promise(function (resolve, reject) {
      $.ajax({
        url: `${baseUrl}${url}`,
        type: 'get',
        data: query,
        dataType,
        headers,
      }).done(function (data, textStatus, jqXHR) {
        const headers = processHeaderToMap(jqXHR.getAllResponseHeaders());
        resolve(processResponse(textStatus, data, headers))
      }).fail(function (jqXHR, textStatus, errorThrown) {
        reject(errorThrown)
      })
    })
  }

  /**
   * postメソッド呼び出し
   * queryがないのはREST原則に則っているため
   * @param {string} url URLパス
   * @param {object} data リクエストボディ
   */
  post(url, data) {
    const baseUrl = this.baseUrl
    const dataType = this.dataType
    const headers = this.headers
    if (dataType == 'json') {
      data = JSON.stringify(data)
    }

    return new Promise(function (resolve, reject) {
      $.ajax({
        url: `${baseUrl}${url}`,
        type: 'post',
        data: data,
        dataType,
        headers,
      }).done(function (data, textStatus, jqXHR) {
        const headers = processHeaderToMap(jqXHR.getAllResponseHeaders());
        resolve(processResponse(textStatus, data, headers))
      }).fail(function (jqXHR, textStatus, errorThrown) {
        reject(errorThrown)
      })
    })
  }
}
src\api\index.js
import Http from "../jquery"

const headers = {
    "Content-Type": "application/json"
}
const instance = new Http("http://localhost:3000", { headers })

/**
 * 本一覧の取得
 */
export const getBooks = async function() {
    const res = await instance.get("/books")
    return res.body
}

/**
 * 本の登録
 */
export const postBook = async function(data) {
    const res = await instance.post("/books", data)
    return res.body
}

解説

使い方としては、自作jQueryモジュールをAPIが呼び出し、インスタンスを作成します。
インスタンス作成時にヘッダー情報などを登録して後程使う構成にしました。
理由としては、呼び出すAPIが一つとは限らないからです。
API(エンドポイント)ごとにインスタンスを作成することで汎用性を持たせています。

通信するときには作成したインスタンスから各メソッドを呼び出します。
レスポンスについてはprocessResponseメソッドを作成し、独自のレスポンスオブジェクトとして値を返すようにしました。

動作確認

2021-01-24_21h37_37.png

データもしっかり取得できています。登録側も問題ないです。

axiosでの実装

実装

本題です。jQueryからaxiosへモジュールを変えます。

src\axios\index.js
import axios from "axios"

/**
 * レスポンスデータの加工
 */
function processResponse(response) {
  return {
    "statusCode": response.statusCode,
    "body": response.data,
    "headers": response.headers
  }
}

/**
 * Http通信用クラス
 */
export default class Api {
  /**
   * Http通信を行うためのインスタンスを作成
   * APIが複数ある場合にも対応
   * @param {string} baseUrl ベースURL
   * @param {*} option オプションパラメーター
   * @param {string} option.dataType Content-typeにあたるデータタイプ
   * @param {string} option.headers デフォルトヘッダー情報
   */
  constructor(baseUrl) {
    this.baseUrl = baseUrl
    this.instance = axios.create({
      baseURL: baseUrl
    })
  }

  /**
   * getメソッドの呼び出し
   * @param {string} url URLパス
   * @param {object} query クエリデータ
   */
  async get(url, params) {
    const config = {
      params
    }
    const response = await this.instance.get(url, config)
    return processResponse(response)
  }

  /**
   * postメソッド呼び出し
   * queryがないのはREST原則に則っているため
   * @param {string} url URLパス
   * @param {object} data リクエストボディ
   */
  async post(url, data) {
    const response = await this.instance.post(url, data)
    return processResponse(response)
  }
}
src\api\index.js
import Http from "../axios"
// import Http from "../jquery"

const headers = {
    "Content-Type": "application/json"
}
const instance = new Http("http://localhost:3000", { headers })

/**
 * 本一覧の取得
 */
export const getBooks = async function() {
    const res = await instance.get("/books")
    return res.body
}

/**
 * 本の登録
 */
export const postBook = async function(data) {
    const res = await instance.post("/books", data)
    return res.body
}

解説

jQueryに比べてaxiosの方がコードがすっきりしました。
しかし、特記すべき点はそこではなく、src\api\index.jsのコードがほぼ変わっていないことです。
変更点としてはモジュールの呼び出し部分のみです。
独自のHTTPクラス、レスポンスを作成したことによりモジュールを変更しても呼び出し元のsrc\api\index.jsには影響を及ぼしていません。

動作確認

image.pngimage.png

この画像を見せても何が変わったんだ?といった感じですが、ちゃんと動いています。(信じてください)

まとめ・感想

このように、ライブラリをラップすると1か所(1ファイル)のみの修正でモジュールを変更することができました!
実際の現場で提案してもなかなか受け入れられることが少ないですが、実証できたおかげでより信ぴょう性が増したと思います。
また、ライブラリを置き換える可能性ってあるの?ということに関してですが、現状でjQueryがほかのライブラリに置き換わっているのを見ると、現在使用しているライブラリが将来的には置き換わる可能性は十分あるのではないのでしょうか。
そのときに、この設計でよかった!といえるような設計をしていきます。

謝辞

今回、この題名にて記事にする際、やめ太郎さんが嫌な思いをしないのか確認させていただきました。
突然のメッセージにも関わらず、記事の内容にも目を通していただきありがとうございます。
この場を借りて御礼申し上げます。

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

TypeScriptでチェックボックスの状態と値を取得する方法

実現したいこと

チェックボックスにチェックをして、削除ボタンを押すと、チェックされた項目が一括削除されるという、よくある処理をTypeScriptで行いたい。
そのためには、チェックボックスにチェックの入った要素だけを取得する必要がある。

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

結論

チェックボックスのHTML

index.html
<span name="check_result" id="check_all">削除</span>

<td><input type="checkbox" name="checkBox" value="1"></td>
<td><input type="checkbox" name="checkBox" value="2"></td>
<td><input type="checkbox" name="checkBox" value="3"></td>
<td><input type="checkbox" name="checkBox" value="4"></td>

チェックボックスにチェックの入った要素を配列で返す関数

App.ts
const checkBoxes = document.getElementsByName('checkBox');

const checkedArray = (checkboxes: NodeList): HTMLElement[] => {
    let resultArray: HTMLElement[] = Array.prototype.slice.call(checkboxes).filter(checkbox => checkbox.checked)

    return resultArray
}

checkedArray(checkBoxes)

解説

まずは、チェックボックス全部を取得します。

App.ts
const checkBoxes = document.getElementsByName('checkBox');

次に、取得した要素(checkBoxes)の中から、チェックの入った要素のみを配列で返す関数を作ります。
ドキュメントにある通り、getElementsByNameNodeListを返すので、

App.ts
const checkedArray = (checkboxes: NodeList): HTMLElement[] => {
}

引数をNodeList型に指定して、返り値をHTMLElementの配列に指定します。

引数として渡ってきたcheckboxesの中からチェックの入った要素のみを判別して配列に入れたいのですが、

App.ts
let resultAry: HTMLElement[] = checkboxes.filter(checkbox => checkbox.checked)
// => Property 'filter' does not exist on type 'NodeList'.

NodeListfileterは使えないとエラーが出ます。

なので、

App.ts
let resultAry: HTMLElement[] = Array.prototype.slice.call(checkboxes).filter(checkbox => checkbox.checked)

Array.prototype.slice.call()を使ってfiltercheckedの要素のみを配列に入れます。

あとは結果の配列をreturnすればOK??

App.ts
const checkedArray = (checkboxes: NodeList): HTMLElement[] => {
    let resultArray: HTMLElement[] = Array.prototype.slice.call(checkboxes).filter(checkbox => checkbox.checked)

    return resultArray
}

いざ実行

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

App.ts
let checkedElements = checkedArray(checkBoxes);
console.log(checkedElements);

以下の要素がコンソールに出力される

<td><input type="checkbox" name="checkBox" value="1"></td>
<td><input type="checkbox" name="checkBox" value="3"></td>

バージョン情報

TypeScript 4.1.3

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

TypeScriptでチェックが入ったチェックボックスの値のみを取得する方法

実現したいこと

「チェックボックスにチェックをして、削除ボタンを押すと、チェックされた項目が一括削除される」という、よくある処理をTypeScriptで行いたい。
そのためには、チェックボックスにチェックの入った要素だけを取得する必要がある。

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

結論

チェックボックスのHTML

index.html
<td><input type="checkbox" name="checkBox" value="1"></td>
<td><input type="checkbox" name="checkBox" value="2"></td>
<td><input type="checkbox" name="checkBox" value="3"></td>
<td><input type="checkbox" name="checkBox" value="4"></td>

チェックボックスにチェックの入った要素を配列で返す関数

App.ts
const checkBoxes = document.getElementsByName('checkBox');

const checkedArray = (checkboxes: NodeList): HTMLElement[] => {
    let resultArray: HTMLElement[] = Array.prototype.slice.call(checkboxes).filter(checkbox => checkbox.checked)

    return resultArray
}

checkedArray(checkBoxes)

解説

まずは、チェックボックス全部を取得します。

App.ts
const checkBoxes = document.getElementsByName('checkBox');

次に、取得した要素(checkBoxes)の中から、チェックの入った要素のみを配列で返す関数を作ります。
ドキュメントにある通り、getElementsByNameNodeListを返すので、

App.ts
const checkedArray = (checkboxes: NodeList): HTMLElement[] => {
}

引数をNodeList型に指定して、返り値をHTMLElementの配列に指定します。

引数として渡ってきたcheckboxesの中からチェックの入った要素のみを判別して配列に入れたいのですが、

App.ts
let resultAry: HTMLElement[] = checkboxes.filter(checkbox => checkbox.checked)
// => Property 'filter' does not exist on type 'NodeList'.

NodeListfileterは使えないとエラーが出ます。

なので、

App.ts
let resultAry: HTMLElement[] = Array.prototype.slice.call(checkboxes).filter(checkbox => checkbox.checked)

Array.prototype.slice.call()を使ってfiltercheckedの要素のみを配列に入れます。

あとは結果の配列をreturnすればOK??

App.ts
const checkedArray = (checkboxes: NodeList): HTMLElement[] => {
    let resultArray: HTMLElement[] = Array.prototype.slice.call(checkboxes).filter(checkbox => checkbox.checked)

    return resultArray
}

いざ実行

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

App.ts
let checkedElements = checkedArray(checkBoxes);
console.log(checkedElements);

以下の要素がコンソールに出力される

<td><input type="checkbox" name="checkBox" value="1"></td>
<td><input type="checkbox" name="checkBox" value="3"></td>

1/25 追記

@vf8974 様より、コメントでウルトラ超シンプルな記述を教えていただきました!

App.ts
const checkBoxes = document.querySelectorAll('input[type=checkbox]:checked');

1行で書けた…

ここから値を取り出して配列にしたい時は、

forEachを使ったり、

App.ts
const checkBoxes = document.querySelectorAll('input[type=checkbox]:checked');

let values = [];
checkBoxes.forEach(node => values.push(node.nodeValue));

console.log(values);
// ["1", "3"]

mapを使えばいい感じ。

App.ts
const checkBoxes = document.querySelectorAll('input[type=checkbox]:checked');

let values = Array.prototype.slice.call(checkBoxes).map(element => element.value);

console.log(values);
// ["1", "3"]

バージョン情報

TypeScript 4.1.3

  • このエントリーをはてなブックマークに追加
  • 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を簡単に取得できるようになります。
また、検証もexpress-jwtを使うことで手軽にできるようになります。(req.cookies.tokenの部分)
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で続きを読む

え、まだpreventDefaultとstopPropagationをいちいち入力してるの??

今回はテクニック集として、独自のショートカットキーを設定していきます。

Javascriptのwindowオブジェクトを使う際にちゃんとperventDefaultstopPropagationを使っていますか??

これをやらないと、思わぬところで自分の意図しない関数が実行されたりということが発生します。

ただ、これを毎回入力するの結構めんどいですよね。

ここで、vscodeでショートカットキーを設定しちゃいましょう!!

こちらの記事=(イコール)打った後の""(ダブルクォーテーション)って遠くない???解決策は・・・を見て頂くと、ショートカットキーの設定のやり方が載っているので是非ご覧ください!

こちらの記事と同じように、まずはショートカットキーを設定します。

僕はctrl+shift+eと設定しました。

次に、以下のように設定します。

{
"key": "ctrl+shift+e",
"command": "type",
"args": { "text": "event.preventDefault()\nevent.stopPropagation()"},
"when": "editorTextFocus"
}

\nで改行を行っています。

vscode再起動。

ctrl+shift+eと打つと、event.preventDefault()event.stopPropagation()が表示される思います!

このようにしてショートカットキーをじゃんじゃん自分好みに設定していってください!!!

以上、「え、まだpreventDefaultとstopPropagationをいちいち入力してるの??」でした!

良ければ、LGTM、コメントお願いします。

また、何か間違っていることがあればご指摘頂けると幸いです。

他にも初心者さん向けに記事を投稿しているので、時間があれば他の記事も見て下さい!!

Thank you for reading

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

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で続きを読む

Bootstrap 3からBootstrap 5に移行してみた

これまでずっと、Bootstrap3で使っていたのですが、そろそろ新しいバージョンに切り替えようと思います。
ちょうど、Bootstrap 5でJQueryに依存しなくなったので、ちょうどよい機会かなあと。

ですが、うわさに聞いていた通り、Bootstrap 3から4への移行で細かな差異があり、移行は結構手間がかかりました。また、Bootstrap 5になってから、JQueryがなくなったので、それの影響もありました。

今回は既存のBootstrap 3ベースで動いているものをBootstrap 5に移行するにあたり、変更した部分を整理しておきます。

ちなみに、マニュアルはそこまで詳しくは読んでおらず、実動作で試行錯誤しながら直したので、もっと良い直し方があるかもしれません。

参考URL

Bootstrap 5
 https://getbootstrap.com/docs/5.0/getting-started/introduction/

Bootstrap 3.4.1
 https://getbootstrap.com/docs/3.4/getting-started/

以下のコンテンツを移行しました。
Bootstrap3.4.1版とBootstrap5版の両方を置きましたので、比較してみてください。
また、実際にWebページとして見れますので、見た目も比較してみてください。

Bootstrap 3.4.1で動かしていたもの
 GitHub:https://github.com/poruruba/utilities
 Webページ:https://poruruba.github.io/utilities/

Bootstrap 5に移行後
 GitHub:https://github.com/poruruba/utilities5
 Webページ:https://poruruba.github.io/utilities5/

変更箇所のサマリ

■Bootstrap変更に伴う変更

  • CDN取得先の変更
  • Glyphiconの代替
  • class=”nav-linkの追加
  • data-toggleからdata-bs-toggleに変更
  • class=”fade in”のinの削除
  • page-headerの代わりにmodal-header
  • panalをcardに置き換え
  • labelの太字が無効
  • btn-defaultの代わりにbtn-secondary
  • class="input-group-btn" が不要
  • class=”form-control”の範囲拡大
  • class="form-inline"の廃止
  • col-xs-がcol-に変更
  • collapseはaccordionで代用
  • text-left/rightの変更
  • img-responsiveをimg-fluidに変更
  • 属性bgcolorをstyleで代用
  • マージンは手動指定

■JQuery廃止に伴う変更

  • セレクタ$の置き換え
  • Toastの代替
  • modalの実装追加
  • Collapseの実装追加
  • Tab選択の実装追加

Bootstrap変更に伴う変更

CDN取得先の変更

v3.4.1からv5.0.0に変えたための当然の変更です。
注目は、JQueryが不要になったことです。

変更前

index.html
    <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
    <script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
    <!-- Latest compiled and minified CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
    <!-- Optional theme -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
    <!-- Latest compiled and minified JavaScript -->
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>

変更後

index.html
    <!-- Latest compiled and minified CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">
    <!-- Latest compiled and minified JavaScript -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW" crossorigin="anonymous"></script>

Glyphiconの代替

残念ながら、Glyphiconが同梱されなくなりました。手軽に使えてよかったのですが。。。
そこで、代替として「Open Iconic」を使うようにしました。

OPEN ICONIC
 https://useiconic.com/open

本日時点では、v1.1.0のようです。
ダウンロードしてとりあえず以下に配置します。

open-iconic-master.zip\open-iconic-master\font\fonts*

dist/fonts/*

open-iconic-master.zip\open-iconic-master\font\css\open-iconic-bootstrap.min.css

dist/css/open-iconic-bootstrap.min.css

そして、index.htmlで以下を追加で取り込みます。

index.html
    <link rel="stylesheet" href="dist/css/open-iconic-bootstrap.min.css">

使うときには以下のように変わります。glyphiconではなくoiになります。

変更前

index.html
<button class="btn btn-default glyphicon glyphicon-paperclip" v-on:click="clip_copy(uuid_uuid)"></button>

変更後

index.html
<button class="btn btn-secondary oi oi-paperclip" v-on:click="clip_copy(uuid_uuid)"></button>

当然ながら、絵も名前も違うので、目で見て似たような絵を探す必要があります。

class=nav-linkの追加

Navbarを使っている場合、タブの記述において、aに「class="nav-link"」を付ける必要があります。
また、タブにドロップダウンを付ける場合、キャラット()の指定は不要になりました。

変更前

index.html
        <ul class="nav nav-tabs">
            <li v-for="(link, index) in favorite_link"><a v-bind:href="'#' + link" data-toggle="tab">{{get_tab_name(link)}}</a></li>
            <li class="dropdown">
                <a class="dropdown-toggle" data-toggle="dropdown">ユーティリティ <span class="caret"></span></a>
                <ul class="dropdown-menu">
                    <li v-for="(link, index) in tab_list"><a v-bind:href="'#' + link.id" data-toggle="tab" v-on:click="favorite_add(link.id)">{{link.name}}</a></li>
                </ul>
            </li>
        </ul>

変更後

index.html
        <ul class="nav nav-tabs">
            <li v-for="(link, index) in favorite_link"><a class="nav-link" v-bind:href="'#' + link" data-bs-toggle="tab">{{get_tab_name(link)}}</a></li>
            <li class="dropdown">
                <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown">ユーティリティ</a>
                <ul class="dropdown-menu">
                    <li v-for="(link, index) in tab_list"><a class="dropdown-item" v-bind:href="'#' + link.id" data-bs-toggle="tab" v-on:click="favorite_add(link.id)">{{link.name}}</a></li>
                </ul>
            </li>
        </ul>

data-toggleからdata-bs-toggleに変更

上記のNavbarもそうですが、いろんなところでdata-toggleを使っている場合は、data-bs-toggleに変更します。

class="fade in"のinの削除

fade inと記載していた個所のinは不要になりました。
例えば以下のような場合です。

index.html
            <div id="blecent" class="tab-pane fade in">
                <h2 class="page-header">BLE Central</h2>

page-headerの代わりにmodal-header

page-headerは無くなったので、どれでも良いのですが、とりあえず、modal-headerで代用しました。

変更前

index.html
            <div id="blecent" class="tab-pane fade in">
                <h2 class="page-header">BLE Central</h2>

変更後

index.html
            <div id="blecent" class="tab-pane fade">
                <h2 class="modal-header">BLE Central</h2>

panalをcardに置き換え

パネルがなくなり、Cardに代わりました。
Collapseを使っていた場合は、CollapseやAccordionに代替するのもありです。

変更前

index.html
                <div class="panel ">
                    <div class="panel-heading">
                        ヘッダー
                    </div>
                    <div class="panel-body">
                      ボディ
                    </div>
                    <div class="panel-footer">
                       フッター
                    </div>
                </div>

変更後

index.html
                <div class="card">
                    <div class="card-header">
                        ヘッダー
                    </div>
                    <div class="card-body">
                      ボディ
                    </div>
                    <div class="card-footer">
                      フッター
                    </div>
                </div>

特に、Collapseを使う場合は書き方が特殊で、すぐ忘れるので、以下のようにVueのコンポーネントにしています。

変更前

js/components_bootstrap.js
  'collapse-panel': {
    props: ['id', 'collapse', 'title'],
    template: `
      <div class="panel">
        <div class="panel-heading">
          <div class="panel-title"><a data-toggle="collapse" v-bind:href="'#' + id">{{title}}</a></div>
        </div>
        <div class="panel-collapse" v-bind:class="collapse=='true' ? 'collapse' : 'collapse in'" v-bind:id="id">
          <slot name="content"></slot>
        </div>
      </div>`,
  }

変更後

js/components_bootstrap.js
  'collapse-panel': {
    props: ['id', 'collapse', 'title'],
    template: `
      <div class="accordion mt-3">
        <div class="accordion-item">
          <div class="accordion-header">
            <button class="accordion-button" v-bind:class="collapse=='true' ? 'collapsed' : ''" type="button" data-bs-toggle="collapse" v-bind:href="'#' + id"><label class='title'>{{title}}</label></button>
          </div>
          <div class="accordion-collapse" v-bind:class="collapse=='true' ? 'collapse' : 'collapse show'" v-bind:id="id">
            <slot name="content"></slot>
          </div>
        </div>
      </div>`,
  }

labelの太字が無効

これまでは、目立たせる意味でで囲って太字にしていましたが、それが無効になりました。
手動で太字にする必要があります。
そこで、cssに記載しておきます。

css/start.css
label.title {
  font-weight: bold;
}

使い方(変更前)

<label>タイトル</label>

使い方(変更後)

<label class=”title”>タイトル</label>

btn-defaultの代わりにbtn-secondary

btn-defaultなど、****-defaultがなくなりました。とりあえず、btn-secondaryで代用しました。

変更前

<button class="btn btn-default">All Read</button>

変更後

<button class="btn btn-secondary">All Read</button>

class="input-group-btn" が不要

Inputにボタンをくっつけるときに、input-group-btnを付けていましたが、不要になりました。

変更前

index.html
                <div class="input-group">
                    <span class="input-group-btn">
                        <button class="btn btn-default glyphicon glyphicon-paperclip" v-on:click="clip_copy(uuid_uuid)"></button>
                    </span>
                    <input type="text" class="form-control" v-model="uuid_uuid" readonly>
                </div>

変更後

index.html
                <div class="input-group">
                    <button class="btn btn-secondary oi oi-paperclip" v-on:click="clip_copy(uuid_uuid)"></button>
                    <input type="text" class="form-control" v-model="uuid_uuid" readonly>
                </div>

class=”form-control”の範囲拡大

form-controlを適用できるものが増えたり、見た目がまともになりました。

・type=”file”がclass=”form-control”対象に変更

変更前

image.png

変更後

image.png

・type=selectはform-seleectに変更

form-controlでも良かったのですが、form-selectにしないと、キャラットが表示されません。

・type=rangeやcolorも見栄え向上

付けないと、横幅を自動的に伸縮してくれないです。

以下を付けるようにします。

class="form-control-color"
class="form-range"

class="form-inline"の廃止

結構form-inlineを多用していたので、これが一番痛かったです。修正も面倒です。

  • 明示的にclass="row"で囲う。
  • 各要素に、class=”col-auto”を付ける。ただし、インプットやセレクトは、一つ上にdivやspanで囲い、そこにcol-autoを付ける。

変更前

index.html
<div class="form-inline">
    <label>タイトル</label>
    <button class="btn btn-default btn-sm" v-on:click="date_duration_reset">リセット</button>
    <select class="form-control" v-on:change="date_process()" v-model="date_duration_unit">
        <option value="year"></option>
        <option value="month"></option>
        <option value="day"></option>
        <option value="hour"></option>
        <option value="minute"></option>
        <option value="second"></option>
    </select>
    <input type="number" class="form-control" v-on:change="date_process()" v-model.number="date_duration">
    <button class="btn btn-primary btn-sm" v-on:click="date_process()">経過期間→経過後日時</button>
</div>

変更後

index.html
<div class="row">
    <label class="col-auto title">タイトル</label>
    <button class="col-auto btn btn-secondary btn-sm" v-on:click="date_duration_reset">リセット</button>
    <span class="col-auto">
        <select class="form-select" v-on:change="date_process()" v-model="date_duration_unit">
            <option value="year"></option>
            <option value="month"></option>
            <option value="day"></option>
            <option value="hour"></option>
            <option value="minute"></option>
            <option value="second"></option>
        </select>
    </span>
    <span class="col-auto">
        <input type="number" class="form-control" v-on:change="date_process()" v-model.number="date_duration">
    </span>
    <button class="col-auto btn btn-primary btn-sm" v-on:click="date_process()">経過期間→経過後日時</button>
</div>

class="col-xs-*"が"col-*"に変更

グリッド指定でxsを使っていた場合はxsを省きます。

collapseはaccordionで代用

これは必須ではないですが、collapseも引き続きBootstrap5で使えますが、accordionの場合には、右端にキャラットが付くのでお好みで。

変更前
image.png

変更後
image.png

属性bgcolorをstyleで代用

なぜか、HTMLでの属性bgcolorの指定が横取りされてしまうようで、style指定にしました。

変更前

index.html
<td v-bind:bgcolor="color_value">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</td>

変更後

index.html
<td v-bind:style="{ 'background-color': color_value }">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</td>

text-left/rightの変更

text-left・text-rightは、それぞれ、text-start・text-endに名前が変更となりました。

img-responsiveをimg-fluidに変更

img-responsiveがimg-fluidに名前が変更となっています。

マージンは手動指定

これまでPanelなどを並べて表示しても、マージンが付与されていたので、くっついて表示されることはなかったのですが、Bootstrap5では自分でマージンを指定します。

class=m-X で、Xに数字を入れます。

変更前

index.html
      <div class="panel">

変更後

index.html
      <div class="accordion m-3">

JQuery廃止に伴う変更

JQueryに依存しなくなったのは良いのですが、いくつか使えない機能が出てきましたので、代替を用意します。

セレクタ$の置き換え

JQueryで慣れ親しんできた$(***) によるセレクタは使えなくなるので、ブラウザ標準の書式に変えます。

変更前

$('#id名')

変更後

document.querySelector('#id名')

Toastの代替

これまでは、有志のToastのライブラリを使ってきましたが、ほとんどがJQueryを前提としたものでした。もちろん、Bootstrap5にもToastはあるのですが、使いにくいし、Toast表示をスタックできないようです。
そこで、JQueryに依存しないライブラリに切り替えます。

ooyun0/siiimple-toast
 https://github.com/ooyun0/siiimple-toast
 https://www.cssscript.com/demo/animated-queued-toast-messages-pure-javascript-siiimple-toast/

index.htmlで以下をロードします。

index.html
    <link href="https://cdn.jsdelivr.net/npm/siiimple-toast/dist/style.css" rel="stylesheet">
    <script src="https://cdn.jsdelivr.net/npm/siiimple-toast/dist/siiimple-toast.min.js"></script>

呼び出しはこんな感じ

js/methods_bootstrap.js
    toast_show: function(message, level = "success"){
        if( level == 'message' )
            siiimpleToast.message(message, { position: 'top|right' });
        else if( level == 'alert' )
            siiimpleToast.alert(message, { position: 'top|right' });
        else
            siiimpleToast.success(message, { position: 'top|right' });
    },

modalの表示/非表示切り替えの実装追加

Javascriptから、Modalを表示したり非表示するためのJavascriptを実装します。

js/methods_bootstrap.js
    dialog_open: function(target, backdrop = 'static'){
        const element = document.querySelector(target);
        let modal = bootstrap.Modal.getInstance(element);
        if( !modal )
            modal = new bootstrap.Modal(element, { backdrop: backdrop, keyboard: false });
        modal.show();
    },
    dialog_close: function(target){
        const element = document.querySelector(target);
        let modal = bootstrap.Modal.getInstance(element);
        if( modal )
            modal.hide();
    },

上記の関数はあらかじめ、index.htmlにModalが定義されている前提です。以下は例。

index.html
        <div class="modal" id="binary_image_dialog">
            <div class="modal-dialog modal-lg">
                <div class="modal-content">
                    <div class="modal-header">
                        <h4 class="modal-title">内容表示
                            <span class="pull-right">
                                <button class="btn btn-secondary" v-on:click="dialog_close('#binary_image_dialog')">閉じる</button>
                            </span>
                        </h4>
                    </div>
                    <div class="modal-body">
                        <img v-if="binary_type.startsWith('image/')" v-bind:src="binary_dataurl" class="img-responsive" />
                        <pre v-if="binary_type.startsWith('text/')">{{binary_text}}</pre>
                    </div>
                </div>
            </div>
        </div>

ここで、1点注意があります。
Modalでよく使うfadeを使う場合です。
fadeを使うことで、Modal表示・非表示の際にアニメーションを付けることができます。
しかしながら、modal.show()で表示したとき、このアニメーションが終わる前に関数から返ってくるため、すぐにmodal.hide()を呼び出してもModalが表示されたままとなる場合があります。

this.dialog_open('#binary_image_dialog');
this.dialog_close('#binary_image_dialog');

表示のアニメーション中に受け取った非表示要求が無視されるためです。
なので、デフォルトでfadeはつけていません。付ける場合は注意して使ってください。

Collapseの実装追加

Modal同様に、Collapseを開いたり閉じたりする関数をJavascriptで実装します。

js/methods_bootstrap.js
    panel_open: function(target){
        var element = document.querySelector(target);
        var collapse = bootstrap.Collapse.getInstance(element);
        if( !collapse )
            collapse = new bootstrap.Collapse(element, { toggle: false });
        collapse.show();
    },
    panel_close: function(target){
        var element = document.querySelector(target);
        var collapse = bootstrap.Collapse.getInstance(element);
        if( !collapse )
            collapse = new bootstrap.Collapse(element, { toggle: false });
        collapse.hide();
    },

Tab選択の実装追加

Tabも同様です。

js/methods_bootstrap.js
    tab_select: function(target){
        var element = document.querySelector("a[href='" + target + "']");
        var tab = bootstrap.Tab.getInstance(element);
        if( !tab )
            tab = new bootstrap.Tab(element);
        tab.show();
    },

未解決事項

今回は、既存の実装をBootstrap5に移行しましたので、既存で使っていた部分だけの差分をまとめました。
Bootstrapはいろんな機能がありますので、そのほんの一助になればと思います。

  • Navbarのドロップダウンを選択すると、選択状態のままとなってしまいます。表示だけなので問題ないですが。(Bootstrapのバグ?)
  • チェックボックスを他のInputやボタンと横並びに表示すると、縦方向に中央配置されていません。直すのが面倒なので直していません。

以上

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

素のJavaScriptでTVの放送用字幕を作ってみた話

TV放送用の字幕、作ってみたくありませんか?

JavaScript(以下JS)は万能言語として、不動の地位を築いていますが、テレビ放送用の字幕…… 正確にはスーパーインポーズ(以下スーパー)も、当然JSで作れます。
今日、突然「◯◯くん、△△局に行って、TVに流す字幕を作ってくれ。」という辞令が下りても、この記事を読んでおけば万全です。

作るもの

qiita_20210124@2x.png
ステップ
①必要なデータを集める。
②ウィンドウその1でデータを受け取るまた必要な情報を加える。
③ウィンドウその2で情報を受け取りスーパーの画像を生成する(フィル信号)。
④ウィンドウその3で情報を受け取りスーパーの表示領域を生成する(キー信号)。
⑤2と3のウィンドウをそれぞれ別ディスプレイに全画面表示する。その際、確認用ディスプレイと放送用ミキサーに分配する。
⑥放送用ミキサーに持ち込む際、必要に応じて信号を変換する(ex 1080p→1080i)。
⑦無事他の映像と重なり、視聴者に届く。

各ウィンドウの役割
- ウィンドウその1(以下W1)
- 各種APIからデータを受け取り、それぞれのウィンドウに渡す。もしくはそれぞれのウィンドウの表示モードを切り替える。
- ウィンドウその2(以下W2)
- 表示される映像の元データ(フィル信号)を生成する。ここでは透過情報が持てない。
- ウィンドウその3(以下W3)
- 表示される映像の箇所を指定するデータ(キー信号)を生成する。これが透過情報になる。

①必要なデータを集める。

おそらく多くの場合、スーパーは事前に画像を用意しておいて、それと切り替える事が多く、今回のようにわざわざプログラムで画面を生成するというのは、何かしらのデータをどこかから持ってきて、そのデータから画像を生成するというニーズがあるからだと思います。
今までの業務だと、WebサービスのAPIからデータを持ってきて、画像を生成するパターンもありましたし、とあるローカルネットワーク内のデータを、そのネットワーク内に立てたローカルサーバーで整形して、その整形データを読み込むというパターンもありました。
このあたりは必要に応じて仕組みを作ってください。

②ウィンドウその1でデータを受け取るまた必要な情報を加える。

今回はChromeを使って、3つのウィンドウを立ち上げ、そのウィンドウ間で処理を渡してスーパーの画面を作成しています。
初期の頃に作っていたものでは、W1は、W2とW3の表示モードを切り替えるのみの操作画面にしていて、W2とW3がそれぞれAPIにデータを取りに行くという処理にしていたのですが、これだとそれぞれのウィンドウへのレスポンスに大きな差が出るケースが有り断念。APIの通信等の外部要因の大きなものや、処理自体が重いものは極力W1に寄せて、W2とW3は、データ等を受け取ったら、いかに早く表示処理をするか?という方針にして安定するようになりました。

補足
複数ウィンドウをJSで制御するには前準備が必要です。
今回はW1からW2、W3を開くということをしています。

W1(index.html)
var windowObject = [];
document.getElementById('button').onclick = function() {
  windowObject[0] = window.open('fill.html', 'window2');
  windowObject[1] = window.open('key.html', 'window3');
}

またW1からW2を操作するには、上記の方法でウィンドウを開いた上で、このような方法で行ってください。

W1(index.html)
windowObj[0].hogehoge(data);
W2(fill.html)
function hogehoge(data) {

}

③ウィンドウその2で情報を受け取りスーパーの画像を生成する(フィル信号)。

このW2では、前述の通り表示される映像の元データを作ります。色は全て扱えますが、透過情報は持てません。透過情報は後述するW3の方で解説します。
ここで画面を生成する方法は、ぱっと思いつくだけでもHTML+CSScanvasの2つの方式がありますが、生成した画像したいも必要に応じて納品して欲しい(その場で渡す)ということが多かったので、canvasで作っていました。
canvasの表示速度の最適化は重要なのですが、ここでは割愛。

④ウィンドウその3で情報を受け取りスーパーの表示領域を生成する(キー信号)。

このW3では、W2で作った画面の表示したい位置を白、非表示にしたい箇所を黒、透過の割合に応じてグレーで表示するデータを作ります。Photoshopで言うところの不透明度80%にしたい場合は#cccccc、不透明度50%にしたい場合は#808080にしてください。
ただ透過部分をグレーにして問題ないと思いますが、放送用ミキサー側の問題もありますので、このあたりは打ち合わせで担当者に確認や、事前テストでトラブルが起こらないかは要チェックです。

⑤2と3のウィンドウをそれぞれ別ディスプレイに全画面表示する。その際、確認用ディスプレイと放送用ミキサーに分配する。

これは究極的にはやらなくても良いのですが、現場で何か不具合が起こった時に、問題の切り分けがしやすいようにやっていました。表示するディスプレイは必ず放送用のミキサーに持ち込みたい解像度…… 1920×1080で持ち込みたいならその解像度のディスプレイを使用します。
また分配する際のスプリッターは、こんなのを使っていました。※ちゃんと事前のテストをしましょう
https://www.amazon.co.jp/dp/B0732MD43P/

⑥放送用ミキサーに持ち込む際、必要に応じて信号を変換する(ex 1080p→1080i)。

ここは必要ないケースもあったり、必要があれば放送側の方が用意してくださることも多いのですが、事前打ち合わせとテストが重要です。放送機材ではプログレッシブ(1080pのp)は上手く扱えず、インターレース(1080iのi)でないと駄目という機材もあり、また一般的なPCで表示されている画面をそのまま取り込むのに慣れていない方とのお仕事だと、土壇場でやっぱりプログレッシブでは駄目で、インターレースにして欲しい!ということもありました。
予算が許すのであれば、自前でコンバーターを持っていても良いかもしれません。
僕はこんなコンバーターをを使っていました。
https://www.amazon.co.jp/dp/B07C2QMH6W/

⑦無事他の映像と重なり、視聴者に届く。

ここまでくれば自分の手を離れて、JSが生成したスーパーがお茶の間に届いているはずです。 :v_tone1:


補足その1

②のところの補足になりますが、W2とW3の表示のタイムラグは、チラツキとなって現れます。
表示させるときの処理の重さや、使用している機材のスペックやコンディション等々、場合によってはどうしても上手くいかないみたいなことも出てくるかもしれませんが、それは放送側のディレクターさんやミキサーさんと相談すると、一回向こうで非表示にしてくれて、こちらの切り替えを行い、向こうで再表示というオペレーションをしてくれることもあります。大体、みなさん協力的なことが多いので、ぜひ相談してみてください。 :thumbsup_tone1:

補足その2

この仕組を使った業務を最後にやったのは2019年の秋なので、現状とは異なる箇所があるかもしれません。
主に屋外での中継がメインの番組をお手伝いさせていただくことが多かったのですが、残念ながらこの状況になってからさっぱり相談がなくなってしまいました。
早くこの状況が終わり、また面白いことが世の中に溢れることを願って公開してみました。 みなさん一緒に頑張りましょう! ??

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

音楽+機械学習ハッカソン、BitRateの優秀プロジェクトが発表  音楽+機械学習の可能性を感じる7つのプロジェクトを解説

image.png

2020年の8月7日〜9月7日(実際は6週間を超えた開催期間だったらしいです)に、オンラインで開催された、Google の機械学習音楽ライブラリーMagenta開発チームと、サンフランシスコのメディアアート機関”Gray Area”による機械学習&音楽のリモートワークショップ&ハッカソンイベント”Bit Rate”。

以前運営している音楽TECHアカデミーの記事にてその告知を行いましたのでご記憶されている方もいらっしゃるでしょうか?

https://canplay-music.com/2020/08/03/bitrate/

世界中から800を超える有志が参加した!との事。
ファイナル審査に進んだ33のプロジェクトの中から、優秀プロジェクトが3つ、表象プロジェクトが4つ、決定しました。
その各プロジェクトを一つずつ紹介させていただきます。
どれも非常に興味深く、この中には、これからの音楽+機械学習のブレークスルーとなる様なプロジェクトもあるかもしれません。
使用されている技術はGoogleの音楽機械学習ライブラリMagentaと、ウェブアプリが中心のためJavaScript関連のライブラリが多いです。

Dear Diary

音楽とともに文字を書いて癒し効果を得る

Dear Diaryはジャーナリングの際の文字入力にMagentaを使用した音楽を一緒に生成して瞑想効果を高める目的の音楽マインドフルネスウェブアプリです。

https://deardiary.ai/

ジャーナリングとは、書く瞑想とも呼ばれ、思い浮かんだ文章をただ文字として書き記す事で瞑想の様な効果を得る手法です。
ストレス軽減や、心を落ち着かせる効果があると言われています。

文字を入力すると、Magentaが生成するピアノの音楽が一緒に生成されて、確かに何も考えずにいれると言えば良いのか、癒しの効果が期待できるのかもしれません。

使っている技術はMagenta.jsからはMusicVAEのinter polation(2つの異なる音楽をつなげる事で新たな音楽を生む音楽生成機械学習)、ピアノの音色はTone.js、自然言語処理も大いに活用されている様で、そのために使用したライブラリはvader.jsというものだそうです。
例えばコード進行沿った音楽生成ができるImprovRNNはあまり音楽的な生成ができなかった、、、とレポートされていますが、通常の音楽生成ではもっとも音楽的な生成ができるImprovRNNがなぜだろう?と色々想像してしまいます。

今後は人の感情をより抽出、表現できる様に自然言語処理の性能を高める事や、なんと、Logic やReaperなどのDAWのプラグイン開発も行いたいと言っています。

Maestro

AIガイドボーカルレッスン

機械学習を活用したボーカルレッスンや楽器レッスンは私もやりたいと計画しているもののため、非常に興味があるプロジェクトでした。

https://maestro-application.herokuapp.com/

ここでMagentaは歌の手本用の音楽を生成し、その音程と合う様に歌う、そして、ピッチやタイミングがあっているかで歌のレベルを判断する仕組みの様です。
各レベルごとにレッスン動画があり、音楽理論もきちんと考慮されているなど非常に好感が持てます。

使っている技術はMagentaはMusic RNN(単音メロディー生成)、ml5.jsでピッチ検出、を行っています。
精度など難しい面もあるかもしれませんが、機械学習の音楽レッスンは実用化、しかもこれまでにない新しい機能を持って、と思っているので今後の進展にも期待したいです。

Natya*ML

インドの古典舞踊バラタナティヤムの振り付けを機械学習生成音楽とともにレンダリング

タイトルのままですが、Natya*MLはインドの古典舞踊バラタナティヤムの振り付けを機械学習の生成曲とともに、レンダリングして表示するウェブアプリです。

https://dance-project.glitch.me/

生成される音楽がとてもインド的で、Magentaでここまでできるのか?と感心しました。

使用している技術は、Magneta のMusicRNN(単音メロディー、ドラム生成)、音色はTone.js、そして振り付けの検出にml5.jsのPoseNetが使用されているとの事です。

製作者のアパルナクマールさんは、なんとMagentaも機械学習も、JavaScriptもさらにはhtmlやcssの様なウェブの知識さえ、あまりない状態で参加したそうです。
それでここまでできるというのは本当に素晴らしい事だと思います。
皆様も勇気付けられるのではないでしょうか?

Sonic Pi DrumRNN GUI

SonicPiにMagentaのドラム生成を組み合わせたライブコーディング パフォーマンス

これは私も実験し、生徒様に発表や、動画投稿などもしていますが(私はPythonのライブコーディング FoxDotで直接、Pythonを使用してMagentaを制御)非常に興味のあるプロジェクトです。

機械学習の音楽生成はリアルタイムのスピードが大きなメリットであると考えており、ライブパフォーマンスには実は向いていると思っています。
その際、同じプログラミング言語によって演奏できるライブコーディングであれば、双方の可能性を大きく広げる事ができ、新たな音楽パフォーマンスを生み出す事が、できるのでは?と考えています。

プロジェクトというよりは、パフォーマンスと言えるものという感もありますが、さらなる進化を期待しましょう。

Loaf-ai

Lo-Fi Hip Hopを機械学習で生成する

昨年Magentaが発表したLo-fi playerの様なアプリで,こちらもLo-Fi Hip Hopを生成するウェブアプリです。

https://kathrynisabelle.com/loaf-ai

サイトの背景画像がどこか昔の日本風でなつかしさを感じますが、これが逆に今の時代が求めているものをあらわしているのでしょう。

音楽の精度はかなり高めでLo-Fi Playerよりも出来が良いと感じます。
特にギターパートに拘っているらしく、サイト下部にギターパートの波形がp5.jsでハイライト表示されている様です。

実用性は1番ある様にも思えるアプリですが、すでに既出のアイディアのため、優秀プロジェクトに選出されなかったのかもしれません。

Sea Change

海の変化によって音楽を生成する

Sea Changeは海の変化によって変化する音楽生成を行うウェブアプリとの事です。

https://sea-change-bitrate.glitch.me/

この目的のために海洋変化の数値をとるのは、随分大掛かりだな、とも感じたのですが、データは海洋ステーションの様なものから簡単に取得できるみたいです。

phaser.jsというゲームを作成するJavaScriptライブラリが使用されている他、Magentaが生成した音楽を再生する際の音源はSpliceからのものが使用されている様です。

Lightning Loops

ブラウザに描画されたグラフィックに合わせた音楽生成を行う

かなり本格的な描画が行われ、実装は高度なスキルが用いられている様に感じます。

https://vr.fox-gieg.com/

音楽生成はMagentaのAI-Duet(自動伴奏ピアノアプリ)とPianoGenie(少ない鍵盤でピアノの88鍵盤全てを使用した様な演奏を実現するアプリ)を使用しており、MIDIデータはご自身で演奏した164曲のMIDIファイルを使用しているそうです。

最後にまとめ

いかがだったでしょうか?
7つのプロジェクト、全て素晴らしかったです。
素晴らしい試みであり、大成功だったのではないでしょうか。
とてもレベルが高く驚きましたし、いつかこれらのプロジェクトから花開くものがきっと現れると期待してしまいます。
コロナの影響でオンラインの開催だった様ですが、世界中から参加できる良いチャンスにもなったのではと思います。
私も日本でこの様なイベントを実現してみたいものです。

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

Chromebookでweb開発をしてみる( 1 )

はじめに

Chromebookはwebブラウザーのchromeをベースに開発されたOSですが、
使い方次第でいろいろなことができると思います。
今回はweb開発に目を向けてプログラムをしていきたいと思います。

開発環境

chromebook 3100 2-in-1
Google cloud
firebase
※Googleアカウントが必要です

プロジェクト作成

まず、firebaseにアクセスして右上の「コンソールへ移動」を押します。
すると「プロジェクトを追加」が出ると思うのでそれを押してください。

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

JavaScriptで非同期処理作ってみた

概要

セレクトボックスの値が非同期で変更するものを作ってみました。


1. セレクトボックスで群馬県を選択
2. 次のセレクトボックスには群馬県内の市だけが選択肢に入る

image.png

環境

Java13/JPA/JavaScript

参考

Ajaxを使った非同期通信
@ResponseBodyについて
ResponseEntityクラスについて

ソースコード

hello.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Ajax!</title>
</head>
<body>
    <form action="">
        <div class="form-group">
            <label th:for="ken"></label>
            <select id="ken" class="form-control">
            <option>選択してください</option>
            <option id="kenOption" th:each="ken : ${kenList}"  th:text="${ken.kenName}" th:value="${ken.kenCode}"></option>
            </select>
        </div>
        <div class="form-group">
            <label th:for="city"></label>
            <select id="city" class="form-control">
            <option>選択してください</option>
            </select>
        </div>
    </form>
    <script src="ajax.js"></script>
</body>
</html>
ajax.js
'use strict';

    //要素の取得
    const ken = document.getElementById('ken');
    const city = document.getElementById('city');

    // slectboxのchangeイベントで発火する関数
    function ajax() {

        //選択された県コードの取得
        let kenCode = ken.value;

        let request = new XMLHttpRequest();

        // onreadystatechangeは非同期。状態が変化するとコールバック関数が呼び出される。
        request.onreadystatechange = function() {

            //readyState4はレスポンスの受信完了
            if(request.readyState === 4) {

                // status200はサーバーとの通信OK
                if(request.status === 200) {

                    // レスポンスの取得
                    let resultJson = request.response;
                    console.log(resultJson);

                    let cityData;

                    // optionタグをresultJson配列の数だけ作る
                    for(let i = 0; i < resultJson.length; i++) {
                        cityData += `<option th:value="${resultJson[i].cityCode}">${resultJson[i].cityName}</option>`
                    }
                    city.innerHTML = cityData;
                }else {
                    city.innerHTML = '<option>通信失敗(´;ω;`)</option>';
                }
            }
        }
        request.open('GET', '/hello/ajax?kenCode=' + kenCode, true);
        request.responseType = 'json';
        request.send(null);
    }
    ken.addEventListener('change', ajax);
AjaxController.java
@Controller
public class AjaxController {
    @Autowired
    AjaxService ajaxService;

    // 画面表示
    @GetMapping("/hello")
    public String getHello(Model model) {
        List<AjaxEntity> kenList = ajaxService.getKenNameList();
        model.addAttribute("kenList", kenList);
        return "hello";
    }

    // 非同期通信。県コードを受け取ってcityリストを返す。
    @GetMapping("/hello/ajax")
    @ResponseBody
    public ResponseEntity<List<AjaxEntity>> postAjax(@RequestParam("kenCode") int kenCode) {

        List<AjaxEntity> list = ajaxService.getCityNameList(kenCode);
        try {
            //通信成功
            return new ResponseEntity<List<AjaxEntity>>(list, HttpStatus.OK);
        } catch(Exception e) {
            //通信失敗
            return new ResponseEntity<List<AjaxEntity>>(HttpStatus.BAD_REQUEST);
        }
    }
}
AjaxService.java
@Service
public class AjaxService {
    @Autowired
    AjaxRepository ajaxRepository;

    /**
     * 全県名と全県コードのリストを取得する
     */
    public List<AjaxEntity> getKenNameList(){

        List<String[]> list = ajaxRepository.getKenNameList();
        List<AjaxEntity> aeList = new ArrayList<>();

        for(String[] array : list) {
            int kenCode = Integer.parseInt(array[0]);
            String kenName = String.valueOf(array[1]);

            AjaxEntity ae = new AjaxEntity();
            ae.setKenCode(kenCode);
            ae.setKenName(kenName);
            aeList.add(ae);
        }
        return aeList;
    }


    /**
     * 同一県内の市をリストで返す
     *
     * 例:群馬県を選択すると、群馬県内の市リストが返る
     */
    public List<AjaxEntity> getCityNameList(int kenCode){

        List<String[]> list = ajaxRepository.getCityNameList(kenCode);
        List<AjaxEntity> aeList = new ArrayList<>();

        for(String[] array : list) {
            int cityCode = Integer.parseInt(array[0]);
            String cityName = String.valueOf(array[1]);

            AjaxEntity ae = new AjaxEntity();
            ae.setCityCode(cityCode);
            ae.setCityName(cityName);
            aeList.add(ae);
        }
        return aeList;
    }
}
AjaxRepository.java
@Repository
public interface AjaxRepository extends JpaRepository<AjaxEntity, Integer>{

    @Query(value = "select distinct kenCode, kenName from AjaxEntity a")
    List<String[]> getKenNameList();

    @Query(value = "select distinct cityCode, cityName from AjaxEntity a where kenCode = :kenCode")
    List<String[]> getCityNameList(@Param("kenCode") int kenCode);
}
AjaxEntity.java
@Data
@Entity
@Table(name="address")
public class AjaxEntity {

    @Id
    @Column(name="kencode")
    private Integer kenCode;

    @Column(name="citycode")
    private Integer cityCode;

    @Column(name="kenname")
    private String kenName;

    @Column(name="cityname")
    private String cityName;
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HTMLElement.styleではデフォルトのCSSシート記述情報は取れないので別の方法でとる

ちょっとだけハマったので備忘録も兼ねて。
html要素をjsで取得し、キーボードを叩くと対象のheightが10pxずつ増えるとします。

<!--html-->
<div class="box">
</div>

<style>
//css
.box{
    height: 100px;
    width: 100px;
    background-color:blue;
}
</style>

//javascript
let box = document.getElementsByClassName("box").item(0);
let body = document.getElementsByTagName('body').item(0);

//body内でキーボードが押されるとboxの高さが増える
body.addEventListener('keydown',function(){
   let boxheight = box.style.height;//現在の高さ
   box.style.height = boxheight + 10 + 'px';
});

こうすると、反応はしますがなんと多少の高さが10pxになってしまいます。
デフォルトでは100pxなので、 +10つまり110pxになるべきでは?ということで
console.log("現在の高さ" + boxheight);
してみると、何も出ず。情報が取れてません。

調べてみると、

htmlelement.styleでは、デフォルトでcssに記述されている情報は取得できないようです。

(※なお、box.style.height = boxheight + 10 + 'px';でjsで操作した後は、)
htmlelement.style(つまりbox.style.height)でも高さ(10px)が取得できます。
jsで一度取得した後は操作できるようですね。

対応方法

この場合、要素の高さは、
・対象.offsetHeight
・getComputedStyle(対象).style
などでとるとうまくいきます。

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

HTMLElement.styleではデフォルトのCSS記述情報は取れないので別の方法でとる

ちょっとだけハマったので備忘録も兼ねて。
html要素をjsで取得し、キーボードを叩くと対象のheightが10pxずつ増えるとします。

<!--html-->
<div class="box">
</div>

<style>
//css
.box{
    height: 100px;
    width: 100px;
    background-color:blue;
}
</style>

//javascript
let box = document.getElementsByClassName("box").item(0);
let body = document.getElementsByTagName('body').item(0);

//body内でキーボードが押されるとboxの高さが増える
body.addEventListener('keydown',function(){
   let boxheight = box.style.height;//現在の高さ
   box.style.height = boxheight + 10 + 'px';
});

こうすると、反応はしますがなんと高さが10pxになってしまいます。
デフォルトでは100pxなので、 +10つまり110pxになるべきでは?ということで
console.log("現在の高さ" + boxheight);
してみると、何も出ず。高さの情報が取れてません。

調べてみると、

htmlelement.styleでは、デフォルトでcssに記述されている情報は取得できないようです。

(※なお、box.style.height = boxheight + 10 + 'px';でjsで操作した後は、)
htmlelement.style(つまりbox.style.height)でも高さ(10px)が取得できます。
jsで一度取得した後は操作できるようですね。

対応方法

この場合、要素の高さは、
・対象.offsetHeight
・getComputedStyle(対象).style
などでとるとうまくいきます。

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

ES6で追加されたループ処理メソッドをまとめる①【forEach,map,filter,find,every,some】

ES6のループ処理メソッドをまとめる①【forEach,map,filter,find,everyとsome】

はじめに

ES6より追加されたループ処理のメソッドによって、ほとんどのfor文を直感的にスッキリに書けることが分かったのでアウトプットも兼ねて、記事にまとめてみることにしました。

※ES6は2015年から使えるようになった記法です。
対応していないブラウザもあるのでBabelなどのトランスパイラの使用をおすすめします。

これらのメソッドを使うメリット。

今回紹介していくメソッドはどれもfor文を使えば実装できます。
なのになんで、これらのメソッドを使うのか。
それは記述がスッキリすることで、誰にでも分かりやすくなるメリットがあります。

【例】 配列をループさせて中身をconsole.logする処理を
for文と、forEachメソッドの両方で書いてみました。

○ for

const array = ["zero","one","two"];

for(let i =0; i < array.length; i++){
  console.log( array[i]);
}

○ forEach

const array = ["zero","one","two"];

array.forEach( function(element){
console.log(element);
});

記述自体はあまり短くなってないですが、array.lengthやi++などがない分、情報が洗練されていて分かりやすいですね!
これが、今回まとめたメソッドを使用するメリットになります。

今回まとめるメソッドの一覧

  • forEach
  • map
  • filter
  • find
  • everyとsome

全てのメソッドの基本形

//基本形
const array = [配列]

array.メソッド(コールバック関数)
// このコールバック関数内で行いたい処理を書きます

forEach

forEachは、一番シンプルなループ処理メソッドです。
forEachが書けたら残りのメソッドは全て作れちゃいます

//例
//配列の中身を出力していく
array.forEach( function(element){
// elementには、配列の中身が渡ってきます
console.log(element);
});

map

mapは、配列の要素全てに処理を行い、新しい配列として生成するときに使用します。

//例
// 配列の中身を倍して、新しい配列に格納する処理
const array = [1, 2, 3];

const newArray = array.map(function (element) {
  return element * 2;
  //return で処理を返しているところに注意です!!
});

filter

filterは、配列の中身を特定の条件で絞り込むときに使用します。
コールバックの returnのあとには、条件式を記述します。

//例
// 配列の中から偶数の要素だけを取り出して新しい配列に格納する
const array = [1, 2, 3, 4, 5];

const newArray = array.filter(function (element) {
  return element % 2 === 0;
});

find

findは、配列の中から特定の要素を探し出すときに使用します。
return のあとには条件式を記述します。
filterと似ているんですが、大きな違いは
条件式がtrueとなった瞬間ループが終了するところです!
↓↓↓
複数の要素を抜き出すことはできませんが、
一意な要素を抜き出す際には、処理のオーバーヘッドを防いでくれる優秀なヤーツですね。

//例 上のfilterと同じ処理を行います。
const array = [1, 2, 3, 4, 5];

const newArray = array.find(function (element) {
  return element % 2 === 0;
});
//この場合の newArrayの中身は 2 です。
//element % 2 === 0; が trueとなったところでループが終了しているからです。

every と some

everyとsomeはこれまでの4つとは少し違います。
違う点は、処理の戻り値として真偽値を返すところです
everyは、配列が条件をすべて満たす場合にtrueを返す
someは、配列が条件を1つでも満たす場合にtrueを返す
というようになってます。

every

配列の要素全てが、条件を満たしているかチェックしたいときに使用します。

//例
//配列の要素が全て偶数かチェックする
const array = [2, 4, 6, 8, 10];

const result = array.every(function (element) {
  return element % 2 === 0;
});
if (result) {
  console.log("OK");
}
//OK がコンソールに出力されます

some

配列の要素どれか1つでも、条件を満たしているかチェックしたいときに使用します。

//例
//配列に偶数が含まれているかチェックする
const array = [1, 2, 3, 4, 5, 6];

const result = array.some(function (element) {
  return element % 2 === 0;
});
if (result) {
  console.log("OK");
}
//OK がコンソールに出力されます

おわりに

自分の学習した内容を、知識定着のためのアウトプットも兼ねて記事にしてみました。
①とした理由は②でreduceについて書きたいと思ったからです!

初学者なため間違っているところなどあるかもしれません。
もし間違いを見つけた方は、ご指摘してもらえると嬉しいです!

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

【Rails】js.erb で文字列の途中で改行する方法

背景

JSにRubyを埋め込みしていたのですが、長ったらしくなってしまったので、途中で改行したくなりました。

方法

行の最後に「\」をつける。

document.getElementById('messages').insertAdjacentHTML('afterbegin',
'<p id="<%= @message.id %>">\
    <span><%= @message.content %></span>\
    <span><%= @message.user.name %></span>\
    <span><%= @message.created_at %></span>\
 </p>');

「\」の入力方法は、macなら「option」+ 「¥」

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

[Rails + jQuery]初学者向けAjaxを取り敢えず飛ばしてみたい

課題

初学者が感覚掴むために取り敢えずAjax飛ばしてみたい。

例として以下フォームで入力された文字をサーバに送ってみる。

form.erb
<%= text_field_tag :myname %>
output
<input type="text" name="myname" id="myname">

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

結論

以下コードで取り敢えず飛ぶ。

ajax.js
$("#myname").change(function(){
  let name = $(this).val();
  $.ajax({
    type: 'GET',
    url: "/api/v1/users",
    data: { name: name },
    dataType: 'json'
    })
  .done(function (response) {
    console.log(response)
  }
  .fail(function (){
  });
})

受け口のRouteやControllerは一般的なRailsの範囲なので割愛します。

参考情報

Ruby on RailsのAjax処理のおさらい
https://qiita.com/ka215/items/dfa602f1ccc652cf2888

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

Javascriptの関数について

当たり前のように使っている"関数"

JavaScriptを学び始めて2ヶ月ですが、
あたらめて理解ができているのか?

アウトプットしながら整理したいと思います。

function fn(a,b,c){
  console.log(a,b,c);
}

fn(0,1,2); //0 1 2

関数についてですが、結果については言わずもがなこのような結果になります。ここから少し深堀します。

引数について

・引数は順番を意識する

引数について考えてみます。
もし引数で c を表示したい場合にはどうすれば良いか?

a,bの入力を省略することができるかと言うと出来ません。

例1:
fn(2);  // cに2を渡したい
// 結果: 2 undefind undefind
例2:
fn(2,3)  // bに2をcに3を渡したい
// 結果: 2 3 undefind

という感じで a から順番に値を渡している形になります。
つまり、JavaScriptの場合には引数は順番が重要と言うことになります。

つまり、cに値を渡したい場合には、

    a    b     c
fn(null, null, 1)  
//結果: null null 1

と言うような形で渡すことでcに問題なく値を渡すことが可能です。
余談ですが、空の設定する際は"nulll"を使用したほうがベターです。
"undefind"は、JavaScript側で自動で設定するものだから..と言うことです。

・引数は全部渡す必要はない

ここで一点気になることがあります。

fn(2);  // cに2を渡したい
// 結果: 2 undefind undefind

引数が1つでも関数が実行されていることです。
そうなんです。JavaScriptは関数宣言で引数を2つ定義していても実行する際は引数が一つで実行することが可能になります。

細かく言うと

fn(1)  =  fn(1,undefind,undedfind)

と言うような形で実行されます。
ただ、計算処理などを入れている場合は,

function abc(a, b, c) {
  console.log(a, b, c)  //1 undefind undefind
  const d = a * b + c;
  console.log(d);       // NaN: Not a Number 数値じゃないですよエラー
}

abc(1);   

と言うような結果になりますのでご注意ください。


今後引き続きアップデート予定です。


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

slick画面読み込み時に一瞬縦並びになるバグ対応

ロード時に一瞬縦並びになるのをCSSで解決する

JSを使用せずに解決する

  $(".main-slide").slick({
    adaptiveHeight: false,
    autoplay: true,
    dots: false,
    infinite: true,
    speed: 2000,
    slidesToShow: 1,
    arrows: true,
    prevArrow: '<p class="arrow-prev" href="#"></p>',
    nextArrow: '<p class="arrow-next" href="#"></p>'
  });
<div class="main-slide">
  <div><img src="https://placehold.jp/1000x250.png" alt="slide1"></div>
  <div><img src="https://placehold.jp/1000x250.png" alt="slide2"></div>
  <div><img src="https://placehold.jp/1000x250.png" alt="slide3"></div>
</div>

このソースから出力されたhtml

<div class="slider slick-initialized slick-slider">

slick-initializedとは、スライダーが初期化(読み込み完了)した時点で付与されるclass。
これを利用して、sliderと一緒にslick-initializedが付与されているか否かでスタイルを分けることで、一瞬縦並びになってしまう状態を防ぐ

CSSでクラスが付与された時に表示させるようにする

.slider{
  display: none;
}
.slider.slick-initialized{
  display: block; /*slick-initializedが付与されたら表示*/
}
.slider{
  opacity: 0;
  transition: opacity .3s linear;
}
.slider.slick-initialized{
  opacity: 1;
}
  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

手書き風アニメーション(vivus.js)

はじめに

vivus.jsという素晴らしい、プラグイン?を使用したのでメモとして残します。
まだ理解し切ってないので、本当にメモ書き程度のまとめです。

完成イメージ

0e9aa538d9f132f8a47f44da50c03897.gif

手順

illustratorで新規作成

・「base」「mask」のレイヤー作成
スクリーンショット 2021-01-23 23.16.39.png

・「base」レイヤーに文字を書く

・文字のオブジェクトの上で「右クリック」→「アウトラインを作成」

・そのまま(選択した状態で)「オブジェクト」→「複合パス」→「作成」

・「base」にロックをかけ、「mask」レイヤーに切り替える

・「ペンツール」で文字をなぞる
※この時、線どうしは被らない様にすると綺麗なアニメーションになる。交差点がある場合は、一旦線を切ってパスギリギリの幅の線を書くといい。

【ペンツールの使用前後の画面】
スクリーンショット 2021-01-23 23.26.38.png
スクリーンショット 2021-01-23 23.22.32.png

・最後にSVGに書き出す。

コーディング

・SVGをエディターで開く
※開いたら、タグ毎に改行すると見やすい

svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 533.98 208.63">
  <defs>
    <style>.cls-1{fill:#f7931e;}.cls-2,.cls-3,.cls-4,.cls-5,.cls-6,.cls-7,.cls-8{fill:none;stroke:#000;stroke-linecap:round;stroke-miterlimit:10;}.cls-2{stroke-width:15px;}.cls-3{stroke-width:14px;}.cls-4{stroke-width:8px;}.cls-5{stroke-width:13px;}.cls-6{stroke-width:5px;}.cls-7{stroke-width:17px;}.cls-8{stroke-width:6px;}</style>
  </defs>
  <g id="base">
    <path class="cls-1" d="M180.64,176.26c30.88,0,43.05,9.81,43.05,19.08,0,16-24.89,23.43-33.79,23.43-2.18,0-3.27-.55-3.27-1.27,0-1.09,1.63-1.82,4.72-2.36,11.81-2.73,27.25-9.27,27.25-19.44,0-7.63-10.72-11.08-33.42-11.08-37.79,0-96.64,25.61-96.64,49.77,0,7.63,4.9,9.63,17.07,9.63,15.08,0,35.79-6.18,53.23-6.18,18.53-.18,33.24,5.27,33.24,20.53,0,35.42-56.31,69.94-112.45,69.94-23.79,0-37.42-9.81-37.42-21.62,0-18.35,32.88-31.61,56-31.61,2.73,0,4.36.55,4.36,1.64s-1.63,2.36-5.63,2.9c-37.78,5.27-48.14,16.54-48.14,25.62,0,8.17,12.36,11.8,32,11.8C127.23,317,185.9,292,185.9,261.28c0-10.54-13.26-12.72-30.51-12.72-13.81,0-30.16,1.45-44.51,1.45C92.53,250,78,246.93,78,232.76,78,203.33,139.58,176.26,180.64,176.26ZM206.42,300.7c0-13.63,13.08-32,27.43-47.6a7.41,7.41,0,0,1,5.64-2.54c2.9,0,5.45,1.82,5.45,4.18a5.29,5.29,0,0,1-1.46,3.09c-10.72,12.35-25.25,29.06-25.25,45.23,0,3.81,1.27,6,4.91,6,9.08,0,20.52-9.26,32.88-27.61a2.27,2.27,0,0,1,1.81-1.27,1.87,1.87,0,0,1,1.82,2c0,2.72-19.8,32.51-39.42,32.51C211.69,314.68,206.42,310,206.42,300.7Zm65.22-95.92c3.27,0,4.54,1.82,4.54,3.82,0,5.26-8.72,14.71-15.26,14.71-3.27,0-4.36-2-4.36-4C256.56,214.23,264.55,204.78,271.64,204.78Zm31.61,109.9c-8.54,0-14.9-4.9-14.9-14.71,0-13.26,12.17-30.34,19.8-40.87-8.36,5.63-16.53,14-24.16,22.52-14.35,16.35-26,33.06-33.06,33.06-4.18,0-6.9-2.36-6.9-7.44,0-9.09,13.8-30.7,28.33-47.42a9.26,9.26,0,0,1,7.09-3.27c2.91,0,5.09,1.28,5.09,3.64a5.31,5.31,0,0,1-1.64,3.63c-15.08,16.89-23.61,31.79-23.61,36,0,.91.36,1.27.9,1.27,2,0,10.36-11.26,20.89-23.25,10.9-12.54,25.25-26.89,35.43-26.89,4.17,0,7.08,1.82,7.08,4.36a4.21,4.21,0,0,1-.91,2.36c-6.54,8.72-22.52,28.52-22.52,44.69,0,4.18,1.45,7.09,5.81,7.09,10.36,0,20.71-9.63,33.06-28a2.28,2.28,0,0,1,1.82-1.27,1.87,1.87,0,0,1,1.82,2C342.67,284.89,322.87,314.68,303.25,314.68ZM335.76,377c-11.63,0-23.62-7.45-23.62-18.35,0-20.16,26.53-21.07,56.5-33.42,2.36-7.45,5.27-15.26,8.54-23.25-7.81,8.35-16.35,14.53-24.53,14.53-11.08,0-17.25-7.81-17.25-19.44,0-22,24.34-47.41,47.77-47.41,10.9,0,15.62,4,19.44,8.36,4.18-4.91,8.35-8.36,11.81-8.36,2,0,2.9,1.45,2.9,3.82,0,8.72-20.53,30.88-32.51,42.14a106.63,106.63,0,0,0-5.45,24.71c1.81-1.09,3.81-2,5.63-3.09a116.41,116.41,0,0,0,38.51-35.79,2.51,2.51,0,0,1,2-1.27c1.09,0,1.63.91,1.63,2,0,2.18-4.9,9.62-14,18.53a143.74,143.74,0,0,1-26.88,20.52c-2.54,1.46-4.91,2.73-7.45,4-1.27,10.9-2.36,21.25-8.17,32.88C363.73,371.72,348.47,377,335.76,377Zm0-3.27c7.63,0,14.89-5.27,20.53-15.62,3.63-6.72,6.72-16.35,10.53-27.61C341.94,340.12,322.13,341,322.13,359,322.13,368.27,327.58,373.72,335.76,373.72Zm20-62.31c8,0,18-10.53,27.07-22.34a170.54,170.54,0,0,1,14.53-24.52c-1.63-5.82-5.81-11.45-14-11.45-17.25,0-35.24,26.16-35.24,44.87C348.11,307.42,350.29,311.41,355.74,311.41Zm97,5.82c-21.07,0-31.79-9.81-31.79-23.8,0-21.07,24.16-43.78,49.59-43.78,11.63,0,18,5.27,18,13.81,0,12.35-18.17,27.25-42.51,27.25a40.86,40.86,0,0,1-11.63-1.46,38.73,38.73,0,0,0-.36,4.54c0,11.27,6.9,18.71,20,18.71,20.53,0,34-11.62,47.05-31.06a2.28,2.28,0,0,1,1.82-1.27,1.86,1.86,0,0,1,1.81,2c0,2.9-10.17,15.08-16.89,21.43C478.17,312.69,466.18,317.23,452.74,317.23ZM435.3,285.07a34.89,34.89,0,0,0,6.18.55c19.8,0,36.51-13.63,36.51-25.25,0-5.09-2.91-6.9-8.54-6.9C456.37,253.47,439.84,268.18,435.3,285.07Zm92.28,29.61c-8.54,0-14-4.54-14-13.26,0-12.89,12.72-23.79,12.72-33.6,0-3.82-3.64-5.27-7.63-6.91-15.08,27.62-32.34,46.87-36.7,46.87-1.27,0-2-.54-2-1.45a2.81,2.81,0,0,1,1.27-2c10.9-8.72,23.07-27.07,33.79-45.05-2.91-1.82-5.45-4.36-5.45-9.08,0-6.36,6.35-12.9,10.71-12.9,2.73,0,4.36,2.36,4.36,6,0,3.27-1.63,7.27-4.36,12,8.36,3.64,17.44,4.36,17.44,13.26,0,10.9-12.9,21.8-12.9,34,0,4.54,1.28,6.72,6,6.72,8.17,0,20.16-9.44,32.52-27.79a2.27,2.27,0,0,1,1.81-1.27,1.87,1.87,0,0,1,1.82,2C567,284.89,547.2,314.68,527.58,314.68Z" transform="translate(-38.71 -173.21)"/>
  </g>
  <g id="mask">
    <path class="cls-2" d="M186.63,217.5s36.87-3,33.87-25-90-12-126,20,8,39,37,33,58-4,57,15-15,31-32,43-68,23-92,19c0,0-21-2-18-21s51-26,55-25" transform="translate(-38.71 -173.21)"/><line class="cls-2" x1="233.79" y1="33.29" x2="221.79" y2="47.29"/><path class="cls-3" d="M240.38,253.1s-38.29,40-26.88,54.4S242,296,242,296" transform="translate(-38.71 -173.21)"/><path class="cls-3" d="M281.57,256.81S250.12,287,251,309" transform="translate(-38.71 -173.21)"/><path class="cls-4" d="M257,308s45.54-52.79,56.27-54.9" transform="translate(-38.71 -173.21)"/><path class="cls-5" d="M318,255s-39.19,49.83-16.5,55.5" transform="translate(-38.71 -173.21)"/><path class="cls-6" d="M306.21,312.43s20.24-6,29.29-22.93" transform="translate(-38.71 -173.21)"/><path class="cls-7" d="M395.15,255s-28.65-12.5-44.65,18.5-8,36-8,36,6,5,10.52,3.82" transform="translate(-38.71 -173.21)"/><path class="cls-8" d="M360,314c-1.3-.73,2.3,2.38,16.5-15.5" transform="translate(-38.71 -173.21)"/><path class="cls-3" d="M413.32,253.47S391,277,382,297s-8,60-29,72-37,5-35-16,43.78-22.49,43.78-22.49" transform="translate(-38.71 -173.21)"/><path class="cls-6" d="M382,322s31.8-20,37.5-32.5" transform="translate(-38.71 -173.21)"/><path class="cls-5" d="M440.5,287.5s27,4.08,39.49-18-11.33-20.41-28.41-12.73" transform="translate(-38.71 -173.21)"/><path class="cls-2" d="M442.5,262.5s-18.74,14-15.37,36,33.08,17.8,43.72,13.4,35.86-30.36,42.65-46.4" transform="translate(-38.71 -173.21)"/><line class="cls-2" x1="480.79" y1="68.29" x2="476.79" y2="79.3"/><path class="cls-2" d="M525.5,260.5s10,4,5,13-20,39.83-4,37.91a46.59,46.59,0,0,0,38.68-31.24" transform="translate(-38.71 -173.21)"/>
  </g>
</svg>

以下雛形をコピペし「3項目」元データのSVGから情報を採取する。
viewBox=""コピペ
<g id="mask">~~~</g>カット&ペースト
xlink:hrefはSVGファイルまでのURIを入力

雛形
<svg xmlns="http://www.w3.org/2000/svg" viewBox="コピペでOK" class="mask" id="move">
    <defs>
        <mask id="clipmask">
            <!-- <g id="mask">~~~</g> をカット&ペースト -->
        </mask>
    </defs>
    <image xlink:href="SVGファイルのURIを入力" width="100%" height="100%" mask="url(#clipMask)"></image>
</svg>

・SVGファイルの<style></style>に書かれている.cls-1{~~~}以外の部分をカット
・CSSファイルにペースト
strokeのプロパティを#fffに変更

css(見やすいよう改行してます)
.cls-2,.cls-3,.cls-4,.cls-5,.cls-6,.cls-7,.cls-8{
    fill:none;
    stroke:#fff;
    stroke-linecap:round;
    stroke-miterlimit:10;
}
.cls-2{
    stroke-width:15px;
}
.cls-3{
    stroke-width:14px;
}
.cls-4{
    stroke-width:8px;
}
.cls-5{
    stroke-width:13px;
}.cls-6{
    stroke-width:5px;
}
.cls-7{
    stroke-width:17px;
}
.cls-8{
    stroke-width:6px;
}

・CDNを読み込み、JSをコピペ

html
<!-- Vivus JS CDN -->
<script src="https://cdn.jsdelivr.net/npm/vivus@latest/dist/vivus.min.js"></script>
<!-- Original JS -->
<script src="./js/main.js"></script>
js
new Vivus('move', {type: 'oneByOne',duration: 100,forceRender: false, animTimingFunction:Vivus.EASE_OUT})

おそらくこれで動くはず。

完成系コード

svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 533.98 208.63">
  <defs>
    <style>.cls-1{fill:#f7931e;}</style>
  </defs>
  <g id="base">
    <path class="cls-1" d="M180.64,176.26c30.88,0,43.05,9.81,43.05,19.08,0,16-24.89,23.43-33.79,23.43-2.18,0-3.27-.55-3.27-1.27,0-1.09,1.63-1.82,4.72-2.36,11.81-2.73,27.25-9.27,27.25-19.44,0-7.63-10.72-11.08-33.42-11.08-37.79,0-96.64,25.61-96.64,49.77,0,7.63,4.9,9.63,17.07,9.63,15.08,0,35.79-6.18,53.23-6.18,18.53-.18,33.24,5.27,33.24,20.53,0,35.42-56.31,69.94-112.45,69.94-23.79,0-37.42-9.81-37.42-21.62,0-18.35,32.88-31.61,56-31.61,2.73,0,4.36.55,4.36,1.64s-1.63,2.36-5.63,2.9c-37.78,5.27-48.14,16.54-48.14,25.62,0,8.17,12.36,11.8,32,11.8C127.23,317,185.9,292,185.9,261.28c0-10.54-13.26-12.72-30.51-12.72-13.81,0-30.16,1.45-44.51,1.45C92.53,250,78,246.93,78,232.76,78,203.33,139.58,176.26,180.64,176.26ZM206.42,300.7c0-13.63,13.08-32,27.43-47.6a7.41,7.41,0,0,1,5.64-2.54c2.9,0,5.45,1.82,5.45,4.18a5.29,5.29,0,0,1-1.46,3.09c-10.72,12.35-25.25,29.06-25.25,45.23,0,3.81,1.27,6,4.91,6,9.08,0,20.52-9.26,32.88-27.61a2.27,2.27,0,0,1,1.81-1.27,1.87,1.87,0,0,1,1.82,2c0,2.72-19.8,32.51-39.42,32.51C211.69,314.68,206.42,310,206.42,300.7Zm65.22-95.92c3.27,0,4.54,1.82,4.54,3.82,0,5.26-8.72,14.71-15.26,14.71-3.27,0-4.36-2-4.36-4C256.56,214.23,264.55,204.78,271.64,204.78Zm31.61,109.9c-8.54,0-14.9-4.9-14.9-14.71,0-13.26,12.17-30.34,19.8-40.87-8.36,5.63-16.53,14-24.16,22.52-14.35,16.35-26,33.06-33.06,33.06-4.18,0-6.9-2.36-6.9-7.44,0-9.09,13.8-30.7,28.33-47.42a9.26,9.26,0,0,1,7.09-3.27c2.91,0,5.09,1.28,5.09,3.64a5.31,5.31,0,0,1-1.64,3.63c-15.08,16.89-23.61,31.79-23.61,36,0,.91.36,1.27.9,1.27,2,0,10.36-11.26,20.89-23.25,10.9-12.54,25.25-26.89,35.43-26.89,4.17,0,7.08,1.82,7.08,4.36a4.21,4.21,0,0,1-.91,2.36c-6.54,8.72-22.52,28.52-22.52,44.69,0,4.18,1.45,7.09,5.81,7.09,10.36,0,20.71-9.63,33.06-28a2.28,2.28,0,0,1,1.82-1.27,1.87,1.87,0,0,1,1.82,2C342.67,284.89,322.87,314.68,303.25,314.68ZM335.76,377c-11.63,0-23.62-7.45-23.62-18.35,0-20.16,26.53-21.07,56.5-33.42,2.36-7.45,5.27-15.26,8.54-23.25-7.81,8.35-16.35,14.53-24.53,14.53-11.08,0-17.25-7.81-17.25-19.44,0-22,24.34-47.41,47.77-47.41,10.9,0,15.62,4,19.44,8.36,4.18-4.91,8.35-8.36,11.81-8.36,2,0,2.9,1.45,2.9,3.82,0,8.72-20.53,30.88-32.51,42.14a106.63,106.63,0,0,0-5.45,24.71c1.81-1.09,3.81-2,5.63-3.09a116.41,116.41,0,0,0,38.51-35.79,2.51,2.51,0,0,1,2-1.27c1.09,0,1.63.91,1.63,2,0,2.18-4.9,9.62-14,18.53a143.74,143.74,0,0,1-26.88,20.52c-2.54,1.46-4.91,2.73-7.45,4-1.27,10.9-2.36,21.25-8.17,32.88C363.73,371.72,348.47,377,335.76,377Zm0-3.27c7.63,0,14.89-5.27,20.53-15.62,3.63-6.72,6.72-16.35,10.53-27.61C341.94,340.12,322.13,341,322.13,359,322.13,368.27,327.58,373.72,335.76,373.72Zm20-62.31c8,0,18-10.53,27.07-22.34a170.54,170.54,0,0,1,14.53-24.52c-1.63-5.82-5.81-11.45-14-11.45-17.25,0-35.24,26.16-35.24,44.87C348.11,307.42,350.29,311.41,355.74,311.41Zm97,5.82c-21.07,0-31.79-9.81-31.79-23.8,0-21.07,24.16-43.78,49.59-43.78,11.63,0,18,5.27,18,13.81,0,12.35-18.17,27.25-42.51,27.25a40.86,40.86,0,0,1-11.63-1.46,38.73,38.73,0,0,0-.36,4.54c0,11.27,6.9,18.71,20,18.71,20.53,0,34-11.62,47.05-31.06a2.28,2.28,0,0,1,1.82-1.27,1.86,1.86,0,0,1,1.81,2c0,2.9-10.17,15.08-16.89,21.43C478.17,312.69,466.18,317.23,452.74,317.23ZM435.3,285.07a34.89,34.89,0,0,0,6.18.55c19.8,0,36.51-13.63,36.51-25.25,0-5.09-2.91-6.9-8.54-6.9C456.37,253.47,439.84,268.18,435.3,285.07Zm92.28,29.61c-8.54,0-14-4.54-14-13.26,0-12.89,12.72-23.79,12.72-33.6,0-3.82-3.64-5.27-7.63-6.91-15.08,27.62-32.34,46.87-36.7,46.87-1.27,0-2-.54-2-1.45a2.81,2.81,0,0,1,1.27-2c10.9-8.72,23.07-27.07,33.79-45.05-2.91-1.82-5.45-4.36-5.45-9.08,0-6.36,6.35-12.9,10.71-12.9,2.73,0,4.36,2.36,4.36,6,0,3.27-1.63,7.27-4.36,12,8.36,3.64,17.44,4.36,17.44,13.26,0,10.9-12.9,21.8-12.9,34,0,4.54,1.28,6.72,6,6.72,8.17,0,20.16-9.44,32.52-27.79a2.27,2.27,0,0,1,1.81-1.27,1.87,1.87,0,0,1,1.82,2C567,284.89,547.2,314.68,527.58,314.68Z" transform="translate(-38.71 -173.21)"/>
  </g>
</svg>
html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <!-- Google font -->
    <link rel="preconnect" href="https://fonts.gstatic.com">
    <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100;300;400;500;700;900&display=swap" rel="stylesheet">
    <!-- FontAwesome -->
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.1/css/all.css" integrity="sha384-vp86vTRFVJgpjF9jiIGPEEqYqlDwgyBgEF109VFjmqGmIY/Y4HV4d3Gp2irVfcrp" crossorigin="anonymous">
    <!-- Original CSS -->
    <link rel="stylesheet" href="./css/style.css">
</head>
<body>
    <svg viewBox="0 0 533.98 208.63" class="mask" id="move" >
        <!-- アニメーション画像 -->
        <defs>
            <mask id="clipMask">
                <!--マスクのpath-->
                <g id="mask"><path class="cls-2" d="M186.63,217.5s36.87-3,33.87-25-90-12-126,20,8,39,37,33,58-4,57,15-15,31-32,43-68,23-92,19c0,0-21-2-18-21s51-26,55-25" transform="translate(-38.71 -173.21)"/><line class="cls-2" x1="233.79" y1="33.29" x2="221.79" y2="47.29"/><path class="cls-3" d="M240.38,253.1s-38.29,40-26.88,54.4S242,296,242,296" transform="translate(-38.71 -173.21)"/><path class="cls-3" d="M281.57,256.81S250.12,287,251,309" transform="translate(-38.71 -173.21)"/><path class="cls-4" d="M257,308s45.54-52.79,56.27-54.9" transform="translate(-38.71 -173.21)"/><path class="cls-5" d="M318,255s-39.19,49.83-16.5,55.5" transform="translate(-38.71 -173.21)"/><path class="cls-6" d="M306.21,312.43s20.24-6,29.29-22.93" transform="translate(-38.71 -173.21)"/><path class="cls-7" d="M395.15,255s-28.65-12.5-44.65,18.5-8,36-8,36,6,5,10.52,3.82" transform="translate(-38.71 -173.21)"/><path class="cls-8" d="M360,314c-1.3-.73,2.3,2.38,16.5-15.5" transform="translate(-38.71 -173.21)"/><path class="cls-3" d="M413.32,253.47S391,277,382,297s-8,60-29,72-37,5-35-16,43.78-22.49,43.78-22.49" transform="translate(-38.71 -173.21)"/><path class="cls-6" d="M382,322s31.8-20,37.5-32.5" transform="translate(-38.71 -173.21)"/><path class="cls-5" d="M440.5,287.5s27,4.08,39.49-18-11.33-20.41-28.41-12.73" transform="translate(-38.71 -173.21)"/><path class="cls-2" d="M442.5,262.5s-18.74,14-15.37,36,33.08,17.8,43.72,13.4,35.86-30.36,42.65-46.4" transform="translate(-38.71 -173.21)"/><line class="cls-2" x1="480.79" y1="68.29" x2="476.79" y2="79.3"/><path class="cls-2" d="M525.5,260.5s10,4,5,13-20,39.83-4,37.91a46.59,46.59,0,0,0,38.68-31.24" transform="translate(-38.71 -173.21)"/></g>
            </mask>
        </defs>
        <!-- ベース画像URI -->
        <image xlink:href="./img/test.svg" width="100%" height="100%" mask="url(#clipMask)"></image>
    </svg>
    <!-- Vivus JS CDN -->
    <script src="https://cdn.jsdelivr.net/npm/vivus@latest/dist/vivus.min.js"></script>
    <!-- Original JS -->
    <script src="./js/main.js"></script>
</body>
</html>
css
.cls-2,.cls-3,.cls-4,.cls-5,.cls-6,.cls-7,.cls-8{
    fill:none;
    stroke:#fff;
    stroke-linecap:round;
    stroke-miterlimit:10;
}
.cls-2{
    stroke-width:15px;
}
.cls-3{
    stroke-width:14px;
}
.cls-4{
    stroke-width:8px;
}
.cls-5{
    stroke-width:13px;
}.cls-6{
    stroke-width:5px;
}
.cls-7{
    stroke-width:17px;
}
.cls-8{
    stroke-width:6px;
}
js
new Vivus('move', {type: 'oneByOne',duration: 100,forceRender: false, animTimingFunction:Vivus.EASE_OUT})

最後に

線のアニメーションも出来るらしい。自分で出来る様になったらまた書きます。

  • このエントリーをはてなブックマークに追加
  • 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

認証

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
ここまでエラーがなければつなぎ込みは完了です。

実装

仕様の確認

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

  • トップページ( 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に以下を追記します。

"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

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

Qiita で ES2015+ を意識せずにコーディングしている記事が 25% 程度ある

2021 年。JavaScript (ECMAScript) は常に進化し続けているが、初学者含めて日本の JavaScript 使用者は何割ぐらいの人が ES2015+ を意識してコーディングしているのか気になったので調べてみる。

動機

変数は var ではなく constlet を使った方がスコープを安心して使用できるし、関数は function App () {} ではなく const App = () => {} で良い。
分割代入やテンプレート文字列、スプレッド構文、map なんかも便利だと思う。
どんどん使っていけばいいし、新しく学ぶ人は当然 ES2015+ で学んだほうが良いと思う。
でも、それを知らずに学び始める人、知らずに居続ける人もいるのかもしれない、とふと気になった。

調査方法

Qiita に投稿された記事から ES2015+ を意識してコーディングしている/意識せずにコーディングしている記事数(※)を Qiita の検索機能から抽出し、母数に対する割合を求める。

ES2015+ を意識しているか否かは、記事内のコードにおいて、変数宣言時に ES2015+ で最も基本的な要素といえる const を使っているか否かを基準に判断する。(後述)

※ 記事数ではなく、そこからユーザ数を拾って調査した方が正確なのでそうしたかったが、時間の都合上で記事数とする。

調査対象

Qiita に投稿された記事の内、次の条件に合致するものを対象とする。

  • 作成が 2020 年の 1 年間( 2020-01-01 以降 2020-12-31 以前)の記事
  • タグに JavaScript が含まれる記事

ES2015+ を意識してコーディングしているか/していないかの基準

記事中のコードに varconst のどちらを使用しているか。

  • 母数: var または const が含まれる
  • ES2015+ を意識してコーディングしている: const が含まれる
  • ES2015+ を意識せずコーディングしている: var が含まれる かつ const が含まれない

調査結果

  • 母数(var または const が含まれる)
    • 5698 ( 4270 + 1428 ) 記事
    • 検索: ES2015+ を意識してコーディングしている、意識せずコーディングしている記事の合計
  • ES2015+ を意識してコーディングしている(const が含まれる)
    • 4270 記事 ... 全体の約 75 %
    • 検索: tag:JavaScript created:>=2020-01-01 created:<=2020-12-31 code:const
  • ES2015+ を意識せずコーディングしている(var が含まれる かつ const が含まれない)
    • 1428 記事 ... 全体の約 25 %
    • 検索: tag:JavaScript created:>=2020-01-01 created:<=2020-12-31 code:var -code:const

結論

調査結果から全体の約 25 % の記事は ES2015+ を意識せずコーディングしている記事と考えられる。

追伸

記事数ではなくユーザ割合をどなたか調査してみてほしいです。

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