- 投稿日:2021-01-24T23:51:31+09:00
ちょっとしたことを調べたいときに見るページを公開しました。
タイトル通り、ちょっとしたことを調べるときに使えそうなページを作りました。
- BMI調べたいとか
- テレビ買う時とかにインチって何センチだっけとか
- 今年西暦/和暦何年で干支は何で自分は何歳なんだっけ
とかたま〜〜に気になるようなことを知ることができます。
webページとして公開したいけど、サーバー立てるのは面倒だったので、github pagesで公開しました。
というわけで慣れ親しんだPythonは使えないので、
JavaScript素人/jQuery初見、の状態でなんとか色々調べながら作成しました。
jQueryについてはいまだによく分かっていません。
もちろんcssもよく知らないのでカスみたいな見た目してます。
こういうの追加してほしいとかあれば可能な限りで追加してみます。
あとcssもアドバイスあれば嬉しいです。
追記
内容なさすぎたし、コード書けって言われて確かにとなったので追記します。
コード全部載せるのはアレなんで、詳しく見たい方は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列表示の方法があれば教えてください。
- 投稿日:2021-01-24T23:34:57+09:00
アプリを一定時間操作しなかったときに認証画面を表示する[React / ReactNative / Expo]
この記事について
ユーザがアプリを一定時間操作していない場合に画面をロックし、PINコードや指紋・顔認証を求めるような機能をReactで実装してみます。
React Native + Expo でアプリを開発しているとして、認証機能自体はExpoのモジュール(Local Authentication)を使用します。
また、「ユーザが操作している」状態の定義は場合によるかと思いますが、ここでは画面内のどこかしらにタッチイベントが発生したかどうかで判定するとします。これをマウスイベントに置き換えるなどすれば、大枠の実装はWebなど他のコンテクストでも共通するものになるはずです。つまりReact Nativeに関してそれほど専門性のある記事ではなく、Reactの学習者に向けた内容になっています。
実装の流れ
1. インターフェースを考える
まず、この機能をコンポーネント化するとしたら、どのようなインタフェースにすればよいでしょう。要件からすれば、認証するまでアプリの機能を使用できなくする(ロックする)という以上、アプリの他の機能(ベースとなる機能)の存在を前提としています。
また、ロック画面を一番手前(z-index最大)に表示してアプリ全体を覆ってしまえば実質的に他の機能は使用できなくなりますが、場合によっては機能全体、あるいは一部分を非表示にしたりする、あるいは一部の処理を無効にするなどの必要が出てくることが想定できます。つまりベースとなる機能自体に影響を与えるポテンシャルを持っている必要がある。そのためには、アプリの比較的根本の部分で、ベースとなる機能を包括(ラップ)するようなコンポーネントになるはずです。
コンポーネントの名前を仮に
LockScreenProvider
とし、JSXで表記するとしたらこのようなイメージです。使用イメージ<LockScreenProvider> <App /> /* ベースとなる機能をchildrenとして渡す */ </LockScreenProvider>TypeScriptの
interface
としてコンポーネントのpropsを定義するとしたら以下のようになります。
ロック画面を表示するまでの時間を定義しつつ、propとして任意に渡せるようにもしてみます。Props定義interface Props { children: React.ReactElement; timeout?: number; // タイムアウト時間(最後に操作してからロック画面を表示するまでの時間,ms) } /** * デフォルトのタイムアウト時間(ms) */ const DEFAULT_TIMEOUT = 30000;さて、先ほど他の機能の「一部分を非表示にしたりする」と書きました。そもそもロック画面を不透明にしアプリを覆ってしまえば実質的にすべての他の機能が非表示になるため、必要となる場面は限られるかもしれませんが、もしこのような処理を行うためにはどうすればよいでしょうか。
一つの方法としては、子要素としてReact要素ではなく「React要素を返す関数」をとり、ロック状態を引数として渡すようなインターフェースが考えられます。使用イメージ(子要素として関数を渡す)<LockScreenProvider> {isLocked => <App isLocked={isLocked} />} </LockScreenProvider>Props定義(子要素として関数を渡す)interface Props { children: (isLocked: boolean) => React.ReactElement; timeout?: number; }子要素(例では
App
)はこのisLocked
の値によって表示や処理を変えればよいわけです。
コンポーネント単体で機能が完結するため使い勝手は良いですが、この記事ではReactのContext / Provider APIを使ったもう一つのパターンを採用し、以下のように実装してみます。Contextを作成し、そのContextを使用するためのフックを先ほどのLockScreenProviderのサブ機能としてexportしておきます。
LockScreenProvider.tsx(部分)/** * useIsLocked * 下階層でロック状態を参照できるようにContext化する */ const LockScreenContext = React.createContext<boolean>(false); export function useIsLocked() { return useContext(LockScreenContext); } /* 省略 */ const isLocked = true; // ロック状態を任意のロジックで管理する return ( <LockScreenContext.Provider value={isLocked}>{/* ProviderとしてContextの値をセットする */} {/* 省略(ここにロック画面などが入る) */} {children}{/* 子要素をそのまま返す */} </LockScreenContext.Provider> );下の階層でこのロック状態を取得するには、この
useIsLocked
を使用します。先ほどの子要素を関数としてとるパターンの場合は孫階層でロック状態を知ろうとするとpropsを順々に経由しなければなりませんが、Contextを使用することで、そのContextのProvider以下の階層であればどこでも直接的に値を参照できるようになります。
最初の例と同じく通常のReact要素として子要素を包みます。
Providerの使用イメージ(最初の例と同じ)<LockScreenProvider> <App /> </LockScreenProvider>下の階層(
App
の子孫要素)では、どこでも先ほどのuseIsLocked
を使用しロック状態を取得することができます。下の階層でContextの値を参照するimport { useIsLocked } from "./LockScreenProvider.tsx"; /* 省略 */ const isLocked = useIsLocked(); // ロック状態を取得する2. ロック状態をコントロールする処理
では、実際に「アプリを一定時間操作していない」ときにロック画面にする処理を実装していきます。
まずはロック状態をstateとして管理しましょう。
この値をロック画面の表示・非表示の制御に使用し、Contextの値としてコンポーネント配下に渡すことになります。LockScreenProvider.tsx(部分)// ロック状態 const [isLocked, setIsLocked] = useState<boolean>(false);一般的にJavaScriptにおいて、最後に何かをしてから一定時間経ったら、といった処理をする場合には
setTimeout
およびclearTimeout
を使用します。
より確実に、厳密な時間を見る必要がある場合はrequestAnimationFrame
で(およそ60FPSの)毎フレーム時刻を見て差分を測る方法を採りますが、ここではその用途からしてそこまでの精度は求めないということにします。まずは、後に
clearTimeout
でタイマーを破棄するための識別子として、setTimeout
の返り値を保持できるようにしておきます。
型はブラウザではnumber
ですが、ReactNativeの開発環境なのでNodeJS.Timeout
としています。一般的に、(Class Componentは例外として)コンポーネント内で値を保持するためには先ほどと同じように
useState
を使うので、素直にそうしておきましょう。この部分は後々修正します。LockScreenProvider.tsx(部分)// タイマーの返り値を保持する const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);「一定時間〜しなかった時に」の部分の処理を実装します。
以下のように、「最後に何かをする」というトリガーになる処理の際にclearTimeout
およびsetTimeout
を使用し、タイマーによる処理を破棄、再予約する形になります。LockScreenProvider.tsx(部分)// ロックする関数 const lock = useCallback(() => setIsLocked(true), []); // タイマー処理を更新(破棄・再予約)する const updateTimer = useCallback(() => { if (isLocked) { return; } // 既存のタイマーをクリア clearTimeout(timer); // タイマーを再予約 setTimer(setTimeout(lock, timeout)); }, [isLocked, timer, lock, timeout]); // ロックを解除する関数 const unlock = useCallback(() => { setIsLocked(false); updateTimer(); // 再度タイマーをセット }, [updateTimer]);
lock
、updateTimer
、unlock
という関数を用意し、それぞれuseCallback
によってメモ化しています。
lock
は単純にロック状態に変更(isLocked
をtrue
に)する処理、
updateTimer
は何かしら操作が行われた際に呼ばれタイマーを更新する処理、
unlock
はロック状態を解除(isLocked
をfalse
に)し、タイマーをまたセットする処理です。
メモ化しているので、第二引数に依存する変数をいれることによってきっちりと各関数自体の更新がかかるようにします(また、その依存関係によってこの定義順になっています)。ちなみに、このままではコンポーネントが最初にマウントされた際にタイマーがセットされません。
要件次第ですが、最初から何も操作していない場合にも一定時間後にロックする場合、以下のようにuseCallback
を使用し、マウント時にタイマーをセットする処理を追加します。LockScreenProvider.tsx(部分)// 最初にタイマーをセット useEffect(() => { // 第二引数に空の配列をいれているため、マウント時に呼ばれる setTimer(setTimeout(lock, timeout)); }, []);そして、肝心のイベントハンドリング、およびロック画面の定義を行います。
View
,Modal
,Text
などはReact Nativeの要素です。スタイリングなども適宜読みかえてください。
タッチイベントが発生した時に先ほどのupdateTimer
を呼び、タイマー処理によってセットされたisLocked
フラグを使ってロック画面(例ではModal
部分)の表示を切り替えます。
ロック画面には、解除(unlock
)するボタンも入れておきます。このボタンにはこの記事の第二のテーマとして後ほど認証機能をつけましょう。LockScreenProvider.tsx(部分)return ( <LockScreenContext.Provider value={isLocked}> <View style={styles.wrapper} onTouchStart={updateTimer} onTouchMove={updateTimer} onTouchEnd={updateTimer} > <Modal visible={isLocked} transparent> <View style={styles.modal}> <Text style={styles.title}> しばらく操作されていないため{"\n"} 画面がロックされています </Text> <TouchableOpacity onPress={unlock} style={styles.button}> <Text style={styles.buttonTitle}>ロックを解除する</Text> </TouchableOpacity> </View> </Modal> {children}{/* 子要素をそのまま */} </View> </LockScreenContext.Provider> );この時点でアプリを起動して確認してみます。
適当な子要素を作り表示しています。
タッチ操作を行い、一定時間(先ほどデフォルトの時間を30秒としました)そのまま放置すると、
ロック画面が表示されました。
「ロックを解除する」ボタンで解除し、また放置すると、再びロック画面になり、
子要素でuseIsLocked
を使用しロック状態を取得できているのも確認できました。
これで概ね処理の流れができました。ところで、このコンポーネントにはこの時点でひとつ問題点があります。
useState
で保持しているsetTimeout
の返り値。これはタッチ操作の度に書き換えられます。
試しにtimer
をコンソールに出してみると...
指でなぞるタッチ操作により連続で値が書き換わり、コンポーネントの関数自体が再評価されているのがわかります。
この値を画面に表示する必要があるような場合にはしょうがないので、適切にこのコンポーネント配下のレンダリングをチューニングする他ありませんが、このtimer
の値はclearTimeout
のために保持しているにすぎず、画面表示には何の必要もありません。パフォーマンスの観点から好ましくないことは明白です。では、この問題点を解決するにはどのような方法があるでしょうか。
ひとつには、あまり推奨されるべきではありませんが、この変数timer
をこの関数コンポーネントのスコープ外に定義してしまう方法があります。このコンポーネントをシングルトン(実行時単一のインスタンスしか存在しないクラス)として使用すると決めているのであれば有効な手段です。
この場合、timer
を更新したとしても関数は再評価されません。LockScreenProvider.tsx/* 省略 */ let timer: NodeJS.Timeout | null = null; /** * LockScreenProvider * [Context Provider] */ function LockScreenProvider({ children, timeout = DEFAULT_TIMEOUT }): React.FunctionComponent<Props> { /* 省略 */ // タイマー処理を更新(破棄・再予約)する const updateTimer = useCallback(() => { if (isLocked) { return; } // 既存のタイマーをクリア clearTimeout(timer); // タイマーを再予約 timer = setTimeout(lock, timeout)); }, [isLocked, lock, timeout]); /* 省略 */もう一つ、あまり良くない例を挙げます。
useState
を使いつつ以下のようにすればどうでしょうか?LockScreenProvider.tsxconst [timerObj] = useState<{ timer: NodeJS.Timeout | null }>({ timer: null }); // タイマー処理を更新(破棄・再予約)する const updateTimer = useCallback(() => { if (isLocked) { return; } // 既存のタイマーをクリア clearTimeout(timerObj.timer); // タイマーを再予約 timerObj.timer = setTimeout(lock, timeout); }, [isLocked, timerObj, lock, timeout]);
set
関数を使わず、timerObj
自体が参照しているオブジェクト自体は同じであり、その中身(プロパティ)を書き換えているだけなので、関数コンポーネントの再評価は行われません。ここでstateとして扱っている
timerObj
のように、中身だけを変化することができるオブジェクトのことをミュータブル(mutable)なオブジェクトと呼びます。反対に、中身を変化させることはできず、変化させるためには新しく作成したものに置き換えなければいけないような制約を持つオブジェクトをイミュータブル(immutable)なオブジェクトと呼びます。上記の実装では、
set
関数を使用せずにstateを書き換えている点や、stateとして扱うデータをミュータブルなものとして扱っている点が、Reactの作法からは外れてしまっています。ではどうすればよいかというと、構造的にはほとんど上記の例と変わりはないのですが...、
このような場合、つまりReactの作法に則った上でコンポーネント内部においてミュータブルなオブジェクトを保持したい場合には、下記のようにuseRef
を使用します。LockScreenProvider.tsxconst timerRef = useRef<NodeJS.Timeout | null>(null); // タイマー処理を更新(破棄・再予約)する const updateTimer = useCallback(() => { if (isLocked) { return; } // 既存のタイマーをクリア clearTimeout(timerRef.current); // タイマーを再予約 timerRef.current = setTimeout(lock, timeout); }, [isLocked, timerRef, lock, timeout]);refというと子コンポーネントやDOM要素などを参照するためのものというイメージがあるかもしれませんが、フックAPI以降その本質はこのように「ミュータブルなオブジェクト」であるという点にあります。
上記の例のように、refオブジェクトの
current
プロパティには任意のタイミングで値を上書きすることができます。
これにより、タッチイベントが発生するたびにタイマーの識別子を上書きしても関数コンポーネント自体の再評価は行われなくなります。また、
updateTimer
が置き換わるタイミングが変わったことから、unlock
関数と初期表示時のタイマーセット、ロック解除後のタイマー再セットのロジックをよりシンプルに書き換えることができます。LockScreenProvider.tsx// ロックを解除する関数 const unlock = useCallback(() => setIsLocked(false), []); // ロック解除時にタイマー処理を更新 // 条件からして初期表示時にも呼ばれる useEffect(() => { if (!isLocked) { updateTimer(); } }, [isLocked, updateTimer]);ではここまでで、「アプリを一定時間操作しなかったときに」ロック画面を表示するロジックは完成しました。Reactを使用したアプリケーション開発においてコンテクストに関わらず共通するような部分は以上になります。
3. 認証機能を実装する
Expoのモジュールを使用して認証画面を実装してみます。
まずはパッケージをインストールしましょう。インストール$ expo install expo-local-authenticationLockScreenProvider.tsx(部分)import * as LocalAuthentication from "expo-local-authentication";モジュールは認証画面を表示するメソッドに加えて以下のチェック用のメソッド3つから成ります。非常にシンプルで、パーミッションも必要ありません。
LocalAuthentication.hasHardwareAsync()
その端末において指紋認証あるいは顔認証のセンサーが使用できるかどうかを返します。
実際に指紋や顔データの登録がなくても、ハードウェア自体にセンサーがあればtrue
を返すようです。
LocalAuthentication.supportedAuthenticationTypesAsync()
その端末において指紋認証、顔認証、Iris(虹彩認証)の中で使用できるものを返します。
返り値は定数の配列になっています。
LocalAuthentication.isEnrolledAsync()
その端末に指紋認証、あるいは顔認証のデータが登録されているかどうかを返します。認証画面の表示には
LocalAuthentication.authenticateAsync(options)
を使用します。
生体認証用のデータが無い場合は自動的にパスコード(PINコード)認証にフォールバックされ、さらにパスコードが登録されていない場合には返り値の中にエラーメッセージ(passcode_not_set
)が入ります。
上記のチェック用メソッドによって認証方法を切り分けてもよいですが、使用可能な認証方法が自動的に選択されるようになっているため、特別な対応が必要であるとしたら生体認証用データもパスコードも登録されていない場合にどうするかという点のみでしょう。
その場合はそもそもロック画面を表示しない、あるいは独自の認証方法を適用するといったことが考えられます(この記事では割愛します)。では、これまでに作成した
LockScreenProvider
に認証機能をつけてみます。
先ほどのunlock
関数に以下のように処理を追加します。LockScreenProvider.tsx(部分)// センサーや生体認証データが無いために認証ができない場合の処理 const fallbackAuthentication = useCallback(() => { setIsLocked(false); // 仮でそのままロック解除とする }, []); // ロック/ロック解除処理 const lock = useCallback(() => setIsLocked(true), []); const unlock = useCallback(async () => { const hasHardware = await LocalAuthentication.hasHardwareAsync(); if (!hasHardware) { fallbackAuthentication(); return; } const { success, error } = await LocalAuthentication.authenticateAsync({ promptMessage: "ロック画面を解除します", }); if (success) { setIsLocked(false); } else if (error !== "user_cancel") { fallbackAuthentication(); } }, [fallbackAuthentication]);認証データがパスコードを含め何も無い場合にはフォールバックの処理を行うようにし、オプションとして
promptMessage
にメッセージを渡します。(その他のオプションはこちら)
ロック画面で解除するボタンを押すと、Android 指紋認証(PIN画面はセキュリティのためかスクショできず)
iOS パスコード
iOS Touch ID
このような認証画面が表示され、認証が成功するとロック画面が消えるようになりました。
Androidでは指紋認証かPINコードかユーザが選択でき(それゆえに、オプションのdisableDeviceFallback
は効果がありません)、iOSではTouch IDの登録が無ければパスコード入力画面、あればTouch IDのダイアログが表示されます。サンプルソース
以上でアプリを一定時間操作しなかったときに認証画面を表示する実装ができました。
この記事の実際のソースはこちらにまとめています。
https://github.com/mildsummer/expo-lock-screen-exampleコンポーネントの最終的な全ソースはこちら。
LockScreenProvider.tsx/** * LockScreenProvider * 一定時間操作していないときにロック画面を表示する */ import React, { useCallback, useContext, useEffect, useRef, useState } from "react"; import { Modal, Text, ViewStyle, StyleSheet, View, TextStyle, TouchableOpacity } from "react-native"; import * as LocalAuthentication from "expo-local-authentication"; // props // 型定義部 interface Props { children: React.ReactElement; timeout?: number; } // styles // 型定義部 interface Styles { modal: ViewStyle; title: TextStyle; button: ViewStyle; buttonTitle: TextStyle; wrapper: ViewStyle; } // スタイリング const styles: Styles = StyleSheet.create<Styles>({ modal: { flex: 1, width: "100%", justifyContent: "center", alignItems: "center", backgroundColor: "rgba(0, 0, 0, 0.8)" }, title: { marginBottom: 20, fontSize: 20, lineHeight: 20 * 1.4, color: "#FFFFFF", textAlign: "center", }, button: { padding: 12, }, buttonTitle: { fontSize: 20, color: "#007aff", }, wrapper: { flex: 1, width: "100%" }, }); /** * デフォルトのタイムアウト時間(ms) */ const DEFAULT_TIMEOUT = 3000; /** * useIsLocked * 下階層でロック状態を参照できるようにContext化する * (ロック状態のモーダルを半透明にしつつ、ロック状態ではユーザ状態を非表示にするなど) * [Context] */ const LockScreenContext = React.createContext<boolean>(false); export function useIsLocked() { return useContext(LockScreenContext); } /** * LockScreenProvider * [Context Provider] */ function LockScreenProvider({ children, timeout = DEFAULT_TIMEOUT }): React.FunctionComponent<Props> { // setTimeoutで返る値を保持 // (stateだとsetの際にrenderされてしまうがその必要が無いものはrefを使用) const timerRef = useRef<NodeJS.Timeout | null>(null); // ロック状態 const [isLocked, setIsLocked] = useState<boolean>(false); // センサーや生体認証データが無いために認証ができない場合の処理 const fallbackAuthentication = useCallback(() => { setIsLocked(false); // 仮でそのままロック解除する }, []); // ロック/ロック解除処理(memo化) const lock = useCallback(() => setIsLocked(true), []); const unlock = useCallback(async () => { const hasHardware = await LocalAuthentication.hasHardwareAsync(); if (!hasHardware) { fallbackAuthentication(); return; } const { success, error } = await LocalAuthentication.authenticateAsync({ promptMessage: "ロック画面を解除します", }); if (success) { setIsLocked(false); } else if (error !== "user_cancel") { fallbackAuthentication(); } }, [fallbackAuthentication]); // タイマー処理を更新する(memo化) // タッチ操作時に連続で呼ばれる const updateTimer = useCallback(() => { if (isLocked) { return; } // 既存のタイマーをクリア clearTimeout(timerRef.current); // タイマーを再セット timerRef.current = setTimeout(lock, timeout); }, [isLocked, timerRef, lock, timeout]); // ロック解除時にタイマー処理を更新 // 条件からして初期表示時にも呼ばれる useEffect(() => { if (!isLocked) { updateTimer(); } }, [isLocked, updateTimer]); return ( <LockScreenContext.Provider value={isLocked}> <View style={styles.wrapper} onTouchStart={updateTimer} onTouchMove={updateTimer} onTouchEnd={updateTimer} > <Modal visible={isLocked} transparent> <View style={styles.modal}> <Text style={styles.title}> しばらく操作されていないため{"\n"} 画面がロックされています </Text> <TouchableOpacity onPress={unlock} style={styles.button}> <Text style={styles.buttonTitle}>ロックを解除する</Text> </TouchableOpacity> </View> </Modal> {children} </View> </LockScreenContext.Provider> ); } export default React.memo(LockScreenProvider);追記(ロック状態の永続化)
このようなロジックでアプリをロックしたとしても、一度アプリを落として再起動するという方法で悪意のある操作をされてしまったら意味がありません。
そういった場合に対応するとしたら、以下のようにAsyncStorageと組み合わせてロック状態を永続化するといったことも必要になります。他に永続化の仕組みを使っていれば必要ありませんが、簡単にAsyncStorageを使うユーティリティ的なフックを書いてみます。
useStorage.tsximport { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; /** * useStateのようなI/FでAsyncStorageを使用するhook * @param {string} key Storageに保存するためのキー * @param {string} initialValue 初期値 */ export default function useStorage(key: string, initialValue: string): [string, Dispatch<SetStateAction<string>>] { const keyRef = useRef<string>(key); const [value, _set] = useState<string>(initialValue); // 最初にAsyncStorageから取得 useEffect(() => { AsyncStorage.getItem(key, (error, result) => { if (result !== null) { _set(result); } }); }, [key]); // 値をセットする関数 // AsyncStorageにも反映 const set = useCallback((newValue: string) => { _set(newValue); AsyncStorage.setItem(key, newValue); }, [key]); // 念のため、keyが変わった場合に元のkeyのvalueを削除 useEffect(() => { if (keyRef.current !== key) { AsyncStorage.removeItem(key); } keyRef.current = key; }, [keyRef, key]); return [value, set]; }
string
型でしか保存できない点が面倒ですがこのような形でisLocked
を保存します。これにより、ロック画面になった状態でアプリを再起動すると即時ロック状態が引き継がれるようになりました。LockScreenProvider.tsxconst STORAGE_KEY = "APP_LOCKED"; /* 省略 */ // ロック状態 const [isLockedStr, setIsLockedStr] = useStorage(STORAGE_KEY, "false"); const setIsLocked = useCallback((isLocked) => { setIsLockedStr(isLocked.toString()); }, []); const isLocked = isLockedStr === "true";
- 投稿日:2021-01-24T21:58:47+09:00
ワイ「ハスケル子ちゃん、ライブラリのラップって本当に意味あるの?」(やってみた)
きっかけ
@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設計
イメージ
各モジュールをどのような構造にするのかイメージを膨らませます。
各VueコンポーネントからAPIモジュールを呼び出します。このAPIモジュールは書籍APIのSDKの役割を担ってもらいます。
そして、APIモジュールはHTTPモジュールを呼び出します。
このHTTPモジュールが今回の課題であるライブラリをラップしているモジュールになります。
今回は実験なので、HTTPモジュールをjQuery用とaxios用にファイルを分割します。しかし、実際にはこのHTTPモジュールを編集してライブラリを切り替えるイメージです。コンポーネント作成
Vueのコンポーネントについては今回本題ではないので解説は省略します。
完成した画像は以下の通りです。jQueryでの実装
実装
ディレクトリ構造
/src /api /index.js /jquery /index.jsコード
jquery/index.jsimport $ 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.jsimport 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
メソッドを作成し、独自のレスポンスオブジェクトとして値を返すようにしました。動作確認
データもしっかり取得できています。登録側も問題ないです。
axiosでの実装
実装
本題です。jQueryからaxiosへモジュールを変えます。
src\axios\index.jsimport 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.jsimport 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
には影響を及ぼしていません。動作確認
この画像を見せても何が変わったんだ?といった感じですが、ちゃんと動いています。(信じてください)
まとめ・感想
このように、ライブラリをラップすると1か所(1ファイル)のみの修正でモジュールを変更することができました!
実際の現場で提案してもなかなか受け入れられることが少ないですが、実証できたおかげでより信ぴょう性が増したと思います。
また、ライブラリを置き換える可能性ってあるの?ということに関してですが、現状でjQueryがほかのライブラリに置き換わっているのを見ると、現在使用しているライブラリが将来的には置き換わる可能性は十分あるのではないのでしょうか。
そのときに、この設計でよかった!といえるような設計をしていきます。謝辞
今回、この題名にて記事にする際、やめ太郎さんが嫌な思いをしないのか確認させていただきました。
突然のメッセージにも関わらず、記事の内容にも目を通していただきありがとうございます。
この場を借りて御礼申し上げます。
- 投稿日:2021-01-24T21:01:38+09:00
TypeScriptでチェックボックスの状態と値を取得する方法
実現したいこと
チェックボックスにチェックをして、削除ボタンを押すと、チェックされた項目が一括削除されるという、よくある処理をTypeScriptで行いたい。
そのためには、チェックボックスにチェックの入った要素だけを取得する必要がある。結論
チェックボックスの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.tsconst 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.tsconst checkBoxes = document.getElementsByName('checkBox');次に、取得した要素(checkBoxes)の中から、チェックの入った要素のみを配列で返す関数を作ります。
ドキュメントにある通り、getElementsByName
はNodeList
を返すので、App.tsconst checkedArray = (checkboxes: NodeList): HTMLElement[] => { }引数を
NodeList
型に指定して、返り値をHTMLElement
の配列に指定します。引数として渡ってきたcheckboxesの中からチェックの入った要素のみを判別して配列に入れたいのですが、
App.tslet resultAry: HTMLElement[] = checkboxes.filter(checkbox => checkbox.checked) // => Property 'filter' does not exist on type 'NodeList'.
NodeList
にfileter
は使えないとエラーが出ます。なので、
App.tslet resultAry: HTMLElement[] = Array.prototype.slice.call(checkboxes).filter(checkbox => checkbox.checked)
Array.prototype.slice.call()
を使ってfilter
でchecked
の要素のみを配列に入れます。あとは結果の配列を
return
すればOK??App.tsconst checkedArray = (checkboxes: NodeList): HTMLElement[] => { let resultArray: HTMLElement[] = Array.prototype.slice.call(checkboxes).filter(checkbox => checkbox.checked) return resultArray }いざ実行
App.tslet 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
- 投稿日:2021-01-24T21:01:38+09:00
TypeScriptでチェックが入ったチェックボックスの値のみを取得する方法
実現したいこと
「チェックボックスにチェックをして、削除ボタンを押すと、チェックされた項目が一括削除される」という、よくある処理をTypeScriptで行いたい。
そのためには、チェックボックスにチェックの入った要素だけを取得する必要がある。結論
チェックボックスの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.tsconst 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.tsconst checkBoxes = document.getElementsByName('checkBox');次に、取得した要素(checkBoxes)の中から、チェックの入った要素のみを配列で返す関数を作ります。
ドキュメントにある通り、getElementsByName
はNodeList
を返すので、App.tsconst checkedArray = (checkboxes: NodeList): HTMLElement[] => { }引数を
NodeList
型に指定して、返り値をHTMLElement
の配列に指定します。引数として渡ってきたcheckboxesの中からチェックの入った要素のみを判別して配列に入れたいのですが、
App.tslet resultAry: HTMLElement[] = checkboxes.filter(checkbox => checkbox.checked) // => Property 'filter' does not exist on type 'NodeList'.
NodeList
にfileter
は使えないとエラーが出ます。なので、
App.tslet resultAry: HTMLElement[] = Array.prototype.slice.call(checkboxes).filter(checkbox => checkbox.checked)
Array.prototype.slice.call()
を使ってfilter
でchecked
の要素のみを配列に入れます。あとは結果の配列を
return
すればOK??App.tsconst checkedArray = (checkboxes: NodeList): HTMLElement[] => { let resultArray: HTMLElement[] = Array.prototype.slice.call(checkboxes).filter(checkbox => checkbox.checked) return resultArray }いざ実行
App.tslet 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.tsconst checkBoxes = document.querySelectorAll('input[type=checkbox]:checked');1行で書けた…
ここから値を取り出して配列にしたい時は、
forEachを使ったり、
App.tsconst checkBoxes = document.querySelectorAll('input[type=checkbox]:checked'); let values = []; checkBoxes.forEach(node => values.push(node.nodeValue)); console.log(values); // ["1", "3"]mapを使えばいい感じ。
App.tsconst 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
- 投稿日:2021-01-24T17:44:40+09:00
Reactでの認証時にJWTをCookieに設定する方法
SPAでの認証といえばJWTを使うことが多いと思いますが、
localStorageに保存するとセキュリティリスクが高いとかで、
CookieにHttpOnlyな値として保存するのが良いとしばしば言われることもあります。
今回はReact × ExpressでJWTをCookieに保存する具体的な方法を紹介します。(そもそもJWTを使うべきかとか、localStorageを使うことのリスクなどについては要件次第なのであまり言及しません)
調査にあたっては以下の記事を参考にしました。
React Authentication: How to Store JWT in a Cookie記事の方法そのままでは自分の環境では上手くいかなかったので、ハマりポイントも含めて手順を解説します。
最終的に出来上がったもの
JWTをCookieに保存する
https://github.com/Kanatani28/jwt-how-to-use/tree/fix_using_cookiesCSRF対策
https://github.com/Kanatani28/jwt-how-to-use/tree/fix_using_csrf_protection動作環境
以下のDockerイメージを使用して挙動を確認しました。
node:15.5.1-alpine3.12準備編
まずはlocalStorageにJWTを保存して動くサンプルアプリケーションを用意します。
上記の参考記事を見てもらっても良いですが、
こちらで用意した以下のリポジトリを見てもらっても良いです。
本記事ではこちらに準じて進めます。Reactの部分だけTypeScriptを使用 + Dockerを使った構成
https://github.com/Kanatani28/jwt-how-to-use(ちなみに自前でプロジェクトを作成したい場合は
create-react-app
でプロジェクトを作成して、各種ライブラリをインストールしてください。)ソースコードは以下のようになっています。
App.tsximport React, { useState } from 'react'; import axios from 'axios'; import './App.css'; const apiUrl = 'http://localhost:3001'; axios.interceptors.request.use( // allowedOriginと通信するときにトークンを付与するようにする設定 config => { const { origin } = new URL(config.url as string); const allowedOrigins = [apiUrl]; const token = localStorage.getItem('token'); if (allowedOrigins.includes(origin)) { config.headers.authorization = `Bearer ${token}`; } return config; }, error => { return Promise.reject(error); } ); type Food = { id: number description: string } function App() { const storedJwt = localStorage.getItem('token'); const [jwt, setJwt] = useState(storedJwt || null); const [foods, setFoods] = useState<Food[]>([]); const [fetchError, setFetchError] = useState(null); const getJwt = async () => { const { data } = await axios.get(`${apiUrl}/jwt`); localStorage.setItem('token', data.token); setJwt(data.token); }; const getFoods = async () => { try { const { data } = await axios.get(`${apiUrl}/foods`); setFoods(data); setFetchError(null); } catch (err) { setFetchError(err.message); } }; return ( <> <section style={{ marginBottom: '10px' }}> <button onClick={() => getJwt()}>Get JWT</button> {jwt && ( <pre> <code>{jwt}</code> </pre> )} </section> <section> <button onClick={() => getFoods()}> Get Foods </button> <ul> {foods.map((food, i) => ( <li>{food.description}</li> ))} </ul> {fetchError && ( <p style={{ color: 'red' }}>{fetchError}</p> )} </section> </> ); } export default App;server.jsconst express = require('express'); const jwt = require('express-jwt'); const jsonwebtoken = require('jsonwebtoken'); const cors = require('cors'); const app = express(); app.use(cors()); const jwtSecret = 'secret123'; app.get('/jwt', (req, res) => { // JWTを生成する(今回は固定値で作成している) res.json({ token: jsonwebtoken.sign({ user: 'johndoe' }, jwtSecret) }); }); app.use(jwt({ secret: jwtSecret, algorithms: ['HS256'] })); const foods = [ { id: 1, description: 'burritos' }, { id: 2, description: 'quesadillas' }, { id: 3, description: 'churos' } ]; app.get('/foods', (req, res) => { res.json(foods); }); app.listen(3001); console.log('App running on localhost:3001');アプリケーション概要
server.jsには
/jwt
と/foods
という2つのエンドポイントを用意しています。
/jwt
はJWTを、/foods
はJSONデータを返します。
App.tsxではボタンを2つ用意し、それぞれボタンを押したタイミングでサーバーと通信するようにしています。
docker-compose up
を実行するとlocalhost:3000
でReactのアプリケーションが立ち上がり、
その後docker-compose exec front node src/server.js
を実行すると
localhost:3001
でNode.jsのアプリケーションが立ち上がります。
localhost:3000
にアクセスすると以下のような画面が表示されるはずです。いきなりGet Foodsボタンを押すと401エラーが表示され、
Get JWTでJWTを取得後、Get Foodsボタンを押すと、今度は正常に通信できるはずです。localStorageを確認してみる
Chromeの開発者ツール > Applicationを開くとlocalStorageに取得したtokenが設定されているのが確認できます。
localStorageに保存されているので、当然JavaScriptで取得することができます。
localStorage.getItem("token")この状態があまりよろしくないので修正していきます。
修正編
JWTをCookieに保存する
まず最初にserver.jsのJWTを発行する部分を修正していきます。
そもそもCookieの仕組みって?
図にすると以下のようになります。
(知ってるよって人はスキップしてください)サーバーからのレスポンスヘッダーにSet-Cookieという値が設定されていた場合、
クライアントのCookieにその値がセットされます。
以降そのサーバーとの通信ではセットされたCookieの値が付与されることになります。
フルスタックなフレームワークだとこういった仕組みを提供しているものが多いです。Set-Cookieヘッダーを付与するようにする
Cookieをセットするためには、サーバーのレスポンスにSet-Cookieヘッダーを含める必要があります。
Cookieを使うため、JWT取得時に以下のようにSet-Cookieヘッダーを含めてレスポンスを返すようにします。server.jsapp.get('/jwt', (req, res) => { const token = jsonwebtoken.sign({ user: 'johndoe' }, jwtSecret); // Set-Cookieヘッダーにtokenをセットする処理 res.cookie('token', token, { httpOnly: true }); res.json({ token }); });今回はHttpOnlyをtrueとしているため、
document.cookie
のようなJavaScriptからはアクセスできず、
基本的にはHTTP通信するときのみ参照できるようになっています。
(HttpOnlyを設定していない場合はセキュリティ的にはlocalStorageに保存する方法と大差ないかと思います)CORS対応する(ハマりポイント)
こちらは元記事にはなかった手順になります。
SPAではよくある構成かと思いますが、今回はlocalhost:3000
とlocalhost:3001
と
クロスオリジンでアプリケーションを起動しています。
クロスオリジンでCookieを使用する場合、いくつか設定が必要になります。server.jsのcorsを設定している部分を以下のように修正します。
server.jsapp.use(cors({ credentials: true, origin: "http://localhost:3000" }));これで
localhost:3000
で起動しているアプリケーションともCookieをやり取りすることができるようになります。また、App.tsxの方にも以下を追記します。
App.tsxaxios.defaults.withCredentials = true;今回はサーバーとの通信にaxiosを使用していますが、
axiosはデフォルトではCookieを使う設定になっていないので、
上記のようにwithCredentialsをtrueにすることで通信時にCookieを送信できるようになります。ここまで設定できたら再度アプリケーションを動かしてみましょう。
Get JWTボタンを押すとJWTが取得でき、開発者ツールで確認すると
Cookieにtokenが設定できているはずです。Cookieに設定されたtokenを検証するようにする
server.jsでApp.tsxからのリクエスト時にCookieに設定されたtokenを検証する処理を追記・修正します。
まずは新しく
cookie-parser
というライブラリを追加します。docker-compose exec front yarn add cookie-parser次にserver.jsを以下のように修正します。
server.jsconst cookieParser = require('cookie-parser'); // 略 app.use(cookieParser()); app.use(jwt({ secret: jwtSecret, algorithms: ['HS256'], getToken: req => req.cookies.token }));expressではcookie-parserを使用することでRequestに含まれるCookieを簡単に取得できるようになります。
また、検証も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.jsapp.post('/foods', (req, res) => { foods.push({ id: foods.length + 1, description: 'new food' }); res.json({ message: 'Food created!' }); });実装は適当ですが、新しくFoodを追加するようなAPIができたイメージですね。
成功した場合はFood created!というメッセージが返ってきます。また、App.tsxの方から、POSTリクエストを送信するように修正します。
App.tsxfunction App() { // 略 const [newFoodMessage, setNewFoodMessage] = useState(null); const createFood = async () => { try { const { data } = await axios.post(`${apiUrl}/foods`); setNewFoodMessage(data.message); setFetchError(null); } catch (err) { setFetchError(err.message); } }; // 略 return ( <> // 略 <section> <button onClick={() => createFood()}> Create New Food </button> {newFoodMessage && <p>{newFoodMessage}</p>} </section> </> ); }CSRFトークンを利用する
expressではcsurfというライブラリを使うことで
手軽にCSRF対策をすることができます。
まずはライブラリを追加します。docker-compose exec front yarn add cookie-parser
/csrf-token
にCSRFトークンを取得するエンドポイントを設定します。server.jsconst csrf = require('csurf') // 略 const csrfProtection = csrf({ cookie: true }); app.use(csrfProtection); app.get('/csrf-token', (req, res) => { res.json({ csrfToken: req.csrfToken() }); });これでCSRFトークンを発行できるようになったので、
App.tsxから利用するようにします。App.tsxfunction App() { // 略 useEffect(() => { const getCsrfToken = async () => { const { data } = await axios.get(`${apiUrl}/csrf-token`); axios.defaults.headers.post['X-CSRF-Token'] = data.csrfToken; }; getCsrfToken(); }, []); // 略 }画面表示時にCSRFトークンを取得し、axiosに設定するようにしています。
これでCSRFの対策ができました。※ちなみにCSRFトークン取得時にもCookieの値が検証されるので、403エラーが出る場合はJWT取得後に画面を更新してからCreateしてみてください。今回は一画面にすべて詰め込んでいるのでこんな感じになってしまいます。
最後に
Cookieの仕組みやCORSについての理解があればフロントエンドがReactからVueになろうが
バックエンドがExpressから他のFWになろうが知識を流用できるはずです。また、localStorageでもCookieでもXSS対策がされていない場合、難易度に差はあれど盗難のリスクが発生するのは同じなので
そもそもXSS対策がされているかどうかのチェックは必須といえるでしょう。クロスサイトスクリプティング(XSS)対策としてCookieのHttpOnly属性でどこまで安全になるのか
高い保守性やUXを保持しつつ安全なアプリケーションを目指していきたいですね。
参考
React Authentication: How to Store JWT in a Cookie
クロスサイトでCookieが設定できない場合に確認すること
CORSまとめ
express.jsのcors対応
Express cors middleware
MDN Web Docs Set-Cookie
クロスサイトスクリプティング(XSS)対策としてCookieのHttpOnly属性でどこまで安全になるのか
- 投稿日:2021-01-24T17:40:06+09:00
え、まだpreventDefaultとstopPropagationをいちいち入力してるの??
今回はテクニック集として、独自のショートカットキーを設定していきます。
Javascriptのwindowオブジェクトを使う際にちゃんと
perventDefault
やstopPropagation
を使っていますか??これをやらないと、思わぬところで自分の意図しない関数が実行されたりということが発生します。
ただ、これを毎回入力するの結構めんどいですよね。
ここで、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
- 投稿日:2021-01-24T17:28:04+09:00
supabaseで簡単にログイン機能実装(Next.js+TypeScript)
はじめに
最近、supabaseという便利なものを見つけたので少し使ってみました!今回は認証だけ使っています。
今回のコード↓
https://github.com/NozomuTsuruta/next-supabase-auth-examplesupabaseとは
Firebaseのようにデータベースや認証を簡単に使えるサービスです。データベースはFirebaseはNoSQLなのに対し、supabaseはPostgreSQLが採用されています。個人的に新しく覚えることが少なくてわかりやすいです。比較的新しいのでFirebaseほどたくさんの機能はありませんが、今後どんどん追加されていくみたいです。
導入
まずはインストールします。
## npm npm install @supabase/supabase-js ## yarn yarn add @supabase/supabase-jshttps://app.supabase.io/にアクセスして、アカウントを作成し、新しいプロジェクトを作成します。
作成した後、そのプロジェクトを開き、SettingsのAPIのページに行くと、URLとAPI_KEYS(public)があると思います。
それをコピーして、
next.config.js
に追加します。next.config.jsmodule.exports = { env: { SUPABASE_URL: "[URL]", SUPABASE_KEY: "[PUBLIC_API_KEY]", }, };実際に使ってみる
supabaseの設定ファイル
下のように先ほど
next.config.js
に追加した値をここで使います。src/util/supabase.tsimport { createClient } from "@supabase/supabase-js"; export const supabase = createClient( process.env.SUPABASE_URL as string, process.env.SUPABASE_KEY as string );あとは、
src/util/supabase.ts
からsupabase
をインポートして簡単に使えます。Formコンポーネント
いろんなページで使う用に作ります。
src/components/Form.tsximport { FC } from "react"; type IProps = { onSubmit: any; buttonText: string; inputList: { name: string; ref: any; type: string; }[]; }; export const Form: FC<IProps> = ({ onSubmit, buttonText, inputList }) => { return ( <form onSubmit={onSubmit}> {inputList.map((props) => ( <label key={props.name}> <span>{props.name}</span> <input {...props} /> </label> ))} <button type="submit">{buttonText}</button> </form> ); };それぞれのページを簡単に作っていきます。react-hook-formを使っているので使い方がわからない方はみてみてください!
react-hook-formの使い方を解説 v6.13.0 useController追加!サインインページ
pages/signin.tsximport { NextPage } from "next"; import { useForm } from "react-hook-form"; import { Form } from "../components/Form"; import { supabase } from "../util/supabase"; type IForm = { email: string; password: string; }; const Signin: NextPage = () => { const { register, handleSubmit } = useForm<IForm>(); const handleSignin = ({ email, password }: IForm) => { supabase.auth.signIn({ email, password }); }; const inputList = [ { type: "email", name: "email", ref: register }, { type: "password", name: "password", ref: register }, ]; return ( <Form onSubmit={handleSubmit(handleSignin)} inputList={inputList} buttonText="サインイン" /> ); }; export default Signin;サインアップページ
サインアップ後にそのまま登録するか、メールアドレス確認するかどうかはsupabaseで設定できます。
src/pages/signup.tsximport { NextPage } from "next"; import { useForm } from "react-hook-form"; import { Form } from "../components/Form"; import { supabase } from "../util/supabase"; type IForm = { email: string; password: string; passwordConf: string; }; const Signup: NextPage = () => { const { register, handleSubmit } = useForm<IForm>(); const handleSignup = ({ email, password }: IForm) => { supabase.auth.signUp({ email, password }); }; const inputList = [ { type: "email", name: "email", ref: register }, { type: "password", name: "password", ref: register }, { type: "password", name: "passwordConf", ref: register }, ]; return ( <Form onSubmit={handleSubmit(handleSignup)} inputList={inputList} buttonText="サインアップ" /> ); }; export default Signup;パスワード再設定ページ
resetPasswordForEmail
の引数にとったメールアドレスに再設定用のメールが送信されます。src/pages/forgot.tsximport { NextPage } from "next"; import { useForm } from "react-hook-form"; import { Form } from "../components/Form"; import { supabase } from "../util/supabase"; type IForm = { email: string; }; const Forgot: NextPage = () => { const { register, handleSubmit } = useForm<IForm>(); const handleResetPassword = ({ email }: IForm) => { supabase.auth.api.resetPasswordForEmail(email); }; const inputList = [{ type: "email", name: "email", ref: register }]; return ( <Form onSubmit={handleSubmit(handleResetPassword)} inputList={inputList} buttonText="パスワード再設定メール送信" /> ); }; export default Forgot;ログイン後のページ
src/pages/index.tsximport { NextPage } from "next"; const Home: NextPage = () => { return <h1>Hello</h1>; }; export default Home;_app.tsx
onAuthStateChange
でログイン状態を監視できます。簡単にルーティングも実装しました。src/pages/_app.tsximport { AppProps } from "next/app"; import { supabase } from "../util/supabase"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; export default function App({ Component, pageProps }: AppProps) { const { pathname, push } = useRouter(); const [loading, setLoading] = useState(true); supabase.auth.onAuthStateChange((_, session) => { if (session?.user && (pathname === "/signin" || pathname === "/signup")) { push("/"); } else if (!session?.user && pathname !== "/signup") { push("/signin"); } }); useEffect(() => { (async () => { const user = supabase.auth.user(); if (user && (pathname === "/signin" || pathname === "/signup")) { await push("/"); } else if (!user && pathname !== "/signup") { await push("/signin"); } setLoading(false); })(); }, []); return ( <> {loading ? ( <h1>loading...</h1> ) : ( <> <button onClick={() => supabase.auth.signOut()}>ログアウト</button> <Component {...pageProps} /> </> )} </> ); }今回のコード↓
https://github.com/NozomuTsuruta/next-supabase-auth-example終わりに
ここまで読んでいただきありがとうございます!今回CSSを書かなかったので見た目はだいぶ酷いと思います?♂️
今回データベースの方は使いませんでしたが、とても便利なので使ってみてください!
- 投稿日:2021-01-24T16:38:32+09:00
Nextjsプロジェクトのディレクトリ構成例をさらしてみる
はじめに
今までreact単体のSPA構成でやっていたものの、webpackとかbabelの設定を試行錯誤しながら構築するのが面倒になったので、これを機にNextjsでスパッと構築できるようにテンプレートを作成しています。
その過程でディレクトリ構成を整理したので一例として共有します。
ディレクトリ構成
project_root/ ├── docs/ ├── public/ ├── src/ │ ├── api/ │ │ ├── generated/ │ ├── components/ │ │ ├── atoms/ │ │ ├── molecules/ │ │ ├── organisms/ │ │ └── templates/ │ ├── foundations/ │ ├── hooks/ │ ├── lib/ │ ├── pages/ │ ├── stores/ │ ├── styles/ │ └── utils/ └── types/
- docs/
ドキュメントを配置する。 仕様書やOpenApi定義もここに配置する。
- public/
静的ファイルを配置する。 Nextjsで定義されたディレクトリ。see: https://nextjs.org/docs/basic-features/static-file-serving
- src/api/
WebApi等の外部APIのクライアントを配置する。 自動生成されたApiのラッパーを作る時もここに配置する。
- src/api/generated/
OpenApi Generator等で自動生成されたクライアントを配置する。
- src/components/
UIコンポーネントを配置する。 Atomic Designの分類に基づいてコンポーネント振り分ける。
- src/components/atoms/
部品となる汎用UIコンポーネントを配置する。 単体で存在できるUIならここに配置する。
- src/components/molecules/
複数のUIコンポーネントを組み合わせたUIコンポーネントを配置する。 特定のコンテキストに依存しないようして再利用を考慮する。 UI制御のロジックが必要であればhooksを提供する。
- src/components/organisms/
特定のコンテキストに依存したUIコンポーネントを配置する。 再利用はあまり考えなくてもいい。 storesやapiに密結合していてもいい。
- src/components/templates/
ページ全体のスケルトンになるUIコンポーネントを配置する。 ページのアクセス制御やレイアウト等を含んだコンポーネント。
- src/foundations/
UIに直接関係しない機能のみのコンポーネントを配置する。 デザインを含まなければ、汎用的でもコンテキストに密結合でもどちらでもいい。
- src/hooks/
汎用的なreact hooksユーティリティを配置。 特定のコンテキストに依存しないようにする。
- src/lib/
モジュール化されたロジックを配置する。 lib間は依存してもいい。 それ以外のレイヤーには依存しないようにする。
- src/pages/
Nextjsでルーティングされるページコンポーネントを配置する。see: https://nextjs.org/docs/routing/introduction
- src/stores/
グローバル状態を管理するhooksを配置する。
- src/styles/
全体のスタイルを配置する。 テーマや汎用的なスタイル要素を定義する。
- src/utils/
ユーティリティロジックを配置する。
- types/
typescriptのカスタム型定義を配置する。おわりに
あくまで一例なのでプロジェクトに合わせて足したり引いたりしてください。
ディレクトリの定義も自分はこうしているというだけなので一番しっくりくるようにいい塩梅でやってください。あと、Nextjsはいいぞ!
react単体だとCode Splittingとか色々頑張ってパフォーマンス調整をしていたのにNextjsは適当に作ってもだいたい速い。
あとSSGに方向を振ったおかげでSPA用のビルド環境として使えるのが強い。まだSSRのフレームワークとして認知されているので導入提案しても最初はえぇ・・・?みたいな反応をされることもあるかと思います。
そんな時は圧を込めてNextjsはビルドツールです!!!と言い張りましょう。
- 投稿日:2021-01-24T16:28:07+09:00
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.csslabel.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”対象に変更
変更前
変更後
・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の場合には、右端にキャラットが付くのでお好みで。
属性bgcolorをstyleで代用
なぜか、HTMLでの属性bgcolorの指定が横取りされてしまうようで、style指定にしました。
変更前
index.html<td v-bind:bgcolor="color_value"> </td>変更後
index.html<td v-bind:style="{ 'background-color': color_value }"> </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.jstoast_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.jsdialog_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.jspanel_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.jstab_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やボタンと横並びに表示すると、縦方向に中央配置されていません。直すのが面倒なので直していません。
以上
- 投稿日:2021-01-24T16:18:16+09:00
素のJavaScriptでTVの放送用字幕を作ってみた話
TV放送用の字幕、作ってみたくありませんか?
JavaScript(以下JS)は万能言語として、不動の地位を築いていますが、テレビ放送用の字幕…… 正確にはスーパーインポーズ(以下スーパー)も、当然JSで作れます。
今日、突然「◯◯くん、△△局に行って、TVに流す字幕を作ってくれ。」という辞令が下りても、この記事を読んでおけば万全です。作るもの
ステップ
①必要なデータを集める。
②ウィンドウその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+CSSとcanvasの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が生成したスーパーがお茶の間に届いているはずです。
補足その1
②のところの補足になりますが、W2とW3の表示のタイムラグは、チラツキとなって現れます。
表示させるときの処理の重さや、使用している機材のスペックやコンディション等々、場合によってはどうしても上手くいかないみたいなことも出てくるかもしれませんが、それは放送側のディレクターさんやミキサーさんと相談すると、一回向こうで非表示にしてくれて、こちらの切り替えを行い、向こうで再表示というオペレーションをしてくれることもあります。大体、みなさん協力的なことが多いので、ぜひ相談してみてください。補足その2
この仕組を使った業務を最後にやったのは2019年の秋なので、現状とは異なる箇所があるかもしれません。
主に屋外での中継がメインの番組をお手伝いさせていただくことが多かったのですが、残念ながらこの状況になってからさっぱり相談がなくなってしまいました。
早くこの状況が終わり、また面白いことが世の中に溢れることを願って公開してみました。 みなさん一緒に頑張りましょう! ??
- 投稿日:2021-01-24T15:21:03+09:00
音楽+機械学習ハッカソン、BitRateの優秀プロジェクトが発表 音楽+機械学習の可能性を感じる7つのプロジェクトを解説
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を使用した音楽を一緒に生成して瞑想効果を高める目的の音楽マインドフルネスウェブアプリです。
ジャーナリングとは、書く瞑想とも呼ばれ、思い浮かんだ文章をただ文字として書き記す事で瞑想の様な効果を得る手法です。
ストレス軽減や、心を落ち着かせる効果があると言われています。文字を入力すると、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
ブラウザに描画されたグラフィックに合わせた音楽生成を行う
かなり本格的な描画が行われ、実装は高度なスキルが用いられている様に感じます。
音楽生成はMagentaのAI-Duet(自動伴奏ピアノアプリ)とPianoGenie(少ない鍵盤でピアノの88鍵盤全てを使用した様な演奏を実現するアプリ)を使用しており、MIDIデータはご自身で演奏した164曲のMIDIファイルを使用しているそうです。
最後にまとめ
いかがだったでしょうか?
7つのプロジェクト、全て素晴らしかったです。
素晴らしい試みであり、大成功だったのではないでしょうか。
とてもレベルが高く驚きましたし、いつかこれらのプロジェクトから花開くものがきっと現れると期待してしまいます。
コロナの影響でオンラインの開催だった様ですが、世界中から参加できる良いチャンスにもなったのではと思います。
私も日本でこの様なイベントを実現してみたいものです。
- 投稿日:2021-01-24T14:44:04+09:00
Chromebookでweb開発をしてみる( 1 )
はじめに
Chromebookはwebブラウザーのchromeをベースに開発されたOSですが、
使い方次第でいろいろなことができると思います。
今回はweb開発に目を向けてプログラムをしていきたいと思います。開発環境
chromebook 3100 2-in-1
Google cloud
firebase
※Googleアカウントが必要ですプロジェクト作成
まず、firebaseにアクセスして右上の「コンソールへ移動」を押します。
すると「プロジェクトを追加」が出ると思うのでそれを押してください。
- 投稿日:2021-01-24T14:17:19+09:00
JavaScriptで非同期処理作ってみた
概要
セレクトボックスの値が非同期で変更するものを作ってみました。
例
1. セレクトボックスで群馬県を選択
2. 次のセレクトボックスには群馬県内の市だけが選択肢に入る環境
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; }
- 投稿日:2021-01-24T13:49:26+09:00
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
などでとるとうまくいきます。
- 投稿日:2021-01-24T13:49:26+09:00
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
などでとるとうまくいきます。
- 投稿日:2021-01-24T12:34:05+09:00
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について書きたいと思ったからです!初学者なため間違っているところなどあるかもしれません。
もし間違いを見つけた方は、ご指摘してもらえると嬉しいです!
- 投稿日:2021-01-24T12:22:31+09:00
React JSとaws-amplify/authでCognitoにユーザー登録する
Cognitoユーザープールの作成
AWSのコンソールからCognito→ユーザープールの管理と進みます。
ユーザープールの作成を選択し、作成するユーザプールの設定に移ります。
今回はユーザープールの設定をカスタマイズせずにデフォルトの設定で作成しました。
画像の「デフォルトを確認する」→「プールの作成」で大丈夫です。カスタマイズする場合は、パスワードの文字数などのルールや、ユーザーに紐づく属性の設定などができます。
アプリクライアントの作成
アプリケーションからCognitoにアクセスするためにはアプリクライアントが必要になります。
今回作成するReactアプリでもアプリクライアントIDを利用するため、アプリクライアントを作成します。先程作成したユーザープールを選択肢、左カラムの「アプリクライアント」を選択します。
まず適当な、「アプリクライアント名」を設定します。
次に、「クライアントシークレットを生成」を無効にします。Javascriptからユーザー登録を行う際はこの設定が必要です。
他はデフォルトのママそのまま画面一番下の「アプリクライアントの作成」ボタンで作成しました。アイデンティティプールの作成
少しわかりにくいですが、画面上部の反転している「フェデレーティッドアイデンティティ」リンクをクリックします
その後ID プール名には適当な名前を入れて、下部の認証プロバイダーエリアでCognitoを選択肢、先ほど作成したユーザープールとアプリクライアントIDを設定して「プールの作成」をします。
Reactでのプロジェクトの作成
新規プロジェクトを作成
$ npx create-react-app cognito-test --template typescript $ npm install --save @aws-amplify/auth $ npm install --save @material-ui/core $ cd cognito-test $ yarn start実装
index.tsxでベタベタと認証情報を直書きしていますが、環境変数い入れて取得するようにしましょう。
index.tsximport React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; import Auth from '@aws-amplify/auth'; Auth.configure({ identityPoolId: 'ap-northeast-1:d7712efb-c630-4ce5-a7b3-a43e50bc424c', region: 'ap-northeast-1', userPoolId: 'ap-northeast-1_7lh5yibbX', userPoolWebClientId: '77b77bvidr3lbemks4t8j5cfl7', }); ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') ); reportWebVitals();App.tsximport React from 'react'; import './App.css'; import {CardContent, CardActions, TextField, Button} from '@material-ui/core'; import { Auth } from '@aws-amplify/auth'; interface Props {} interface State { username: string; email: string; password: string; } class App extends React.Component<Props, State> { constructor(props: Props) { super(props); this.state = { username: "", email: "", password: "" }; } render() { const signup = async () => { const data = { username: this.state.username, password: this.state.password, attributes: { email: this.state.email } }; try { const { user } = await Auth.signUp(data); alert('success!') } catch (error) { console.log('error signing up:', error); } } const setUsername = (value: string) => { this.setState({ username: value }); } const setEmail = (value: string) => { this.setState({ email: value }); } const setPassword = (value: string) => { this.setState({ password: value }); } return ( <div className="App"> <CardContent> <TextField onChange={(e) => setUsername(e.target.value)} type="text" label="Username" placeholder="Username" margin="normal" /><br/> <TextField onChange={(e) => setEmail(e.target.value)} type="email" label="Email" placeholder="xxx@xx.xx" margin="normal" /><br/> <TextField onChange={(e) => setPassword(e.target.value)} type="password" label="Password" placeholder="Password" margin="normal" /> </CardContent> <CardActions style={{justifyContent: 'center'}}> <Button variant="contained" size="large" color="secondary" onClick={signup}>Login </Button> </CardActions> </div> ); } } export default App;アプリからユーザー登録
ブラウザで http://localhost:3000 を開くと作成した画面が表示されます。
ここから、ユーザー名、Email、パスワードを入力して「LOGIN」ボタンを押します。
パスワードのルールはCognitoのデフォルトで、英数大文字小文字、記号が必須で8文字以上となっています。
その後Cognitoでユーザープールの「ユーザーとグループ」を見るとユーザーが作成されています。
- 投稿日:2021-01-24T11:28:38+09:00
【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」+ 「¥」
- 投稿日:2021-01-24T11:27:19+09:00
[Rails + jQuery]初学者向けAjaxを取り敢えず飛ばしてみたい
課題
初学者が感覚掴むために取り敢えずAjax飛ばしてみたい。
例として以下フォームで入力された文字をサーバに送ってみる。
form.erb<%= text_field_tag :myname %>output<input type="text" name="myname" id="myname">結論
以下コードで取り敢えず飛ぶ。
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
- 投稿日:2021-01-24T11:05:42+09:00
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);と言うような結果になりますのでご注意ください。
今後引き続きアップデート予定です。
- 投稿日:2021-01-24T09:18:54+09:00
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; }
- 投稿日:2021-01-24T04:44:37+09:00
[React Native] react-native-draggable-gridviewのセルサイズ変更
0. 背景
react-native-draggable-gridviewを使うことで、ドラッグ可能なリスト形式のビューを表示できる(FlatListにドラッグ機能を追加したようなもの)
しかし、デフォルトではpropsでwidthしか指定できず、セルサイズを正方形でしか表示できない(widthは縦、横両サイズに反映される)1. 目的
react-native-draggable-gridviewのセルサイズを長方形で表示
→propsでheightを指定できるようにする2. 環境
- React : 16.8.6
- React Native : 0.63.4
3. 準備
react-native-draggable-gridviewのインストール
npm install react-native-draggable-gridview
react-native-responsive-screenのインストール(端末画面の比率でセルサイズを指定できる)
npm install react-native-responsive-screen
4. ソースコード変更
[プロジェクト名]/node_modules/react-native-draggable-gridview/src/index.tsx
を書き換える( ページ末尾に記載 )
→index.tsxの中身をページ末尾のソースコードで全て書き換える5. 変更前(デフォルト)
App.jsimport React from 'react'; import { View, Text, Alert } from 'react-native'; import { useState } from "react"; import GridView from 'react-native-draggable-gridview' import { widthPercentageToDP as wp, heightPercentageToDP as hp, } from 'react-native-responsive-screen'; function App() { const [data, setData] = useState([ { id: 0, name: 'AAA' }, { id: 1, name: 'BBB' }, { id: 2, name: 'CCC' }, { id: 3, name: 'DDD' }, { id: 4, name: 'EEE' } ]) return ( <> <GridView data={data} numColumns={2} delayLongPress={150} // width={wp('80%')} renderItem={item => ( <View style={{ flex: 1, margin: 1, justifyContent: 'center', backgroundColor: 'cyan' }}> <Text style={{ textAlign: 'center' }}>{item.name}</Text> </View> )} onPressCell={item => Alert.alert(item.name)} onReleaseCell={item => setData(item)} keyExtractor={item => item.id} /> </> ) } export default App;
numColumns={2}, width指定なし numColumns={2}, width={wp('80%')} セルが正方形で描画される.
widthを指定した場合、縦横両方のサイズに反映される.6. 変更後
App.jsimport React from 'react'; import { View, Text, Alert } from 'react-native'; import { useState } from "react"; import GridView from 'react-native-draggable-gridview' import { widthPercentageToDP as wp, heightPercentageToDP as hp, } from 'react-native-responsive-screen'; function App() { const [data, setData] = useState([ { id: 0, name: 'AAA' }, { id: 1, name: 'BBB' }, { id: 2, name: 'CCC' }, { id: 3, name: 'DDD' }, { id: 4, name: 'EEE' } ]) return ( <> <GridView data={data} numColumns={2} // numColumns={1} delayLongPress={150} height={hp('20%')} // weight={hp('80%')} renderItem={(item, index) => ( <View style={{ flex: 1, margin: 1, justifyContent: 'center', backgroundColor: 'cyan' }}> <Text style={{ textAlign: 'center' }}>{item.name}</Text> </View> )} onPressCell={item => Alert.alert(item.name)} onReleaseCell={item => setData(item)} keyExtractor={item => item.id} /> </> ) } export default App;
numColumns={2}, height={hp('20%')} numColumns={1}, height={hp('20%')}, weight={hp('80%')} heightを設定することで縦方向だけサイズを変更可能.
また、heightと同時にweightも設定可能.7. ソースコード
index.tsx/** * react-native-draggable-gridview */ import React, { memo, useRef, useState, useCallback } from 'react' import { Dimensions, LayoutRectangle } from 'react-native' import { View, ViewStyle, TouchableOpacity } from 'react-native' import { Animated, Easing, EasingFunction } from 'react-native' import { ScrollView, ScrollViewProps } from 'react-native' import { PanResponder, PanResponderInstance } from 'react-native' import _ from 'lodash' const { width: screenWidth, height: screenHeight} = Dimensions.get('window') interface GridViewProps extends ScrollViewProps { numColumns?: number containerMargin?: ContainerMargin width?: number height?: number data: any[] activeOpacity?: number delayLongPress?: number selectedStyle?: ViewStyle animationConfig?: AnimationConfig keyExtractor?: (item: any) => string renderItem: (item: any, index?: number) => JSX.Element renderLockedItem?: (item: any, index?: number) => JSX.Element locked?: (item: any, index?: number) => boolean onBeginDragging?: () => void onPressCell?: (item: any, index?: number) => void onReleaseCell?: (data: any[]) => void onEndAddAnimation?: (item: any) => void onEndDeleteAnimation?: (item: any) => void } interface AnimationConfig { isInteraction?: boolean useNativeDriver: boolean easing?: EasingFunction duration?: number delay?: number } interface ContainerMargin { top?: number bottom?: number left?: number right?: number } interface Point { x: number y: number } interface Item { item: any pos: Animated.ValueXY opacity: Animated.Value } interface State { scrollView?: ScrollView frame?: LayoutRectangle contentOffset: number numRows?: number cellWidth?: number cellHeight?: number grid: Point[] items: Item[] animation?: Animated.CompositeAnimation animationId?: number // Callback ID for requestAnimationFrame startPoint?: Point // Starting position when dragging startPointOffset?: number // Offset for the starting point for scrolling move?: number // The position for dragging panResponder?: PanResponderInstance } const GridView = memo((props: GridViewProps) => { const { data, keyExtractor, renderItem, renderLockedItem, locked, onBeginDragging, onPressCell, onReleaseCell, onEndAddAnimation, onEndDeleteAnimation, ...rest } = props const numColumns = rest.numColumns || 1 const top = rest.containerMargin?.top || 0 const bottom = rest.containerMargin?.bottom || 0 const left = rest.containerMargin?.left || 0 const right = rest.containerMargin?.right || 0 const width = rest.width || screenWidth const height = rest.height || screenHeight // 追加 const activeOpacity = rest.activeOpacity || 0.5 const delayLongPress = rest.delayLongPress || 500 const selectedStyle = rest.selectedStyle || { shadowColor: '#000', shadowRadius: 8, shadowOpacity: 0.2, elevation: 10, } const [selectedItem, setSelectedItem] = useState<Item>(null) const self = useRef<State>({ contentOffset: 0, grid: [], items: [], startPointOffset: 0, }).current //-------------------------------------------------- Preparing const prepare = useCallback(() => { if (!data) return // console.log('[GridView] prepare') const diff = data.length - self.grid.length if (Math.abs(diff) == 1) { prepareAnimations(diff) } else if (diff != 0) { onUpdateGrid() } else if ( _.findIndex(self.items, (v: Item, i: number) => v.item != data[i]) >= 0 ) { onUpdateData() } }, [data, selectedItem]) const onUpdateGrid = useCallback(() => { // console.log('[GridView] onUpdateGrid') const cellWidth = (width - left - right) / numColumns self.cellWidth = cellWidth self.cellHeight = height self.numRows = Math.ceil(data.length / numColumns) const grid: Point[] = [] for (let i = 0; i < data.length; i++) { const x = (i % numColumns) * cellWidth const y = Math.floor(i / numColumns) * height grid.push({ x, y }) } self.grid = grid onUpdateData() }, [data, selectedItem]) const onUpdateData = useCallback(() => { // console.log('[GridView] onUpdateData') // Stop animation stopAnimation() const { grid } = self self.items = data.map((item, i) => { const pos = new Animated.ValueXY(grid[i]) const opacity = new Animated.Value(1) const item0: Item = { item, pos, opacity } // While dragging if (selectedItem && selectedItem.item == item) { const { x: x0, y: y0 } = selectedItem.pos const x = x0['_value'] const y = y0['_value'] if (!self.animation) pos.setValue({ x, y }) selectedItem.item = item selectedItem.pos = pos selectedItem.opacity = opacity self.startPoint = { x, y } } return item0 }) }, [data, selectedItem]) const prepareAnimations = useCallback( (diff: number) => { const config = rest.animationConfig || { easing: Easing.ease, duration: 300, useNativeDriver: true, } const grid0 = self.grid const items0 = self.items onUpdateGrid() const { grid, items } = self const diffItem: Item = _.head( _.differenceWith( diff < 0 ? items0 : items, diff < 0 ? items : items0, (v1: Item, v2: Item) => v1.item == v2.item ) ) // console.log('[GridView] diffItem', diffItem) const animations = (diff < 0 ? items0 : items).reduce((prev, curr, i) => { // Ignore while dragging if (selectedItem && curr.item == selectedItem.item) return prev let toValue: { x: number; y: number } if (diff < 0) { // Delete const index = _.findIndex(items, { item: curr.item }) toValue = index < 0 ? grid0[i] : grid[index] if (index < 0) { prev.push(Animated.timing(curr.opacity, { toValue: 0, ...config })) } } else { // Add const index = _.findIndex(items0, { item: curr.item }) if (index >= 0) curr.pos.setValue(grid0[index]) toValue = grid[i] if (diffItem.item == curr.item) { curr.opacity.setValue(0) prev.push(Animated.timing(curr.opacity, { toValue: 1, ...config })) } } // Animation for position prev.push(Animated.timing(curr.pos, { toValue, ...config })) return prev }, []) if (diff < 0) { self.items = items0 self.grid = grid0 } // Stop animation stopAnimation() self.animation = Animated.parallel(animations) self.animation.start(() => { // console.log('[Gird] end animation') self.animation = undefined if (diff < 0) { self.items = items self.grid = grid onEndDeleteAnimation && onEndDeleteAnimation(diffItem.item) } else { onEndAddAnimation && onEndAddAnimation(diffItem.item) } }) }, [data, selectedItem] ) const stopAnimation = useCallback(() => { if (self.animation) { self.animation.stop() self.animation = undefined } }, []) prepare() //-------------------------------------------------- Handller const onLayout = useCallback( ({ nativeEvent: { layout }, }: { nativeEvent: { layout: LayoutRectangle } }) => (self.frame = layout), [] ) const animate = useCallback(() => { if (!selectedItem) return const { move, frame, cellWidth } = self const s = cellWidth / 2 let a = 0 if (move < top + s) { a = Math.max(-s, move - (top + s)) // above } else if (move > frame.height - bottom - s) { a = Math.min(s, move - (frame.height - bottom - s)) // below } a && scroll((a / s) * 10) // scrolling self.animationId = requestAnimationFrame(animate) }, [selectedItem]) const scroll = useCallback( (offset: number) => { const { scrollView, cellWidth, numRows, frame, contentOffset } = self const max = cellWidth * numRows - frame.height + top + bottom const offY = Math.max(0, Math.min(max, contentOffset + offset)) const diff = offY - contentOffset if (Math.abs(diff) > 0.2) { // Set offset for the starting point of dragging self.startPointOffset += diff // Move the dragging cell const { x: x0, y: y0 } = selectedItem.pos const x = x0['_value'] const y = y0['_value'] + diff selectedItem.pos.setValue({ x, y }) reorder(x, y) scrollView.scrollTo({ y: offY, animated: false }) } }, [selectedItem] ) const onScroll = useCallback( ({ nativeEvent: { contentOffset: { y }, }, }: { nativeEvent: { contentOffset: { y: number } } }) => (self.contentOffset = y), [] ) const onLongPress = useCallback( (item: string, index: number, position: Point) => { if (self.animation) return // console.log('[GridView] onLongPress', item, index) self.startPoint = position self.startPointOffset = 0 setSelectedItem(self.items[index]) onBeginDragging && onBeginDragging() }, [onBeginDragging] ) const reorder = useCallback( (x: number, y: number) => { if (self.animation) return const { numRows, cellWidth, cellHeight, grid, items } = self let colum = Math.floor((x + cellWidth / 2) / cellWidth) colum = Math.max(0, Math.min(numColumns, colum)) let row = Math.floor((y + cellHeight / 2) / cellHeight) row = Math.max(0, Math.min(numRows, row)) const index = Math.min(items.length - 1, colum + row * numColumns) const isLocked = locked && locked(items[index].item, index) const itemIndex = _.findIndex(items, (v) => v.item == selectedItem.item) if (isLocked || itemIndex == index) return swap(items, index, itemIndex) const animations = items.reduce((prev, curr, i) => { index != i && prev.push( Animated.timing(curr.pos, { toValue: grid[i], easing: Easing.ease, duration: 200, useNativeDriver: true, }) ) return prev }, [] as Animated.CompositeAnimation[]) self.animation = Animated.parallel(animations) self.animation.start(() => (self.animation = undefined)) }, [selectedItem] ) //-------------------------------------------------- PanResponder const onMoveShouldSetPanResponder = useCallback((): boolean => { if (!self.startPoint) return false const shoudSet = selectedItem != null if (shoudSet) { // console.log('[GridView] onMoveShouldSetPanResponder animate') animate() } return shoudSet }, [selectedItem]) const onMove = useCallback( (event, { moveY, dx, dy }: { moveY: number; dx: number; dy: number }) => { const { startPoint, startPointOffset, frame } = self self.move = moveY - frame.y let { x, y } = startPoint // console.log('[GridView] onMove', dx, dy, moveY, x, y) x += dx y += dy + startPointOffset selectedItem.pos.setValue({ x, y }) reorder(x, y) }, [selectedItem] ) const onRelease = useCallback(() => { if (!self.startPoint) return // console.log('[GridView] onRelease') cancelAnimationFrame(self.animationId) self.animationId = undefined self.startPoint = undefined const { grid, items } = self const itemIndex = _.findIndex(items, (v) => v.item == selectedItem.item) itemIndex >= 0 && Animated.timing(selectedItem.pos, { toValue: grid[itemIndex], easing: Easing.out(Easing.quad), duration: 200, useNativeDriver: true, }).start(onEndRelease) }, [selectedItem]) const onEndRelease = useCallback(() => { // console.log('[GridView] onEndRelease') onReleaseCell && onReleaseCell(self.items.map((v) => v.item)) setSelectedItem(undefined) }, [onReleaseCell]) //-------------------------------------------------- Render const _renderItem = useCallback( (value: Item, index: number) => { // Update pan responder if (index == 0) { self.panResponder = PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => false, onMoveShouldSetPanResponder: onMoveShouldSetPanResponder, onMoveShouldSetPanResponderCapture: onMoveShouldSetPanResponder, onShouldBlockNativeResponder: () => false, onPanResponderTerminationRequest: () => false, onPanResponderMove: onMove, onPanResponderRelease: onRelease, onPanResponderEnd: onRelease, }) } const { item, pos, opacity } = value // console.log('[GridView] renderItem', index, id) const { cellWidth, grid } = self const p = grid[index] const isLocked = locked && locked(item, index) const key = (keyExtractor && keyExtractor(item)) || (typeof item == 'string' ? item : `${index}`) let style: ViewStyle = { position: 'absolute', width: cellWidth, height: cellHeight, } if (!isLocked && selectedItem && value.item == selectedItem.item) style = { zIndex: 1, ...style, ...selectedStyle } return isLocked ? ( <View key={key} style={[style, { left: p.x, top: p.y }]}> {renderLockedItem(item, index)} </View> ) : ( <Animated.View {...self.panResponder.panHandlers} key={key} style={[ style, { transform: pos.getTranslateTransform(), opacity, }, ]} > <TouchableOpacity style={{ flex: 1 }} activeOpacity={activeOpacity} delayLongPress={delayLongPress} onLongPress={() => onLongPress(item, index, p)} onPress={() => onPressCell && onPressCell(item, index)} > {renderItem(item, index)} </TouchableOpacity> </Animated.View> ) }, [selectedItem, renderLockedItem, renderItem] ) // console.log('[GridView] render', data.length) return ( <ScrollView {...rest} ref={(ref) => (self.scrollView = ref)} onLayout={onLayout} onScroll={onScroll} scrollEnabled={!selectedItem} scrollEventThrottle={16} contentContainerStyle={{ marginTop: top, marginBottom: bottom, marginLeft: left, marginRight: right, }} > <View style={{ height: top + self.numRows * self.cellHeight + bottom, }} /> {self.items.map((v, i) => _renderItem(v, i))} </ScrollView> ) }) /** * swap * @param array * @param i * @param j */ const swap = (array: any[], i: number, j: number) => array.splice(j, 1, array.splice(i, 1, array[j])[0]) export default GridView
- 投稿日:2021-01-24T01:46:52+09:00
手書き風アニメーション(vivus.js)
はじめに
vivus.js
という素晴らしい、プラグイン?を使用したのでメモとして残します。
まだ理解し切ってないので、本当にメモ書き程度のまとめです。完成イメージ
手順
illustratorで新規作成
・「base」レイヤーに文字を書く
・文字のオブジェクトの上で「右クリック」→「アウトラインを作成」
・そのまま(選択した状態で)「オブジェクト」→「複合パス」→「作成」
・「base」にロックをかけ、「mask」レイヤーに切り替える
・「ペンツール」で文字をなぞる
※この時、線どうしは被らない様にすると綺麗なアニメーションになる。交差点がある場合は、一旦線を切ってパスギリギリの幅の線を書くといい。・最後に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>jsnew 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; }jsnew Vivus('move', {type: 'oneByOne',duration: 100,forceRender: false, animTimingFunction:Vivus.EASE_OUT})最後に
線のアニメーションも出来るらしい。自分で出来る様になったらまた書きます。
- 投稿日:2021-01-24T01:15:45+09:00
React + Contentful で「おみくじアプリ」を作ってみる
はじめに
「Vueはちょくちょく使ってるけど、React触ったことないんだよなぁ」
「Contentful…なんだか今後使う事ありそうだし、使ってみたいなぁ」
そんなフロントエンジニアが、React + Contentful で "おみくじアプリ" を作った際の備忘録です。
やる事
- React導入
- Contentful導入
- つなぎ込み
- 実装
React導入
公式のドキュメント通り
npx create-react-app react-omikujiで環境が出来ます。(爆速!最高!)
cd react-omikujiで環境に移動して
npm startでローカル環境が起動します。
新しい React アプリを作る - React公式ドキュメントContentful導入
サインアップ
公式ページからサインアップします。
freeだとユーザー数やAPIコール数に制限がありますが、一旦freeにて。
実案件で使う際は課金が必須になると思います。スペース作成
今回は omikuji というIDでスペースを作成します。
おみくじ結果の登録
情報として以下が必要になります。
- タイトル (例. 大吉)
- 本文 (例. 最高にハッピーな1日になるでしょう)
なので、上記をコンテントモデルに追加します。
Field IDは
- タイトル => title
- 本文 => description
とします。
続いて、おみくじの結果が複数必要になるので登録していきます。
このような形で6つ、結果を登録します。
つなぎ込み
Contentfulの公式にReactでContentfulを使うチュートリアルがあるので、そちらを参考にReactでContentfulが使えるようつなぎ込みます。
Getting started with React and Contentful認証
https://graphql.contentful.com/content/v1/spaces/[YOUR_SPACE_ID]/explore?access_token=[YOUR_ACCESS_TOKEN]
上記に自身のスペースIDとアクセストークンを置き換えアクセスします。
するとGraphiQLの画面になるので、GraphQLクエリを以下の形式で記述します。{ omikujiCollection { items { title description } } }エラーがなければ認証完了です。
ReactでContentfulGraphQLを使用する
App.jsを以下に書き換えます。
App.jsimport {useState, useEffect} from "react"; import './App.css'; const query = ` { omikujiCollection { items { title description } } } ` function App() { const [page, setPage] = useState(null); useEffect(() => { window .fetch(`https://graphql.contentful.com/content/v1/spaces/[YOUR_SPACE_ID]/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer [YOUR_ACCESS_TOKEN]", }, body: JSON.stringify({ query }), }) .then((response) => response.json()) .then(({ data, errors }) => { if (errors) { console.error(errors); } setPage(data.omikujiCollection.items[0]); }); }, []); if (!page) { return "Loading..."; } return ( <div className="App"> <header className="App-header"> <p>{page.title}</p> <p>{page.description}</p> </header> </div> ); } export default App;これで
npm start
をすれば表示されたはずです。
ここまでエラーがなければつなぎ込みは完了です。実装
仕様の確認
まずどのような仕様のおみくじにするのか確認です。
- トップページ(
react-omikuji/
)で「今日の運勢を占う」ボタンを押下すると、react-omikuji/result
ページに遷移し、結果が表示される- ランダムで結果が表示される
上記が問題なければなんでも良しとします。
ルーティング設定
ページ遷移の挙動を実現するためにreact-router-domをインストールします。
npm i react-router-domコンポーネント追加
トップページで使う
Button
と、結果ページのResult
のコンポーネントを用意します。components/Button.jsimport React from "react"; import { Link } from 'react-router-dom' export default class Button extends React.Component { render() { return ( <Link to="/result">{this.props.text}</Link> ) } }components/Result.jsimport React from "react"; export default class Result extends React.Component { render() { return ( <div> <p>{this.props.title}</p> <p>{this.props.description}</p> <a href="/react-omikuji/">トップに戻る</a> </div> ) } }実装
App.jsを以下に書き換えます。
App.jsimport {useState, useEffect} from "react"; import { BrowserRouter as Router, Route } from 'react-router-dom'; import Button from './components/Button'; import Result from './components/Result'; import './App.css'; const query = ` { omikujiCollection { items { title description } } } ` function App() { const [page, setPage] = useState(null); useEffect(() => { window .fetch(`https://graphql.contentful.com/content/v1/spaces/[YOUR_SPACE_ID]/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer [YOUR_ACCESS_TOKEN]", }, body: JSON.stringify({ query }), }) .then((response) => response.json()) .then(({ data, errors }) => { if (errors) { console.error(errors); } const number = Math.floor( Math.random() * ((data.omikujiCollection.items.length - 1) + 1 - 0) ) + 0 setPage(data.omikujiCollection.items[number]); }); }, []); if (!page) { return "Loading..."; } return ( <div className="App"> <Router basename='/react-omikuji/'> <Route exact path='/' render={() => <Button text='今日の運勢を占う'/>} /> <Route exact path='/result' render={() => <Result title={page.title} description={page.description}/>} /> </Router> </div> ); } export default App;ルートディレクトリ変更
トップページを
react-omikuji/
とするためにpackage.jsonに以下を追記します。"homepage": "/react-omikuji/",完成
npm start
でローカル確認が問題なければ、npm run build
でビルドされます。あとがき
「いやいや、おみくじ判定のタイミング!」
「aタグで戻るんかい!」
などなどありますが、あくまで仕様が
- トップページ(
react-omikuji/
)で「今日の運勢を占う」ボタンを押下すると、react-omikuji/result
ページに遷移し、結果が表示される- ランダムで結果が表示される
上記が問題なければなんでも良しとします。
という事で…
トークン丸見え問題・APIコール数問題などなどがあるので、ヘッドレスCMS使うならSSG一択なんでしょうかね…
リンク
新しい React アプリを作る - React公式ドキュメント
Getting started with React and Contentful
- 投稿日:2021-01-24T01:11:08+09:00
Qiita で ES2015+ を意識せずにコーディングしている記事が 25% 程度ある
2021 年。JavaScript (ECMAScript) は常に進化し続けているが、初学者含めて日本の JavaScript 使用者は何割ぐらいの人が ES2015+ を意識してコーディングしているのか気になったので調べてみる。
動機
変数は
var
ではなくconst
やlet
を使った方がスコープを安心して使用できるし、関数はfunction App () {}
ではなくconst App = () => {}
で良い。
分割代入やテンプレート文字列、スプレッド構文、map
なんかも便利だと思う。
どんどん使っていけばいいし、新しく学ぶ人は当然 ES2015+ で学んだほうが良いと思う。
でも、それを知らずに学び始める人、知らずに居続ける人もいるのかもしれない、とふと気になった。調査方法
Qiita に投稿された記事から ES2015+ を意識してコーディングしている/意識せずにコーディングしている記事数(※)を Qiita の検索機能から抽出し、母数に対する割合を求める。
ES2015+ を意識しているか否かは、記事内のコードにおいて、変数宣言時に ES2015+ で最も基本的な要素といえる
const
を使っているか否かを基準に判断する。(後述)※ 記事数ではなく、そこからユーザ数を拾って調査した方が正確なのでそうしたかったが、時間の都合上で記事数とする。
調査対象
Qiita に投稿された記事の内、次の条件に合致するものを対象とする。
- 作成が 2020 年の 1 年間(
2020-01-01
以降2020-12-31
以前)の記事- タグに
JavaScript
が含まれる記事ES2015+ を意識してコーディングしているか/していないかの基準
記事中のコードに
var
とconst
のどちらを使用しているか。
- 母数:
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+ を意識せずコーディングしている記事と考えられる。
追伸
記事数ではなくユーザ割合をどなたか調査してみてほしいです。