- 投稿日:2020-12-18T23:54:17+09:00
Next.jsで`window is not defined` を解決する(依存ライブラリ対応)
- 投稿日:2020-12-18T23:40:18+09:00
Kotlin/JSでWebフロント開発をはじめよう
こんにちは、マヤミトです。
この記事はKotlin Advent Calendar 2020 18日目の記事です。
皆さんはKotlin/JSを使っていますか?まだまだ記事が少ないこともあり、どう始めればいいのかわからないという人もいるかもしれません。今回は、普段Kotlinを書いている人がKotlin/JSでWebフロント開発を始めるのに最低限必要な知識を身につけられることをゴールにこの記事を書いています。解説不足の部分があれば気軽にコメントしてください。Kotlin/JSについて
Kotlin/JSは、文字通りKotlinをJavaScriptにトランスパイルする技術です。
https://kotlinlang.org/docs/reference/js-overview.html
当然JVMの資産は使えませんが、その代わりにDOMの操作などをKotlinでType Safeに扱うことができます。
NodeJSの資産を使ってサーバーサイドの開発もできますが、今回の記事では特に触れないことにします(興味のある方は以前書いたこちらの記事をご覧ください)。Kotlin/JSを扱う方法は大きく分けて2種類あり、通常のWebフロント開発のようにnpmのプロジェクトとして作成する方法と、通常のKotlinでの開発のようにGradleのプロジェクトとして作成する方法があります。古い記事ではnpmを使う方法が多くヒットしますが、最近だとGradle Pluginを使う後者の方法が公式では推奨されており、今回はそちらの方法を解説します。
今回使用したバージョンなど
IntelliJ IDEA Ultimate 2020.3
Kotlin 1.4.21
Gradle 6.7Kotlin/JSは情報が古くなるサイクルが早いので、この記事の通りにやっても動かない場合は最新の情報を公式ドキュメントなどに直接探しに行くことをオススメします(まあその公式ドキュメントもたまに古かったりしますが……)
簡単なプロジェクトを作ってみる
プロジェクトのセットアップ
IntelliJ IDEAの
New Project
のGradle
からKotlin/JS for browser
を選択し、新規プロジェクトを作成します。
この際、Kotlin DSL build script
にチェックを付けることをおすすめします。
main.ktimport kotlinx.browser.document fun main() { document.write("Hello, world!") }index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>HelloKotlinJS</title> <script src="HelloKotlinJS.js"></script> </head> <body> </body> </html>build.gradle.ktsplugins { id("org.jetbrains.kotlin.js") version "1.4.21" } group = "com.yt8492" version = "1.0.0" repositories { mavenCentral() } dependencies { implementation(kotlin("stdlib-js")) } kotlin { js { browser { webpackTask { cssSupport.enabled = true } runTask { cssSupport.enabled = true } testTask { useKarma { useChromeHeadless() webpackConfig.cssSupport.enabled = true } } } binaries.executable() } }ページに
Hello, world!
と表示されるだけの簡単なコードです。
デフォルトでは、プロジェクト名.js
をHTMLから読み込んでいます。これが実際にビルドして吐き出されるファイル名です。任意のファイル名に設定したい場合、build.gradle.kts
に設定を追記します。build.gradle.ktskotlin { js { browser { webpackTask { cssSupport.enabled = true outputFileName = "main.js" // 追加 } runTask { cssSupport.enabled = true outputFileName = "main.js" // 追加 } testTask { useKarma { useChromeHeadless() webpackConfig.cssSupport.enabled = true webpackConfig.outputFileName = "main.js" // 追加 } } } binaries.executable() } }実行してみる
プロジェクトルートで以下を実行します。
./gradlew browserRun --continuousするとブラウザが立ち上がり、実際にページが表示されると思います。
--continuous
オプションはホットリロードを有効にします。開発中はこのオプションをつけて実行すると便利です。ビルドしてみる
プロジェクトルートで以下を実行します。
./gradlew browserWebpack
プロジェクトルート/build/distributions/
以下にトランスパイルされたJavaScriptのファイルとresources
以下のファイルが吐き出されるので、index.html
をブラウザで開くと、先程と同じページが表示されると思います。DOMの操作をしてみる
デフォルトでは単純に
Hello, world!
と表示するだけでしたが、ボタンをクリックしたらテキストが表示されるようなコードを書いてみましょう。index.html<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Hello Kotlin/JS</title> </head> <body> <button id="greetButton">Greet</button> <h1 id="greetText"></h1> </body> <script src="main.js"></script> </html>main.ktimport kotlinx.browser.document import org.w3c.dom.HTMLButtonElement import org.w3c.dom.HTMLHeadingElement fun main() { val greetButton = document.getElementById("greetButton") as HTMLButtonElement val greetText = document.getElementById("greetText") as HTMLHeadingElement greetButton.addEventListener("click", { greetText.textContent = "Hello, Kotlin/JS!" }) }
Greet
ボタンを押すと、以下のようにHello, Kotlin/JS!
と表示されると思います。
document.getElementById
で要素を取得して操作しているのがわかると思います。Kotlin/JSで使えるライブラリ集
今回の記事の目的はKotlin/JSでの開発に最低限必要な知識を身に着けてもらうことなので、各種ライブラリの使い方はここでは解説しませんが、自分がよく使うライブラリを紹介したいと思います。
kotlin-react
文字通り、KotlinでReactを扱うことができるようになるラッパーライブラリです。Kotlin/JS向けのJavaScriptラッパーライブラリ集kotlin-wrappersのうちの一つです。
https://github.com/JetBrains/kotlin-wrappers/blob/master/kotlin-react/README.md
最新版ではReact v17に対応していて、hooksも普通に使えます。
kotlin-wrappersリポジトリには、React Routerのラッパーやstyled-componentsのラッパーなどもあります。それらと組み合わせて使うと良いでしょう。Ktor Client
Kotlin/MPP対応のHTTP Clientライブラリです。当然Kotlin/JSでも使うことができます。
https://ktor.io/docs/clients-index.htmlKlock
Kotlin/MPP対応の時間を扱うライブラリです。Kotlin/JVMではJavaのDate型などが使えましたが、当然Kotlin/JSでは使えないので、こういったライブラリを使うと良いでしょう。
https://ktor.io/docs/clients-index.html終わりに
今回はKotlin/JS初心者向けの記事でした。Kotlin好きな皆さんがこの記事を読んでKotlin/JSを始めてくれると嬉しいです。
最近のKotlin界隈ではKMMが盛り上がりを見せていますが、Kotlin/JSも流行るといいなぁ……と思いながら地道に活動をしています。興味のある方はぜひ一度試してみてほしいです。
- 投稿日:2020-12-18T23:31:49+09:00
React Hooksについてと、便利な独自フックのご紹介
はじめに
この記事では、React Hooksについての簡単なご紹介と、React Hooksを使った便利な独自フックのご紹介をしたいと思います。
React Hooksとは
フック (hook) は React 16.8 で追加された新機能です。
state などの React の機能を、クラスを書かずに使えるようになります。(参考:フックの導入 - React)ボタンをクリックするとカウントが増えていくコードを、フックを使った場合と使っていない場合で比べてみましょう。
■ フックを使わない場合
class Example extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; } render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={() => this.setState({ count: this.state.count + 1 })}> Click me </button> </div> ); } }■ フックを使った場合
import React, { useState } from 'react'; function Example() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> );このように、シンプルな記述で実現できます。
フックが登場するまでは、クラスコンポーネントで状態管理(state)を行うのが一般的でしたが、フックの登場により関数コンポーネントでも状態を扱いやすくなりました。
フック API リファレンスをみると、Reactでは以下のフックが用意されています。
- 基本のフック
- useState
- useEffect
- useContext
- 追加のフック
- useReducer
- useCallback
- useMemo
- useRef
- useImperativeHandle
- useLayoutEffect
- useDebugValue
基本的なフックの一つである、
useState
の使い方をご紹介します。useStateの使い方
状態(state)の管理にはステートフックを使います。
ステートフックはuseState
を呼び出すことで使用できます。引数に初期値を指定し、戻り値としてstateとそれを更新するための関数をペアで返します。
例えば、0から始まるカウントをstateとし、ボタンをクリックするごとにstateを更新するような場合、次のように使用します。// count: stateの現在の値 // setCount: stateを更新するための関数 // 0: 初期値 const [count, setCount] = useState(0); return ( <div> <label>{count}</label> <button onClick={() => setCount(count + 1)}>カウントアップ</button> </div> );この他にも、useEffectやuseContextなど便利なフックはありますが、この記事では割愛させていただきます。
独自フック
自分独自のフックを作成することで、コンポーネントからロジックを抽出して再利用可能な関数を作ることが可能です。
独自で作成したフックの中で、使えそうなフックを紹介したいと思います。
独自で作成したフック
SPA + REST API構成のサービス開発リファレンスで紹介しているコード例(example-chat)をもとにします。
このコード例では、hooks/index.ts に独自フックをまとめて宣言しています。
※ 言語としてTypeScriptを使用しています入力コンポーネント用独自フック
フォームの作成についてはReactから以下のようにガイドされております。
HTML では
<input>
、<textarea>
、そして<select>
のようなフォーム要素は通常、自身で状態を保持しており、ユーザの入力に基づいてそれを更新します。
React では、変更されうる状態は通常はコンポーネントの state プロパティに保持され、setState() 関数でのみ更新されます。そのため、テキストボックスなどの入力値については、
useState
を使用して保持します。
入力コンポーネントの実装では、input要素に渡す属性や関数等、同様の実装をすることが多くなります。
そこで、ステートフックとステート更新をラッピングした独自フックを作成し、各入力コンポーネントの実装コストを下げることが目的となっています。useInput
input要素のステートフックとステート更新をラッピングした独自フック。
onChange属性で、値が変わるたびにstateを更新するようになっています。export const useInput = (initialState: string = '') : [string, React.InputHTMLAttributes<HTMLInputElement>, React.Dispatch<React.SetStateAction<string>>] => { const [value, setValue] = useState<string>(initialState); const onChange = (event: React.FormEvent<HTMLInputElement>) => { setValue(event.currentTarget.value); }; return [ value, { value, onChange }, setValue ]; };使い方
引数:
- 初期値
戻り値:
- stateの現在の値
- inputに渡すためのプロパティが設定されたオブジェクト
- stateの更新関数// text: stateの現在の値 // textAttributes: inputに渡すためのプロパティが設定されたオブジェクト // setText: stateの更新関数 const [text, textAttributes, setText] = useInput(''); return ( // textAttributesには `value属性`と`onChange属性`が入っている // スプレッド構文で展開して属性を一括設定する <input type='text' {...textAttributes}/> );型定義が異なるだけで、ほとんど同じコードとして
textarea
要素用の 「useTextarea」があります。
詳しくは、ソースコードを参照してください。useCheckbox
input[type=checkbox]
要素のステートフックとステート更新をラッピングした独自フック。
※単一のチェックボックスの場合に使用export const useCheckbox = (value: string, initialChecked: boolean = false) : [string, React.InputHTMLAttributes<HTMLInputElement>] => { const [checked, setChecked] = useState<boolean>(initialChecked); const [checkedValue, setCheckedValue] = useState<string>(initialChecked ? value : ''); const onChange = (event: React.FormEvent<HTMLInputElement>) => { setChecked(event.currentTarget.checked); if (event.currentTarget.checked) { setCheckedValue(value); } else { setCheckedValue(''); } }; return [ checkedValue, { value, checked, onChange } ]; };使い方
引数:
- チェックボックスのvalue属性
- 初期状態でチェックをつけるかどうか {true/false}
戻り値:
- チェックしている値
- チェックボックス要素の属性が設定されたオブジェクト// checkedValue: チェックしている値 // checkboxAttributes: チェックボックス要素の属性が設定されたオブジェクト const [checkedValue, checkboxAttributes] = useCheckbox('check', false); return ( // checkboxAttributesには `value属性`と`onChange属性`と`checked属性`が入っている // スプレッド構文で展開して属性を一括設定する <input type='checkbox' {...checkboxAttributes}/> );useCheckboxes
input[type=checkbox]
要素のステートフックとステート更新をラッピングした独自フック。
※複数のチェックボックスがある場合に使用export const useCheckboxes = (choices: string[], initialChecked: string[] = []) : [string[], string[], (value: string) => React.InputHTMLAttributes<HTMLInputElement>] => { const [checkedValues, setCheckedValues] = useState<string[]>(initialChecked.filter(v => choices.includes(v))); initialChecked.forEach(value => { if(!choices.includes(value)){ Logger.debug('checkbox initialChecked(' + value + ') is not includes choices.'); } }); const onChange = (event: React.FormEvent<HTMLInputElement>) => { const currentTarget = event.currentTarget; if (currentTarget.checked) { if (!checkedValues.includes(currentTarget.value)) { setCheckedValues([...checkedValues, currentTarget.value]); } } else { setCheckedValues(checkedValues.filter(v => v !== currentTarget.value)); } }; const attributes = (value: string) => { const checked = checkedValues.includes(value); return {value, onChange, checked}; }; return [ choices, checkedValues, attributes, ]; };使い方
引数:
- チェックボックスの選択肢
- 初期状態でチェックをつける選択肢
戻り値:
- チェックボックスの選択肢
- チェックしている値
- チェックボックスの属性を返す関数(選択肢の値が引数)const [choices, checkedValues, attributes] = useCheckboxes(['a', 'b', 'c'], ['a']); return ( {choices.map((choice, index) => ( <label key={index}> <input type="checkbox" {...attributes(choice)}/> <span>{choice}</span> </label> ))} );useRadio
input[type=radio]
要素のステートフックとステート更新をラッピングした独自フック。export const useRadio = (choices: string[], initialChecked: string = '') : [string[], string, (value: string) => React.InputHTMLAttributes<HTMLInputElement> ] => { const [checkedValue, setCheckedValue] = useState<string>(choices.includes(initialChecked) ? initialChecked : ''); if(initialChecked && !choices.includes(initialChecked)){ Logger.debug('radio initialChecked(' + initialChecked + ') is not includes choices.'); } const onChange = (event: React.FormEvent<HTMLInputElement>) => { setCheckedValue(event.currentTarget.value); }; // ランダムなname属性を生成する const [name] = useState(() => 'radio_' + new Date().getTime().toString(16) + Math.floor(10000 * Math.random()).toString(16)); const attributes = (value: string) => { const checked = value === checkedValue; return {name, value, onChange, checked}; }; return [ choices, checkedValue, attributes, ]; };使い方
引数:
- ラジオボタンの選択肢の値
- 初期状態でチェックをつける値
戻り値:
- ラジオボタンの選択肢
- チェックしている値
- ラジオボタンの属性を返す関数(選択肢の値が引数)const [choices, checkedValue, attributes] = useRadio(['a', 'b'], 'a'); return ( {choices.map((choice, index) => ( <label key={index}> <input type="radio" {...attributes(choice)}/> <span>{choice}</span> </label> ))} );useSelect
select
要素のステートフックとステート更新をラッピングした独自フック。export const useSelect = (initialState: string = '') : [string, React.SelectHTMLAttributes<HTMLSelectElement> ] => { const [value, setValue] = useState<string>(initialState); const onChange = (event: React.FormEvent<HTMLSelectElement>) => { setValue(event.currentTarget.value); }; return [ value, { value, onChange } ]; };使い方
引数:
- 初期値
戻り値:
- state
- selectに渡すためのプロパティが設定されたオブジェクトconst [select, selectAttributes] = useSelect(''); return ( <select name="hoge" {...selectAttributes}> <option value=''/> <option value='1'>1</option> <option value='2'>2</option> <option value='3'>3</option> </select> );useSelectMultiple
select(multiple)
要素のステートフックとステート更新をラッピングした独自フック。
※複数選択可能なselectexport const useSelectMultiple = (initialState: string[] = []) : [string[], React.SelectHTMLAttributes<HTMLSelectElement> ] => { const [value, setValue] = useState<string[]>(initialState); const onChange = (event: React.FormEvent<HTMLSelectElement>) => { const options = event.currentTarget.options; const selectedValues = []; for (let i = 0; i < options.length; i++) { if (options[i].selected) { selectedValues.push(options[i].value); } } setValue(selectedValues); }; return [ value, { value, onChange, 'multiple': true } ]; };使い方
引数:
- 初期値
戻り値:
- state
- select(multiple)に渡すためのプロパティが設定されたオブジェクトconst [select, selectAttributes] = useSelectMultiple([]); return ( <select name="hoge" {...selectAttributes}> <option value=''/> <option value='1'>1</option> <option value='2'>2</option> <option value='3'>3</option> </select> );その他のフック
usePageTitle
SPAではページごとのtitle要素が変わらないため、そのtitle要素を設定するフック。
export function usePageTitle(title?: string): void { useEffect(() => { if (title) { const previousTitle = document.title; document.title = title; return () => { document.title = previousTitle; }; } }, [title]); }使い方
title要素
を変更したい画面で呼び出してください。usePageTitle('ページタイトル');useDownloader
次のような手順でファイルのダウンロードを行う用のフック。
- レスポンスボディをBlobオブジェクトへ変換する
- URL.createObjectURL(blob)でURLを生成する
- a要素を動的に生成しhref属性に生成したURL、download属性にファイル名を設定する
- JavaScriptでa要素のclick()を実行する
- 生成したa要素とURLを破棄する(メモリリークの回避)
export function useDownloader(): (blob: Blob, filename: string) => void { const download = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); URL.revokeObjectURL(url); document.body.removeChild(anchor); }; return download; }使い方
Fetch APIでファイルデータを取得し、ResponseのblobメソッドでBlobオブジェクトを得ます。
そのBlobオブジェクトとファイル名をuseDownloader
から返却された関数に渡してダウンロードを行います。const download = useDownloader(); const blob = (await fetch('/api/download')).blob(); const filename = 'file-name.csv'; download(blob, filename);まとめ
以上、React Hooksのご紹介と、独自で作成したフックの紹介でした。
使えそうなフックがありましたら、是非使ってみてください。
- 投稿日:2020-12-18T23:19:02+09:00
【React】react-scrollでスクロールボタンを実装
この記事はReact Advent Calendar 2020 19日目の記事です。
はじめに
react-scrollを使って、簡単にスクロールボタンを実装していきます。
実装自体はとても簡単なので、是非参考にどうぞ〜。目標物
react-scrollをインストール
まずは、react-scrollをインストール
npm install react-scroll実装部分
- react-scrollをimport
- scrollToTopを実装(ページのトップまでスクロールしてくれる機能)
- ボタンのonClickイベントで呼び出す(ボタンが押下されたときにscrollToTopを呼ぶ)
ScrollButton.jsximport React from 'react'; import './ScrollButton.css'; import { animateScroll as scroll } from 'react-scroll'; //import class ScrollButton extends React.Component { // scrollToTopの実装 scrollToTop = () => { scroll.scrollToTop(); }; render() { return( <button onClick={this.scrollToTop}>Click</button> ); } } export default ScrollButton;スタイルを付ける
ScrollButton.cssbody { margin: 0 auto; padding: 0; max-width: 800px; min-height: 100vh; text-align: center; margin-bottom: 15%; } .section { margin-bottom: 15%; } button { display: inline-block; font-size: 32px; width: 200px; height: 48px; border-radius: 4px; margin-right: 24px; margin-left: 24px; color: #fff; background-color: #66ccff; border: none; outline: none; box-shadow: 4px 4px #d8d8d8; cursor: pointer; appearance: none; transition: .5s; } button:active { position: relative; top: 4px; left: 4px; box-shadow: none; }全体
最終的な成果物になります。
コピペで直ぐに動きますので、是非ご自身のローカル環境で動かしてみてください。ScrollButton.jsximport React from 'react'; import './ScrollButton.css'; import { animateScroll as scroll } from 'react-scroll'; class ScrollButton extends React.Component { // scrollToTopの実装 scrollToTop = () => { scroll.scrollToTop(); }; render() { return( <React.Fragment> <div className="text"> <h1>Hello React</h1> <div className="section"> Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsum doloribus laborum hic dicta odit, ullam quo aperiam tempora, culpa, ipsam consectetur eligendi delectus blanditiis perferendis dolor voluptatum unde molestias numquam! Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur architecto vero perferendis vel quos incidunt aut numquam recusandae assumenda harum hic iure ipsum consequuntur quasi explicabo, aperiam ea quas eligendi. Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ratione ipsam corrupti culpa repellendus incidunt facere amet minus reprehenderit optio sint? Fuga ad at magnam odit temporibus dolore quod, dignissimos quisquam. </div> <h1>【Section1】What's React</h1> <div className="section"> Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsum doloribus laborum hic dicta odit, ullam quo aperiam tempora, culpa, ipsam consectetur eligendi delectus blanditiis perferendis dolor voluptatum unde molestias numquam! Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur architecto vero perferendis vel quos incidunt aut numquam recusandae assumenda harum hic iure ipsum consequuntur quasi explicabo, aperiam ea quas eligendi. Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ratione ipsam corrupti culpa repellendus incidunt facere amet minus reprehenderit optio sint? Fuga ad at magnam odit temporibus dolore quod, dignissimos quisquam. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsum doloribus laborum hic dicta odit, ullam quo aperiam tempora, culpa, ipsam consectetur eligendi delectus blanditiis perferendis dolor voluptatum unde molestias numquam! Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur architecto vero perferendis vel quos incidunt aut numquam recusandae assumenda harum hic iure ipsum consequuntur quasi explicabo, aperiam ea quas eligendi. Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ratione ipsam corrupti culpa repellendus incidunt facere amet minus reprehenderit optio sint? Fuga ad at magnam odit temporibus dolore quod, dignissimos quisquam. </div> <h1>【Section2】react is a JavaScript library created by facebook</h1> <div className="section"> Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsum doloribus laborum hic dicta odit, ullam quo aperiam tempora, culpa, ipsam consectetur eligendi delectus blanditiis perferendis dolor voluptatum unde molestias numquam! Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur architecto vero perferendis vel quos incidunt aut numquam recusandae assumenda harum hic iure ipsum consequuntur quasi explicabo, aperiam ea quas eligendi. Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ratione ipsam corrupti culpa repellendus incidunt facere amet minus reprehenderit optio sint? Fuga ad at magnam odit temporibus dolore quod, dignissimos quisquam. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsum doloribus laborum hic dicta odit, ullam quo aperiam tempora, culpa, ipsam consectetur eligendi delectus blanditiis perferendis dolor voluptatum unde molestias numquam! Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur architecto vero perferendis vel quos incidunt aut numquam recusandae assumenda harum hic iure ipsum consequuntur quasi explicabo, aperiam ea quas eligendi. Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ratione ipsam corrupti culpa repellendus incidunt facere amet minus reprehenderit optio sint? Fuga ad at magnam odit temporibus dolore quod, dignissimos quisquam. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsum doloribus laborum hic dicta odit, ullam quo aperiam tempora, culpa, ipsam consectetur eligendi delectus blanditiis perferendis dolor voluptatum unde molestias numquam! Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur architecto vero perferendis vel quos incidunt aut numquam recusandae assumenda harum hic iure ipsum consequuntur quasi explicabo, aperiam ea quas eligendi. Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ratione ipsam corrupti culpa repellendus incidunt facere amet minus reprehenderit optio sint? Fuga ad at magnam odit temporibus dolore quod, dignissimos quisquam. </div> <h1>【Section3】Build complex UI with react</h1> <div className="section"> Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsum doloribus laborum hic dicta odit, ullam quo aperiam tempora, culpa, ipsam consectetur eligendi delectus blanditiis perferendis dolor voluptatum unde molestias numquam! Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur architecto vero perferendis vel quos incidunt aut numquam recusandae assumenda harum hic iure ipsum consequuntur quasi explicabo, aperiam ea quas eligendi. Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ratione ipsam corrupti culpa repellendus incidunt facere amet minus reprehenderit optio sint? Fuga ad at magnam odit temporibus dolore quod, dignissimos quisquam. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsum doloribus laborum hic dicta odit, ullam quo aperiam tempora, culpa, ipsam consectetur eligendi delectus blanditiis perferendis dolor voluptatum unde molestias numquam! Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur architecto vero perferendis vel quos incidunt aut numquam recusandae assumenda harum hic iure ipsum consequuntur quasi explicabo, aperiam ea quas eligendi. Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ratione ipsam corrupti culpa repellendus incidunt facere amet minus reprehenderit optio sint? Fuga ad at magnam odit temporibus dolore quod, dignissimos quisquam. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsum doloribus laborum hic dicta odit, ullam quo aperiam tempora, culpa, ipsam consectetur eligendi delectus blanditiis perferendis dolor voluptatum unde molestias numquam! Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur architecto vero perferendis vel quos incidunt aut numquam recusandae assumenda harum hic iure ipsum consequuntur quasi explicabo, aperiam ea quas eligendi. Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ratione ipsam corrupti culpa repellendus incidunt facere amet minus reprehenderit optio sint? Fuga ad at magnam odit temporibus dolore quod, dignissimos quisquam. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsum doloribus laborum hic dicta odit, ullam quo aperiam tempora, culpa, ipsam consectetur eligendi delectus blanditiis perferendis dolor voluptatum unde molestias numquam! Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur architecto vero perferendis vel quos incidunt aut numquam recusandae assumenda harum hic iure ipsum consequuntur quasi explicabo, aperiam ea quas eligendi. Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ratione ipsam corrupti culpa repellendus incidunt facere amet minus reprehenderit optio sint? Fuga ad at magnam odit temporibus dolore quod, dignissimos quisquam. </div> </div> <button onClick={this.scrollToTop}>Click</button> </React.Fragment> ); } } export default ScrollButton;ScrollButton.cssbody { margin: 0 auto; padding: 0; max-width: 800px; min-height: 100vh; text-align: center; margin-bottom: 15%; } .section { margin-bottom: 15%; } button { display: inline-block; font-size: 32px; width: 200px; height: 48px; border-radius: 4px; margin-right: 24px; margin-left: 24px; color: #fff; background-color: #66ccff; border: none; outline: none; box-shadow: 4px 4px #d8d8d8; cursor: pointer; appearance: none; transition: .5s; } button:active { position: relative; top: 4px; left: 4px; box-shadow: none; }終わり。
- 投稿日:2020-12-18T22:40:02+09:00
Next.jsの自分なりのベストプラクティスを6点紹介する
この記事はNext.js Advent Calendar 2020の18日目の記事です。
はじめに
タイトル通りです。
業務でNext.jsを約1年ぐらい触ってきて、毎回設定する項目についてまとめました。いわゆるNext.js文脈ではなくReact, Web技術界隈のプラクティスも一部含まれますが優しい気持ちで御覧ください。
0. TypeScriptを使う
使いましょう。
TypeScriptを使う際の公式ドキュメントも充実しています。https://nextjs.org/learn/excel/typescript
公式ドキュメント通りにやると
tsconfig.json
がstrict: false
になっているので、strict: true
にするのをお忘れなく。1. パスをまとめる: パスの中身を楽に書き換えられるように
※Next 10系からは
as
が自動的に解釈されるようになったので、よしなに読み飛ばしてください。
https://nextjs.org/blog/next-10#automatic-resolving-of-hrefよくNextのドキュメントで以下のような記述をみますが、一般的にパスを直書きするとのちのち辛い思いをすることは明らかです。
<Link href="/categories/[slug]" as="/categories/books">雑なサンプルですが以下のよう
href
,as
を返す関数を実装します。export type LinkPropsLike = { href: string; as: string; }; export const pathBuilder = { posts: () => ({ index: (): LinkPropsLike => ({ href: '/posts', as: `/posts`, }), detail: (postId: number): LinkPropsLike => ({ href: '/posts/[postId]', as: `/posts/${postId}`, }), }), };使用例としては以下のようになります。
<Link {...pathBuilder.posts().detail(1)}>最初の記事</Link>ちなみにそれをよしなにやってくれるライブラリもあります。下手に作るよりライブラリ等に則った方が秩序が保たれるので良いかもしれません。
https://github.com/yarnaimo/next-typed-pathGitHubのサンプルコードママ
import { $dynamic, $route, createRoutes } from 'next-typed-path' const routes = createRoutes({ about: $route, users: { index: $route, [$dynamic]: { index: $route, posts: { [$dynamic]: { index: $route, }, }, settings: { index: $route, lang: $route, }, }, }, }) routes.about // => '/about' routes.users.index // => '/users' routes.users('123').index // => '/users/123' routes.users('123').posts('456').index // => '/users/123/posts/456' routes.users('123').settings.index // => '/users/123/settings' routes.users('123').settings.lang // => '/users/123/settings/lang'クールですね。
TypeScript 4.1のTemplate Literal Typesがアツいので、そのうちTSだけでよしなにできるようになるかもですね。
Next.jsでそのうちサポートしてくれると非常嬉しい。2. Vercelを使う: ホスティングやCDの設定, PRでの確認を楽に
複雑なことをやらないのであればたいていのパターンで脳死でデプロイできます。
ポチポチでSSL暗号化からCDのインテグレーションの設定までできるので重宝しています。Vercelの紹介は別の記事や公式に任せるとして、個人的に特にアツいのは
Preview deployments
です。
Vercelは以下のような開発フローを推奨しています。
- Pull Requestでいわゆるステージング環境を確認する
- マージするとProductionに反映される
https://vercel.com/docs/platform/deployments
後述しますが、NetlifyでPull RequestのブランチでStorybookデプロイするようにしておくとレビュー作業がはかどります。それに加えてVercelでデプロイプレビュー確認ができるとリモートにもってきて確認作業をする必要がかなり減ります。
以下のようにGitHub等のPull Request上で確認することができます。
画像は https://armno.in.th/2020/05/07/vercel/ から
※Previewで確認できるサイトのドメインはVercelがよしなに設定するため、動作にドメインの制約があるようなサービスは以上のワークフローの設定は行えません。
3. Storybookでコンポーネントを管理する: コンポーネントの確認を楽に
実装時にコンポーネントを確認しながら実装したいですよね。Storybookを入れましょう。
Storybookは以下のスクショのようにコンポーネントを確認できるツールです。
アドオンをいれることによってイベントを確認できたり、props
の中身を書き換えて確認ができたり強力なことができます。Storybookの導入を推奨する文脈は以下のとおりです。
- コンポーネント単位での開発時に用いる
- コードレビュー時に後述のNetlifyにデプロイされたStorybookを確認する
- 共同開発者がコンポーネントを流用できるように(コミュニケーションコストを減らせる)
- 挙動の確認として
設定はわりと簡単に済みます。
Next.js ▲ + Typescript + Storybook The Really Simple Guide 2019 - DEV
https://dev.to/aprietof/next-js-typescript-storybook-the-really-simple-guide-2019-fei?signin=trueNetlifyを用いてPull RequestのブランチでStorybookをデプロイするように設定しておくとよいです。
以下のようにPull Requestで作成されたコンポーネント等をクリックするだけで確認することができます。
画像は https://www.netlify.com/blog/2016/07/20/introducing-deploy-previews-in-netlify/ から
4. APIクライアントによしなに型をつける: OASでAPIの型定義を書いて楽する
Next.jsをプロダクトで使う多くの場合、APIを参照するはずです。
APIの定義がSwaggerなどのOASを使っているプロジェクトなら
openapi2aspida
やopenapi-generator
を使うことを強く推奨します。要はAPI定義ファイルから型付きのAPIクライアントを生成します。例えば、バックエンドチームがAPIとAPI定義ファイルを提供してフロントチームがそれから実装するみたいなフローが考えられます。ツールを使わない場合はフロントの人がAPI定義ファイルからAPIクライアントのレスポンスの型をよしなにTypeScriptで記述する必要があります。(つらい)
ところが
openapi2aspida
を導入すると楽になります。流れとしては以下のようになります。まず、APIの定義を書きます。今回はサンプルとして以下のAPI定義を用います。
https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v2.0/yaml/petstore.yamlpetshop.yml... paths: /pets: get: summary: List all pets operationId: listPets tags: - pets parameters: - name: limit in: query description: How many items to return at one time (max 100) required: false type: integer format: int32 responses: "200": description: A paged array of pets headers: x-next: type: string description: A link to the next page of responses schema: $ref: '#/definitions/Pets' default: description: unexpected error schema: $ref: '#/definitions/Error' ...
openapi2aspida
の設定ファイルを書いてaspida.config.jsmodule.exports = { input: "api", outputEachDir: true, openapi: { inputFile: "./petshop.yml" } }ビルドすると、
npx openapi2aspidaこれだけで型付きのAPIクライアントが使える! うれC!
いわゆるスキーマ駆動開発が非常に効率的に行うことが可能になります。
aspida
については製作者様のQiitaの記事がわかりやすいのでご覧ください。
https://qiita.com/m_mitsuhide/items/68406158d35a14fa0aa25. Bundle Analyzerを導入する: ページが重くなっていないか確認できるように
Code SplittingをよしなにやってくれるのがNext.jsのかなりの旨味です。ページに必要じゃないソースとかを絡めてもってこないから軽くできるよといった機能です。
ただ、野良ライブラリを使用するとNextのCode Splittingでも重くなることが稀に生じます。
そのため、Bundle Analyzerを仕込んでおいてライブラリ導入時等に確認することが推奨されます。※
next build
時にNextが各ページのサイズを出力してくれるので、サイズ確認だけできればよいのであれば不要です。
@next/bundle-analyzer
を使用する場合は以下のように設定しています。yarn add --dev @next/bundle-analyzer cross-env
next.config.jsconst withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({ // 他の設定 })package.json{ "scripts": { "analyze": "cross-env ANALYZE=true next build", "build": "next build", ... } ... }確認時は以下のような動きになります。
yarn build
してバンドルファイルを作成yarn analyze
して解析結果をブラウザで確認する (内部の処理としては以下のよう)
cross-env
で環境変数ANALYZE
にtrue
を入れる- 次に
next build
される際にenabled
がtrue
になる- Bundle Analyzerが仕事をする
- クライアントサイド, サーバーサイドのバンドルサイズがブラウザで確認できる
以下のように、視覚的にバンドルファイルに占めるコードを確認することができます。
以下の例ではlodash
が大きくバンドルファイルを占めていることが確認できますね。画像は https://www.npmjs.com/package/webpack-bundle-analyzer のREADMEのものです。
6. 画像を最適化する
Next 10系以前は
next/image
が無いため、自分でよしなにする必要があります。
next-imagesをいれるのが結構楽だったりします。画像サイズによってインライン展開させることができるのでHTTPリクエストを減らせます。
next.config.jsconst withImages = require('next-images') module.exports = withImages({ inlineImageLimit: 16384, webpack(config, options) { return config } })また他には以下のような手法があります。画像はウェブの大半の通信を食うのでできることはやったほうがよいです。Lighthouse等もここらをやっているかどうかで大きく点数が変わってきます。
- 画像はCloudinaryやimgix, ImageFluxなどの画像よしな系SaaSで提供する
- Lazy Loadの設定を行う
- WebPを使ってあげる
- Nextから提供する画像はTinyPNG, SVGOMG等の圧縮の限りを尽くす
10系移行は
next/image
で良いでしょう。
ちなみにnext/image
は現時点ではStorybookは相性が悪いです。next/image
が普通に画像を渡しているわけじゃないからです。
https://github.com/vercel/next.js/issues/18393@next/plugin-storybookがよしなに整備されることを祈る or contributeしましょう。お願いします。
その他
強いて言えば以下を考慮しています。
pages/*.(jsx|tsx)
には責務を持たせない- コンポーネントをNext.jsに依存させない
- CSSに(ある程度の)秩序をもたせるように頑張る (ex: Sass, CSS in JS等)
最後に
Reactに塩梅の良い秩序と平穏をもたらしてくれるNext.jsは非常にうまいこと自らの責務の中で成長していると思います。Next.jsの責務のそれを超えた今回の(設定|プラクティス)が(どこか|誰か)のプロジェクトの参考になれれば幸いです。
- 投稿日:2020-12-18T19:13:12+09:00
【ReactNative】実機ビルド、APK生成で起きたトラブルシューティング~Android編~
ReactNativeを頑張っているみなさんこんにちは。ブリューアスのwebフロントエンジニアのsuginokoです。
もう年末ですね。1年というのはあっという間です。弊社ではじわじわとReactNativeの案件にも関わるようになってきておりワタシも初めての経験でアタフタしましたが、なんとか今年のゴールにたどり着けそうです。
今回は初めてのReactNativeということで沢山のトラブルに出くわしたので、その対応トラブルシューティングを書いていこうと思います。アプリ開発に携わったことのない同じようなフロントエンジニアさんに届きますように~
前置き
今回1からプロジェクトを立ち上げたわけではなく、既に実装済みのReactNativeのプロジェクトを頂きました。
実装内容としましても少し古い書き方になっている箇所も多々ありましたので、そちらをリファクタを行ったり、見た目部分を修正するなど、実装自体はそこまで重くなかった対応になります。
辛かったのは最初(環境構築)と最後(実機ビルド、APK生成)です。環境
- react: 16.9.0
- react-native: 0.61.5
- react-native-agora: 2.9.1-alpha.2
- react-native-firebase: 5.6.0
※パッケージはおおまかに使用したものだけ記載
基本的にはwindowsで実装してますが、expoは使用しておらず、ios開発は別途Macを用意して確認しつつの実装を行ってました。
yarnも使ってます。お客様の環境が開発用と本番用としか分かれてなかったため、buildTypeとしては
- Debug(開発環境)
- Staging(開発環境を参照しているデプロイゲート更新用)
- Release(本番)
このように分けました。
今回の納期までのスケジュールではもろもろのパッケージのバージョンアップは行わない方針でいったので後に書きますトラブルに見舞われた可能性があります。。トラブルシューティング
※エラーを無くすだけなので、その設定はちょっと・・・というのがあるかもしれませんがご了承ください。
error E:\my-gridsome-site\node_modules\sharp: Command failed.Command: (node install/libvips && node install/dll-copy && prebuild-install) || (node-gyp rebuild && node install/dll-copy)...
1から実装したものでは無くて、他のプロジェクトを取り込む際に起こるエラーなようです。
nodeのバージョンを適切なバージョンに上げて解決。(多分このエラーの下の方に適切なnodeバージョンが書いてあった気がする)
Macでは
sharp: Command failed · Issue #585 · gridsome/gridsome · GitHub
上記だと解消するらしいです。nodistを入れていたらnpxが使えなくなった話
Windows限定な話な気がしますが
Nodist を入れたら npx が使えなくなったので手動でインストール / Twin Turbo Computing
こちらで解決。Task :app:processDebugGoogleServices FAILED
cd `{project_name}` cd android && ./gradlew cleanたまに、上記の対応で直ることがありますが、
firebaseのgoogle-services.jsonが原因の可能性もあります。
環境にdebugとrelease以外の環境が存在するとこのエラーに巡り合えます。
applicationIdSuffix
を指定しているかにもよりますが、指定していない場合は{projectName}/android/app/google-services.json からコピーして
{projectName}/android/app/src/debug/google-services.json を作成します。
パッケージ名はandroid/app/src/main/AndroidManifest.xmlに書いているpackageNameを見ます。
applicationIdSuffix
を設定していなければmainに記載している名称と同じで問題ありませんが、設定している場合は
{packageName}.{applicationIdSuffix}
になります。これでエラーが無くなります。
applicationIdSuffixを指定している場合はStaging環境にもgoogle-services.jsonが必要だったと思います。{ "project_info": { "project_number": "...", "firebase_url": "...", "project_id": "...", "storage_bucket": "..." }, "client": [ { "client_info": { "mobilesdk_app_id": "...", "android_client_info": { "package_name": "←ここを環境ごとに合わせる" } }, ・・・参照:https://noy.hatenablog.jp/entry/2018/02/15/121431
No matching client found for package name
Task :app:processDebugGoogleServices FAILED
と同様の対応でなくなりました。
google-services.jsonが適切な場所にないのと、正しいpackage nameになってないことが問題でした。What went wrong: Execution failed for task ':app:mergeReleaseResources'.
重複エラーだそうです。今思えば
{project_name}/android/app/src/main/assetsにindex.android.bundle
が存在しなかったことが原因でしたが以下の方法で解決。
参照:reactjs - React Native 0.57.1 Android Duplicate Resources - Stack Overflow
- {project_name}/android/app/build 全部削除
- {project_name}/android/build 全部削除
- 実行 rm -rf $HOME/.gradle/caches/
- {project_name}/android/app/src/main/assetsのindex.android.bundle を削除(この時点で自分のプロジェクトには存在しなかったのでパス)
- 以下を実行
react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/resこの時結構不要なファイルも出たから削除した気がする・・・・
jsonファイルやnode_modulesのもろもろも色々出てきてビルドには不要だったんで削除しました。Task :app:transformClassesAndResourcesWithR8ForRelease FAILED
transformClassesAndResourcesWithR8ForReleaseの実行に失敗している。
調べてみると諸々のパッケージバージョンが合わないことで発生しているケースがあるらしいです。(多分色々古かったパッケージもあった。バージョンアップはリスキーなので断念)
R8の設定をtrueにすることで難読化や最適化を行ってくれるそうですが、ここではfalseにしていきます。{project_name}/android/gradle.properties
追記#Disables R8 for Android Library modules only. android.enableR8.libraries = false #Disables R8 for all modules. android.enableR8 = false参照:https://developer.android.com/studio/releases
Execution failed for task ':app:transformClassesAndResourcesWithProguardForRelease'. java.io.IOException: Please correct the above warnings first.
以下続き
Warning: there were 1649 unresolved references to classes or interfaces. You may need to add missing library jars or update their versions. If your code works fine without the missing classes, you can suppress the warnings with '-dontwarn' options.Staging用APKを生成するときに出たエラーです。
Proguard 関連の処理が原因らしいです。この辺を-dontwarnを使って制御することができるらしいです。
とはいえ、1649件もなんかバージョンアップしてないとか、クラスに欠陥があるとか、不足ライブラリがあるらしいものを一気になんとかできるのか。。。(調べてみると、件数が少ないと1個1個バージョンアップやらすることで解消することもあるそうです)
実機ビルドでは普通にアプリが動いていることから、-dontwarnなどをproguard-rules.proを解消できそうであると考え、以下の対応を行っています。{project_name}/android/app/build.gradleのbuildTypeにstagingで起きたエラーなので、
stagingにproguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
を追加
これでproguard-rulesを見に行きます。(多分ここまでたどり着くのに2日くらい)buildTypes { debug { ・・・ } staging { ・・・ // add proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } release { ・・・ } }次にproguard-rulesの設定。
{project_name}/android/app/proguard-rules.pro
元々の専任の方が書いてあった記述にプラスしてReactNative系の処理とWarningはスルー、もろもろの処理をスルーしますよ的な書き方だったりを追記。# Add project specific ProGuard rules here. # By default, the flags in this file are appended to flags specified # in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt # You can edit the include path and order by changing the proguardFiles # directive in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # Add any project specific keep options here: -keep class io.agora.**{*;} # THIS IS VERY VERY BAD. REMOVE AS SOON AS VERSIONING IS FIXED -dontwarn ** -dontnote ** -keep class host.exp.exponent.generated.AppConstants { *; } ##### Crashlytics ##### -keepattributes SourceFile,LineNumberTable ##### React Native ##### -keep,allowobfuscation @interface **.facebook.proguard.annotations.KeepGettersAndSetters -keep,allowobfuscation @interface **.facebook.react.bridge.ReadableType -keepclassmembers @**.facebook.proguard.annotations.KeepGettersAndSetters class * { void set*(***); *** get*(); } -keep class * extends **.facebook.react.bridge.JavaScriptModule { *; } -keep class * extends **.facebook.react.bridge.NativeModule { *; } -keepclassmembers class * { @**.facebook.react.uimanager.UIProp <fields>; } -keepclassmembers class * { @**.facebook.react.uimanager.ReactProp <methods>; } -keepclassmembers class * { @**.facebook.react.uimanager.ReactPropGroup <methods>; } ##### Versioned React Native ##### -keep class **.facebook.** { *; } -keep class abi** { *; } -keep class versioned** { *; }本当はもっと書いてあったけど、いらなそうなのがあったんでその辺は削除。
多分、先人がReactNativeのバージョンとか上げてしまったり、その他のパッケージとかもものすごく古いパッケージとかもあったし、色々な苦労が見えた結果かなと思う。これが正しかったのかわからないけど、このエラーはこうすることで消えました。(proguardの設定も意味がわからなくて2日くらいかかって合わせて4日くらいかかってしまった。。)Execution failed for task ':react-native-orientation:verifyReleaseResources
自分でreact-native-orientationを入れた記憶がないので先人のものでしょう。
参考:Execution failed for task ':react-native-orientation:verifyReleaseResources' · Issue #290 · yamill/react-native-orientation · GitHub{project_name}/android/gradle.bundle に以下を追加
buildscript{ ...} allprojects { ...} subprojects { afterEvaluate { project -> if (project.hasProperty("android")) { android { compileSdkVersion 28 // version of compile sdk used for project buildToolsVersion '28.0.3' // version of build tool used for project } } } }これは同じ{project_name}/android/gradle.bundleに記載しているbuildscriptにある、
compileSdkVersion
とbuildToolsVersion
を合わせないといけないです。
ちなみに、こちらのバージョンも28以上でないといけなかったらしいです。こちらのプロジェクトでは偶然28以上を使ってたので、問題ありませんでした。Task :@react-native-community_async-storage:generateDebugBuildConfig FAILED
cd android && ./gradlew clean で解消
キャッシュが残っていることがあるらしいです。Execution failed for task ':app:installDebug'. > com.android.builder.testing.api.DeviceException: com.android.ddmlib.InstallException: INSTALL_FAILED_VERSION_DOWNGRADE
cd android && ./gradlew cleanだけでは直らず、普段実機ビルドできているのにどうして?というエラー
PC再起動で直りました。Task :app:mergeDebugResources FAILED
このエラーの前後にエラーの原因となっているログが書いているはずで、自分の記憶にないエラー内容だと、
cd android
./gradlew clean
で解決することがあるでも大概は「あ、これ自分触ったやつ」っていうエラーもあるので、そちらに問題があることもあったりしました。
Task 〇〇:app:packageDebug FAILED
cd android
./gradlew clean
で解決することがあるが、この場合はAndroidStudioがメモリを食っていて出来ない場合もあったので、AndroidStudioで
1.キャッシュ消しての再起動
2.build>clean projectで解消以上。
開発期間が短かったので出来る限りのことをやろうと思って必死に調べました。
使っているパッケージやライブラリが古く、もうドキュメントすら残されてないのが多い中での対応だったので、そこが一番しんどかったです。でもこれでAndroid設定周りの対応学べた気がしますね
次はiOS編を書いていきます。(Androidほどは無いかもしれない)
- 投稿日:2020-12-18T17:29:38+09:00
ぴえん?かどうか判定するクソアプリをつくった
どうも、株式会社メイクリーの黒神(@kokushing)と申します。
クソアプリ2 Advent Calendar 2020 18日目の投稿です。作ったもの
写真に写っている顔が「ぴえん?」かどうかを判定するクソアプリです。
画像を選択すると、顔を検出し、ぴえんかどうかを判定します。
ぴえんスコアが90を超えると「ぱおん?」になります。
判定はけっこうガバガバです。
経緯
今年も昨年に引き続き、face-api.jsというtensorflow.jsを用いたJavaScriptライブラリを利用しようということでパッと思いついたのがこのクソアプリです。
ぶっちゃけ5秒で思いつきました。
これくらいのスピード感がクソアプリ開発には必要ですよね(謎)最初は手書きHTMLからソースコードを書き起こしてWebサイト作れるクソアプリを作っていましたが、真面目すぎるな〜と思い転向しました。
手書きテキストからソースコード生成するクソアプリ作ってる pic.twitter.com/cC2ESsvCxH
— 黒神 / makery, inc CEO (@kokushing) December 1, 2020tensorflow使ってぴえんかどうかを判定するクソアプリつくってる pic.twitter.com/fKEce1pyRW
— 黒神 / makery, inc CEO (@kokushing) December 5, 2020あと余談なんですが、 三省堂 辞書を編む人が選ぶ「今年の新語2020」の大賞は「ぴえん」らしいですよ。めでたい。
実装
React.js、TypeScript、face-api.js、react-dropzone、styled-componentsで実装しました。
画像のダウンロードにはhtml2canvasというライブラリを利用しています。
インフラはNetlify先輩。Vercel先輩も気になっているので今度試します。このクソアプリの仕組みは単純で、react-dropzoneで受け取った画像データをface-api.jsで解析し、顔の表情を数値として取得します。
あとは数値によって結果を変えるだけ。シンプルですね。
悲しい顔の数値を取得するには下記のように書くと取得できます。
(async () => { const inputEl = document.getElementById("input") as HTMLImageElement; const MODEL_URL = `${process.env.PUBLIC_URL}/models`; await faceapi.nets.tinyFaceDetector.load(MODEL_URL); await faceapi.nets.faceExpressionNet.load(MODEL_URL); const detectionsWithExpressions = await faceapi .detectAllFaces(inputEl, new faceapi.TinyFaceDetectorOptions()) .withFaceExpressions(); const score = detectionsWithExpressions.length > 0 ? detectionsWithExpressions[0].expressions.sad : -1; console.log(score); // 悲しみの数値を取得 })();あとがき
本当はOG画像とかSSRしてTwitterでシェアできるようにしたかったんですが、時間の都合で断念しました。
ぴえん?
Photo by
- 投稿日:2020-12-18T16:25:36+09:00
create-react-appで生成したReactのJest実行をVSCodeからブレークが効く状態で実行する
- 投稿日:2020-12-18T16:01:29+09:00
Contentful+Nextで画像の横幅を取得しwebp化して表示する
Imageコンポーネント
https://nextjs.org/docs/api-reference/next/image
Image
はコンポーネントですから、単なる画像タグと違って、パラメーターが足りないとエラーを起こします。layout="fill"
を指定しない限り、width
とheight
にサイズを渡さないといけません。autoにしたいんだけど
サイズ取得をサボって、以下のように書くこともできます。
components/cover-image.tsxexport default function CoverImage({ work, fixHeight }: CoverImageProps): ReactElement { return ( <Link href={`/works/${work.slug}`}><a><Image src={work.coverImage.imageUrl} alt={`${work.title}のアイキャッチ画像`} width={"auto"} height={"auto"} /></a> </Link> ) }こうなる
しかし
Next.js 10.0.3
現在、"auto"
を渡すと意図しない大きさになります。webpに変換するには固定した値が必要なので、仕方なくcontentfulから取ってきます。検証した環境
package.json 抜粋
"dependencies": { "@contentful/rich-text-react-renderer": "^14.1.2", "@contentful/rich-text-types": "^14.1.2", "@fortawesome/fontawesome-svg-core": "^1.2.32", "@fortawesome/free-brands-svg-icons": "^5.15.1", "@fortawesome/free-solid-svg-icons": "^5.15.1", "@fortawesome/react-fontawesome": "^0.1.13", "autoprefixer": "^10.0.4", "classnames": "^2.2.6", "contentful": "^8.0.3", "date-fns": "^2.16.1", "next": "10.0.3", "postcss": "^8.1.14", "react": "17.0.1", "react-dom": "17.0.1", "react-markdown": "^5.0.3", "tailwindcss": "^2.0.1" }, "devDependencies": { "@fullhuman/postcss-purgecss": "^3.0.0", "@types/node": "^14.14.14", "@types/react": "^17.0.0", "typescript": "^4.1.3" }Contentfulでデータを用意
- slug: テキスト
- coverImage: メディア単体
がそれぞれの記事に設定されていればOKです。
以下、タイトルやリッチテキスト等の使わないデータは省略しています。
work
の部分は、普通post
やarticle
等にしているかと思います。適宜置き換えてください。実装
Images APIの詳細
https://www.contentful.com/developers/docs/references/images-api/
こちらのページに、Contentful上の画像のJSON構造が載っています。要するに
width: <アイキャッチ画像>.fields.file.details.image.width ?? null, height: <アイキャッチ画像>.fields.file.details.image.height ?? nullでサイズを取得できます。
環境変数と外部ドメインの指定
next.config.jsmodule.exports = { env: { CONTENTFUL_SPACE_ID: process.env.CONTENTFUL_SPACE_ID, CONTENTFUL_ACCESS_TOKEN: process.env.CONTENTFUL_ACCESS_TOKEN, CONTENTFUL_PREVIEW_ACCESS_TOKEN: process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN, CONTENTFUL_PREVIEW_SECRET: process.env.CONTENTFUL_PREVIEW_SECRET }, images: { // This is required to show images with <Image /> component domains: ['images.ctfassets.net'], }, }型の定義
services/work.types.tsexport type CoverImage = { imageUrl: string; width: number; height: number; }; export type Work = { id: string; slug: string; coverImage: CoverImage; };記事のデータを取得
services/work.tsimport { ContentfulClientApi, createClient } from "contentful"; import { Creator, CreatorImage, CoverImage, Work } from "./work.types"; export class WorkApi { client: ContentfulClientApi; previewClient: ContentfulClientApi; constructor() { this.client = createClient({ space: process.env.CONTENTFUL_SPACE_ID, accessToken: process.env.CONTENTFUL_ACCESS_TOKEN, }) this.previewClient = createClient({ space: process.env.CONTENTFUL_SPACE_ID, accessToken: process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN, host: 'preview.contentful.com', }) } convertCoverImage = (rawImage): CoverImage => { if (rawImage) { return { imageUrl: 'https:' + rawImage.fields.file.url ?? null, width: rawImage.fields.file.details.image.width ?? null, height: rawImage.fields.file.details.image.height ?? null, }; } return null; }; convertWork = (rawData): Work => { const rawWork = rawData.fields; const rawCoverImage = rawWork.coverImage ? rawWork.coverImage : null; return { id: rawData.sys.id, slug: rawWork.slug, coverImage: this.convertCoverImage(rawCoverImage) ?? null } }; // 以下省略 }省略した部分でデータ取得の処理を書いています。
アイキャッチ画像用コンポーネントを用意
components/cover-image.tsximport { ReactElement } from 'react' import { Work } from '../../services' import Link from 'next/link' import Image from 'next/image' type CoverImageProps = { work: Work, fixHeight: boolean } export default function CoverImage({ work, fixHeight }: CoverImageProps): ReactElement { const image = ( <Link href={`/works/${work.slug}`}><a><Image src={work.coverImage.imageUrl} alt={`${work.title}のアイキャッチ画像`} width={work.coverImage.width} height={work.coverImage.height} /></a> </Link> ) return ( <div> {image} </div> ) }アイキャッチ画像コンポーネントに
Work
型のworkを渡せば、webpになって出てきます。ソースコード
https://github.com/sasigume/sasigudotme
参考
next/image (https://nextjs.org/docs/api-reference/next/image)
Create a Blog App With Next.js and Contentful API
(https://medium.com/better-programming/create-a-blog-app-with-nextjs-and-contentful-api-7927af49b3b)
- 投稿日:2020-12-18T15:48:15+09:00
React FC ステートで混乱するの巻
Hooksという魔法のせいでなんだかステート(状態)を関数が持てるような錯覚を起こし、
バグで詰まってしまった愚か者がいるらしいですよ。(私のことです)下記、検証や考察で仮説を建てたものなので誤り等あるかもしれません。
その際はコメントにて指摘いただけると嬉しいです?♂️React FC事件簿
問題が起きていたコードをものすごく簡略化したのが下記のコードになります。
import React, { useEffect, useState } from "react"; import "./styles.css"; export default function App() { const [counter, setCounter] = useState(0); const [madeDom, setMadeDom] = useState(null); const counterCheck = () => { alert(`カウントは${counter}`); }; useEffect(() => { setMadeDom(<ChildComp handler={counterCheck} />); }, []); return ( <div className="App"> 現在カウンターは {counter} <button onClick={() => setCounter(counter + 1)}>カウントUP</button> {madeDom} </div> ); } function ChildComp({ handler }) { return <button onClick={handler}>子供カウントチェック</button>; }親コンポーネントは初回レンダリングの際に
useEffect
にてmadeDom
を作成し、それを
useState
で保持しています。
またmadeDomには、親コンポーネントのメソッドcounterCheck
を渡しています。
こちらは親のステートcounter
を表示するメソッドです。親コンポーネントはビューとして
・自身のカウンター、
・カウントアップ用ボタン
・生成したmadeDom
を表示させています。初回はこんな塩梅ですね。
そしてカウントUPさせたのち、子供カウントチェックボタンを押すと....あれ、、、
同じcounterを保持しているはずなのに親と子で異なる結果となりました。
もっと正確にいうなら、子に渡したメソッドから参照するcounter
は増加せず、
親からダイレクトに表示しているcounter
のみのカウントが増加しています。
ここで理由が即座に説明できる方には、もしかしたらこの記事を読む必要はないかもしれません。
ですが、私と同じような混乱を感じている方は引き続きお付き合いいただけたらと思います。Classは状態がありますので
上記と全く同じ実装をClassでやってみましょう。
import React, { Component } from "react"; import "./styles.css"; export default class App extends Component { constructor(props) { super(props); this.countCheckHandler = this.countCheckHandler.bind(this); this.state = { counter: 0, madeDom: null }; } countCheckHandler() { alert(`カウントは${this.state.counter}`); } componentDidMount() { this.setState({ madeDom: <ChildComp handler={this.countCheckHandler} /> }); } render() { return ( <div className="App"> 現在カウンターは {this.state.counter} <button onClick={() => this.setState({ counter: this.state.counter + 1 })} > カウントUP </button> {this.state.madeDom} </div> ); } } class ChildComp extends Component { render() { return ( <button onClick={() => { this.props.handler(); }} > 子供カウントチェック </button> ); } }クラスに変えただけなのですが、子コンポーネント経由で増加分を正しく表示できています。
繰り返しになりますが、クラスは内部情報を持ちますが関数は持ちません。
少し真相に近づいてきました。classコンポーネントが見ているデータ先
実際に
this
で確認してみましょう。 渡しているメソッドにconsoleを付け加えます。countCheckHandler() { + console.log(this); alert(`カウントは${this.state.counter}`); }
意図していた通り親コンポーネントのApp
が参照されています。
なので、ハンドラー内で呼び出しているthis.state.counter
は間違いなく Appのcounterが呼び出されています。【Classでの内部状態考察】
では関数コンポーネントではどうでしょうか。
【Funcでの内部状態考察】
このように、生成時のみCounterをコピーしてくる形になるので、その後Appの情報が増えてもChildCompは知らんべ、ということのようです。
なぜなら、関数は内部状態を持たないので(本日n回目)、Appのcounter状態をみる、という芸当はChildComp側はできないわけです。じゃあuseStateってなんなんだ! propsってなんなんだ! 状態みたいなの保ててるんだけど!?!?!?
と混乱したところで、そもそもHooksで表現しているStateの仕組みって何よ、ってところをおさらいします。そもそもHooksってどうやって状態を表現しているんだろう。
https://daveceddia.com/intro-to-hooks/#the-magic-of-hooks
上記をぜひ読んでください。
...だけでは味気ないので、ゆる〜く超意訳してみます。Reactがfunctionコンポーネントを初めてレンダリングする際、オブジェクトを生成します。
このコンポーネントのオブジェクトはDOMに存在し続ける限りずっと生き続けます。
Reactはこのオブジェクトを使用して、色々なメタデータを扱っているわけですね。また、コンポーネントは自分でレンダリングするのではなく、Reactが呼び出すことでレンダリングされます。
コンポーネント自体は返すものは、DOMノードに変換可能なオブジェクト構造でしかありません。このReactが呼び出すための準備の際にstateがセットアップされます。
function AudioPlayer() { const [volume, setVolume] = useState(80); const [position, setPosition] = useState(0); const [isPlaying, setPlaying] = useState(false); }(コードは記事からそのまま拝借しました)
このように3回useState
が呼び出された場合、Reactは3つの値を配列に入れていきます。
次にレンダーされる場合、この3つのhooksは常に同じ順番で呼び出されます(呼び出し順は常に同じでなくてはならない、というhooksのルールを思い出してください。)そして、新しい状態を作る代わりに、2回目のレンダーではそのポジションにある値を返します。これがReactが変数がスコープ外の複数の関数の呼び出しがあってもステートを作成・維持できる方法です。
単にオブジェクトであるというのがミソですね。hooksで起きた問題箇所を詳しく調べる
少しコードサンプルを変えて色々検証してみます。
import React, { useEffect, useState } from "react"; import "./styles.css"; export default function App() { const [count, setCount] = useState(0); // 1つ目は変数に格納したものを表示させる const myChild = <Child count={count} />; //2つ目は初回レンダリング次のみ生成し、それを保持する const [myChild2, setMyChild2] = useState(null); useEffect(() => { setMyChild2(<Child count={count} />); }, []); return ( <div className="App"> <button onClick={() => { setCount(count + 1); }} > UP </button> <Child count={count} /> {myChild} {myChild2} </div> ); } function Child({ count }) { return ( <> <p>カウントは{count}</p> <button onClick={() => { alert(count); }} > カウントチェック </button> </> ); }わざわざ子コンポーネントに確認用alertのハンドラーを追加しているのは、
レンダリングはされていないが内部の情報は更新されているかも?という疑いを検証するためです。このように
useEffect
で初回レンダリングで生成しているChild
コンポーネントのみ、
親のcountを追えていないことがわかります。
もちろん、アラートでの表示も同様でした。つまりuseEffectで初回のみ
myChild2
を再計算させているため、
useStateで生成されたオブジェクトが追えていない、ということのようです。
本来であれば
・count
が変わる
・App内が再計算される
・Childコンポーネントのprops も再計算される
という流れがうまく働いていなかったことが原因でした。まとめと書簡
今後useEffect内でコンポーネントを生成する場合、
再計算されないこと/そして関数である故に、propsの値などを直に参照できていると思い込まないことに注意していこうと思います。また、検証に当たってreact内の実装をガツガツ読めるようになった方がより検証しやすいな〜と思ったので、
もっと実装を直でガツガツ読めるようになりたいです..(途中までコード追ってたのですが挫けました)改善しました
import React, { useState } from "react"; import "./styles.css"; export default function App() { const [counter, setCounter] = useState(0); const counterCheck = () => { alert(`カウントは${counter}`); }; return ( <div className="App"> 現在カウンターは {counter} <button onClick={() => setCounter(counter + 1)}>カウントUP</button> <ChildComp handler={counterCheck} /> </div> ); } function ChildComp({ handler }) { return <button onClick={handler}>子供カウントチェック</button>; }実際はもっと複雑だったのですが、上記のような形で
レンダリングにChildCompを書き込むことで、問題なくカウンターを呼び出すことができるようになりました。または、useEffectの第二引数に依存する変数を指定することでも改善できます。
import React, { useEffect, useState } from "react"; import "./styles.css"; export default function App() { const [counter, setCounter] = useState(0); const [madeDom, setMadeDom] = useState(null); const counterCheck = () => { alert(`カウントは${counter}`); }; useEffect(() => { setMadeDom(<ChildComp handler={counterCheck} />); }, [counter]); return ( <div className="App"> 現在カウンターは {counter} <button onClick={() => setCounter(counter + 1)}>カウントUP</button> {madeDom} </div> ); } function ChildComp({ handler }) { return <button onClick={handler}>子供カウントチェック</button>; }めでたしめでたし。
- 投稿日:2020-12-18T15:22:05+09:00
redux-toolkitをreduxなしで使ってみる
こちらはVISITS advent calendar 2020 24日目の記事です。
https://qiita.com/advent-calendar/2020/visits
本日はフロントエンドエンジニアのpipopotamsauがつとめさせていただきます。
本記事ではredux-toolkitでreduxを使わず、Reactの標準APIのみで使う方法をご紹介しようと思います。
1. redux-toolkitとは
redux-toolkitとはredux開発チームが公式に出している、より効率的かつ簡単にreduxを利用できるようにするライブラリになります。
redux-toolkitの詳しい説明は本記事の趣旨ではないため割愛しますが、名前からわかる通り基本的には「redux」と一緒に使うためのライブラリです。
しかし、reduxなしでReactのuseReducerと一緒に使うこともできるので紹介したいと思います。
2. redux-toolkitとuseReducerを組み合わせて使ってみる
それでは早速redux-toolkitとuseReducerを組み合わせたサンプルアプリケーションをみていきましょう。
以下のようなカウンターアプリを作ってみます。
※ 本記事のサンプルアプリケーションのレポジトリはこちらです
まずはredux-toolkitの
createSlice
ヘルパーを使用し、reducer、action creator、action typeを生成します。import { createSlice } from '@reduxjs/toolkit'; const initialState = { counter: 0 }; const slice = createSlice({ name: 'counter', reducers:{ INCREMENT: (state) => { state.counter += 1; }, DECREMENT: (state) => { state.counter -= 1; }, RESET: (state) => { state.counter = 0; }, }, initialState }); const { actions, reducer } = slice;次は上記のcreateSliceで生成したreducerとuseReducerを組み合わせて、stateとdispatch関数を生成します。
const { actions, reducer } = slice; function CounterApp() { const [state, dispatch] = useReducer(reducer, initialState); return ( // return jsx ); }ここまででほぼ終わりです。あとはcounterを表示したり、buttonのonClickイベントに上記で生成したdispatch関数とactionを組み合わせたコールバックを仕込むだけです。
const { actions, reducer } = slice; function CounterApp() { const [state, dispatch] = useReducer(reducer, initialState); return ( <div className="counter-app"> <p className="counter">Counter: { state.counter }</p> <ul className="buttons"> <li> <button onClick={ () => dispatch(actions.INCREMENT) }>+</button> </li> <li> <button onClick={ () => dispatch(actions.DECREMENT) }>-</button> </li> <li> <button onClick={ () => dispatch(actions.RESET) }>reset</button> </li> </ul> </div> ); }これで以下のようなカウンターアプリを作ることができました
3. なぜredux-toolkitをuseReducerと一緒に使うのか?
基本的にreduxでredux-toolkitを使う理由と一緒だと考えています。
効率的にreducerやaction creators, action typeを作るためだったり、書き方を統一する意図で使用しています。
4. 終わりに
最後までお読みいただきありがとうございました。
本記事のサンプルコードは以下になります。コード全体がみたいという方はこちらを参照してください。
https://github.com/pipopotamasu/redux-toolkit-and-usereducerさて、クリスマスまで残すところあと1日です。
VISITS Technologiesのアドベントカレンダーの最後を飾るのは、エンジニアリングマネージャーの@kotala_bになります!
- 投稿日:2020-12-18T12:41:29+09:00
FirebaseのonSnapshotを使ったときに起きた、serverTimestampがnullになる現象について
React+TypeScriptでアプリを開発している時のこと。
「ユーザーがformに入力した内容をリアルタイムに画面にに反映したいな」と思い(チャットの様なものだと思ってください)、調べていると
get()
ではなく、onSnapshot()
を使えばFirestore(db)に保存されたデータを即時画面に反映できると知り、使ってみた結果即時反映させることには成功したものの、なぜかserverTimestamp()がnullになる
エラーに直面しました。その時の対処法の解説になります。
コードの例
以下は上手く行った時のReactのコード例ですが、他のフレームワークやライブラリでも同じ様な感じだと思います。
CommentForm.tsxuseEffect(() => { let comments: any = []; const unsubscribe: any = db .collection("posts") .doc(id) .collection("comments") .orderBy("createdAt", "desc") .onSnapshot((snapshots) => { snapshots.docChanges().forEach((change) => { const data = change.doc.data({ serverTimestamps: "estimate" }); const changeType = change.type; const date = data.createdAt.toDate(); switch (changeType) { case "added": comments.push({ ...data, createdAt: date }); break; case "modified": const index = comments.findIndex( (comment: any) => comment.id === change.doc.id ); comments[index] = comment; break; case "removed": comments = comments.filter( (comment: any) => comment.id !== change.doc.id ); break; default: break; } }); setComment(comments); unsubscribe(); }); }, []);解決した方法
解決した方法はかなり簡単でした。。。
doc.data({ serverTimestamps: "estimate" }).createdAt解説
FirebaseでgetやonSnapshotを使ってデータを呼び出した際に、
snapshots.docs.forEach(doc => {...})の様な感じで、データを取り出すと思います。
この後に、
doc.data()
とすると一つ一つのデータのオブジェクトが取得できます。しかし、
doc.data().createdAtとすると、最新(追加した瞬間)のデータがnullになってしまうのですが、
doc.data({ serverTimestamps: "estimate" }).createdAtとすることで、追加した瞬間のnullの状態のtimestampに対して、timestampを推定して、
確定するまでとりあえず仮のTimestamp
を入れておいてくれます。Firebaseの公式ドキュメントを見ていただけたらわかると思いますが、この
doc.data()
のdata()
に関して以下の様な解説がされています。ドキュメント内のすべてのフィールドをオブジェクトとして取得します。文書が存在しない場合は 'undefined' を返します。デフォルトでは、まだ最終的な値に設定されていない FieldValue.serverTimestamp() の値は null として返されます。これをオーバーライドするには、オプションオブジェクトを渡す必要があります。
今回のエラーも加味して簡単に言い換えると、、、
data()
でオプションを何も指定していない場合は、FieldValue.serverTimestamp()
が最終的な値を決定するまでnull
を返します。nullが返されたくないなら、オプションを指定すればなんとかできます。
といったところでしょうか。
ドキュメントを見てみると、
data()
のオプションは2つある様です。
オプション 説明 estimate 保留中のサーバータイムスタンプはローカルクロックに基づいた推定値を返します。この推定値は最終的な値とは異なり、サーバーの結果が利用可能になると、これらの値が変更されます。 previous 保留中のタイムスタンプは無視され、代わりに以前の値を返します。 none 省略された場合や 'none' に設定された場合は、サーバの値が利用可能になるまでの間、デフォルトで null が返されます。 こんなところに大ヒントが書いてありましたね?
答えは公式ドキュメントに?
Firebaseの公式ドキュメントはやはりかなり充実していて、大抵のfirebase関連の問題はドキュメントに書いてあると思いました?
今後はFirebaseを使うときはわからなかったらドキュメントをしっかり読む、という習慣を身につけようと思いました。
参考
- 投稿日:2020-12-18T12:22:51+09:00
GatsbyJSチュートリアルの内容をTypeScript化させてみた
チュートリアル4~8までの内容をTypeScript化してみた。Data in Gatsbyより
GraphQL周りの型はgatsby-plugin-typegenというプラグインで型生成できるため楽に実装できた。
(Gatsby.jsのTypeScript化 2020を大いに参考。Zennのサイトへ飛びます。)
※ただし1箇所だけ最後まで分からなかった問題はあり(必要な箇所で後述します)全体として動かすために最低限必要なTS化となっており細かいところはどうか許してやってください(指摘はもちろんもらえたら嬉しいです)
成果物
TS化→http://localhost:9000/ でも同様に立ち上げることができたという画像
チュートリアルをデフォルト(JavaScript)で終えたブランチはココ
チュートリアルをTS化したブランチ(完成形)はココ手順
- typescriptの環境構築
- GraphQLの型生成
- 全ページのTS化
1. typescriptの環境構築
"tsc --init"で初期化されたtsconfig.jsonファイルを作成します。
npx tsc --init次にtypescriptに必要なライブラリを2つ落としてきます。
yarn add -D typescript yarn add gatsby-plugin-typegen // gatsby-config.jsのplugins[]へ追記tsconfig.jsonの設定
最初はstrictモードのオプションを外しておきます。
tsconfig.json{ "compilerOptions": { "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ "lib": ["dom", "es2017"], /* Specify library files to be included in the compilation. */ "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ "outDir": "./build", /* Redirect output structure to the directory. */ "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ "noUnusedLocals": true, /* Report errors on unused locals. */ "noUnusedParameters": true, /* Report errors on unused parameters. */ "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ "baseUrl": "src", /* Base directory to resolve non-absolute module names. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ "skipLibCheck": true, /* Skip type checking of declaration files. */ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ // "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* Enable strict null checks. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ }, "include": ["src/**/*", "gatsby-node/index.ts"], "exclude": ["node_modules", "public", "build", "src/templates/blog-post.tsx"], }gatsby-config.jsへ追記
gatsby-config.jsmodule.exports = { siteMetadata: { title: `typescriptのテスト gatsbyのチュートリアル参考:https://www.gatsbyjs.com/tutorial/part-four/`, description: `これは説明文章ですよ`, author: `gatsbyJSマン`, }, plugins: [ `gatsby-plugin-emotion`, { resolve: `gatsby-plugin-typography`, options: { pathToConfigModule: `src/utils/typography`, }, }, { resolve: `gatsby-source-filesystem`, options: { name: `src`, path: `${__dirname}/src/`, } }, `gatsby-transformer-remark`, { resolve: `gatsby-plugin-manifest`, options: { name: `GatsbyJS`, short_name: `GatsbyJS`, start_url: `/`, background_color: `#6b37bf`, theme_color: `#6b37bf`, display: `standalone`, icon: `src/images/icon.png`, }, }, `gatsby-plugin-offline`, `gatsby-plugin-react-helmet`, `gatsby-plugin-typegen`, // 型生成のプラグインを追加 ], }2. GraphQLの型生成
どこからでも良いのですが、まずindex.jsのファイルでGraphQLの型を生成してみます。
index.tsxへファイル名を変更。当然ですがそのままだと型エラーが表示されます(エディターはVSCodeを使用)。
下記のように書き換えます。index.tsx/** @jsx jsx */ //emotionCSSのTS化のための記述 import React, {FC} from "react" import { jsx, css } from '@emotion/react' // jsxを追加 import { Link, graphql } from "gatsby" import { rhythm } from "../utils/typography" import Layout from "components/layout" const Home: FC<{ data: any }> = ({data}): any => { //型生成するまで適当にanyを突っ込んでおく return ( <Layout> <div> <h1 css={css` display: inline-block; border-bottom: 1px solid; `} > Amazing Pandas Eating Things </h1> <h4>{data.allMarkdownRemark.totalCount} Posts</h4> {data.allMarkdownRemark.edges.map(({ node }) => ( <div key={node.id}> <Link to={node.fields.slug} css={css` text-decoration: none; color: inherit; `} > <h3 css={css` margin-bottom: ${rhythm(1 / 4)}; `} > {node.frontmatter.title}{" "} <span css={css` color: #bbb; `} > — {node.frontmatter.date} </span> </h3> <p>{node.excerpt}</p> </Link> </div> ))} </div> </Layout> ) } export const query = graphql` query MarkdownOfIndex { //便宜上、任意で型の名前をつけておく(ただし、名前がなくても生成されるファイルの type Query オブジェクトの中に型が格納されているので取り出せば良い) allMarkdownRemark { totalCount edges { node { id frontmatter { title date(formatString: "DD MMMM, YYYY") } fields { slug } excerpt } } } } ` export default Homeこの状態で、
gatsby buildしてあげると、srcディレクトリ直下に __ generated __ ファイルが生成されて中に型情報ファイルが入っています。
ファイル検索で"MarkdownOfIndex"をかけてみると生成された型を確認できるのでこれを引っ張ってあげましょう。gatsby-typescript.tstype MarkdownOfIndexQueryVariables = Exact<{ [key: string]: never; }>; type MarkdownOfIndexQuery = { readonly allMarkdownRemark: ( //この型を引っ張ってあげる Pick<MarkdownRemarkConnection, 'totalCount'> & { readonly edges: ReadonlyArray<{ readonly node: ( Pick<MarkdownRemark, 'id' | 'excerpt'> & { readonly frontmatter: Maybe<Pick<MarkdownRemarkFrontmatter, 'title' | 'date'>>, readonly fields: Maybe<Pick<MarkdownRemarkFields, 'slug'>> } ) }> } ) };GatsbyのPagePropsライブラリを使う
index.tsxに戻りPagePropsを追加
index.tsximport { Link, graphql, PageProps } from "gatsby"そして下記のように型を嵌めれば上手くいく。
index.tsxconst Home: FC<PageProps<GatsbyTypes.MarkdownOfIndexQuery>> = ({data}) => { return ( <Layout> //以下略3. srcの全ページのTS化
2.と同じ要領で生成した型を当てていきます。
build前の状態
それぞれのファイルを最低限の書き換えでbuildできる状態にします。この段階でもし怒られた箇所があれば適当にanyなどをはめて一時凌ぎを。
components/layout.tsx/** @jsx jsx */ import React, {FC} from "react" import { jsx, css } from "@emotion/react" import { useStaticQuery, Link, graphql } from "gatsby" import { rhythm } from "../utils/typography" const Layout = ({ children }) => { const data = useStaticQuery<GatsbyTypes.LayoutSiteMetadataQuery>( graphql` query LayoutSiteMetadata { site { siteMetadata { title } } } ` ) return ( <div css={css` margin: 0 auto; max-width: 700px; padding: ${rhythm(2)}; padding-top: ${rhythm(1.5)}; `} > <Link to={`/`}> <h3 css={css` margin-bottom: ${rhythm(2)}; display: inline-block; font-style: normal; `} > {data.site.siteMetadata.title} </h3> </Link> <Link to={`/about/`} css={css` float: right; `} > About </Link> {children} </div> ) } export default Layoutcomponents/seo.tsximport React from "react" import PropTypes from "prop-types" import { Helmet } from "react-helmet" import { useStaticQuery, graphql } from "gatsby" const SEO = ({ description, lang, meta, title }) => { const { site } = useStaticQuery<GatsbyTypes.SEOsiteMetadataQuery>( graphql` query SEOsiteMetadata { site { siteMetadata { title description author } } } ` ) const metaDescription = description || site.siteMetadata.description return ( <Helmet htmlAttributes={{ lang, }} title={title} titleTemplate={`%s | ${site.siteMetadata.title}`} meta={[ { name: `description`, content: metaDescription, }, { property: `og:title`, content: title, }, { property: `og:description`, content: metaDescription, }, { property: `og:type`, content: `website`, }, { name: `twitter:card`, content: `summary`, }, { name: `twitter:creator`, content: site.siteMetadata.author, }, { name: `twitter:title`, content: title, }, { name: `twitter:description`, content: metaDescription, }, ].concat(meta)} /> ) } SEO.defaultProps = { lang: `en`, meta: [], description: ``, } SEO.propTypes = { description: PropTypes.string, lang: PropTypes.string, meta: PropTypes.arrayOf(PropTypes.object), title: PropTypes.string.isRequired, } export default SEOpages/about.tsximport React from "react" import { graphql } from "gatsby" import Layout from "../components/layout" const About = ({ data }) => { return ( <Layout> <h1>About {data.site.siteMetadata.title}</h1> <p> We are the only site running on your computer dedicated to showing the best photos and videos of pandas eating lots of food. </p> </Layout> ) } export const query = graphql` query AboutsiteMetadata { site { siteMetadata { title } } } ` export default Aboutpages/my-files.tsximport React from "react" import { graphql } from "gatsby" import Layout from "../components/layout" const MyFiles = ({data}) => { console.log(data) return ( <Layout> <div> <h1>My Sites Files</h1> <table> <thead> <tr> <th>relativePath</th> <th>prettySize</th> <th>extension</th> <th>birthTime</th> </tr> </thead> <tbody> {data.allFile.edges.map(({ node }, index) => ( <tr key={index}> <td>{node.relativePath}</td> <td>{node.prettySize}</td> <td>{node.extension}</td> <td>{node.birthTime}</td> </tr> ))} </tbody> </table> </div> </Layout> ) } export const query = graphql` query { allFile { edges { node { relativePath prettySize extension birthTime(fromNow: true) } } } } ` export default MyFilestemplates/blog-post.tsximport React from "react" import { graphql } from 'gatsby' import Layout from "../components/layout" import SEO from "../components/seo" const BlogPost = ({data}) => { const post = data.markdownRemark return ( <Layout> <SEO title={post.frontmatter.title} description={post.excerpt} /> <div> <h1>{post.frontmatter.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.html }}/> </div> </Layout> ) } export const query = graphql` query($slug: String!) { markdownRemark(fields: { slug: { eq: $slug } }) { html frontmatter { title } excerpt } } ` export default BlogPosttypography.jsはtypography.tsへファイル名のみ変更
gatsby buildgatsby-node.jsのTS化
次にgatsby-node.jsをTS化していくのでts-nodeをインストール
yarn add -D ts-nodegatsby-node.jsのTS化においては、gatsby-node.jsのファイル名はそのままにして、別にTS用のディレクトリとファイルである gatsby-node/index.ts を親ディレクトリ上に作ってここから引っ張ってきました。
参考:Gatsby.jsのTypeScript化 2020まずはindex.ts上にgatsby-node.jsと同じ処理をTypeScript化させます。
gatsby-node/index.tsimport path from 'path' import { createFilePath } from "gatsby-source-filesystem" import { GatsbyNode } from 'gatsby' export const onCreateNode: GatsbyNode["onCreateNode"] = ({ node, getNode, actions }) => { const { createNodeField } = actions if (node.internal.type === `MarkdownRemark`) { const slug = createFilePath({ node, getNode, basePath: `pages` }) createNodeField({ node, name: `slug`, value: slug, }) } } export const createPages: GatsbyNode["createPages"] = async ({ graphql, actions }) => { const { createPage } = actions const result = await graphql<{ allMarkdownRemark: GatsbyTypes.Query["allMarkdownRemark"]}>(` { allMarkdownRemark { edges { node { fields { slug } } } } } `) const { data } = result || 'undefined'; if( data === undefined) throw 'データが見つかりませんでした'; data.allMarkdownRemark.edges.forEach(({node}) => { if(node.fields){ createPage({ path: node.fields.slug || '/undefined', component: path.resolve(`./src/templates/blog-post.tsx`), context: { slug: node.fields.slug } }) } }) }一方でgatsby-node.jsは上のgatsby-node/index.tsを引っ張ってくるコードに書き換える。
gatsby-node.js"use strict" require("ts-node").register({ compilerOptions: { module: "commonjs", target: "esnext", }, }) require("./src/__generated__/gatsby-types") const { createPages, onCreateNode, } = require("./gatsby-node/index") exports.createPages = createPages exports.onCreateNode = onCreateNodeこの時点で一度 gatsby develop で動くか確認すると一応、トランスパイルはうまくいっていると思う(エラーなどあれば any などでごまかしましょうw)
strictモードで手直ししていく
tsconfig.json{ "compilerOptions": { "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ "lib": ["dom", "es2017"], /* Specify library files to be included in the compilation. */ "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ "outDir": "./build", /* Redirect output structure to the directory. */ "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ "noUnusedLocals": true, /* Report errors on unused locals. */ "noUnusedParameters": true, /* Report errors on unused parameters. */ "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ "baseUrl": "src", /* Base directory to resolve non-absolute module names. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ "skipLibCheck": true, /* Skip type checking of declaration files. */ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ "strict": true, /* Enable all strict type-checking options. */ "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ "strictNullChecks": true, /* Enable strict null checks. */ "strictFunctionTypes": true, /* Enable strict checking of function types. */ "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ }, "include": ["src/**/*", "gatsby-node/index.ts"], "exclude": ["node_modules", "public", "build", "src/templates/blog-post.tsx"], }するとすぐにエラーが生じるので手直ししていきます。
生成されたundefined のところでエラーが生じるので修正する
gatsby-plugin-typegenで生成された型だと、Objectに'undefined'の可能性があるため型を拡張するかもしくは ? を挿入、もしくは if文 で 'undefined' の怒られを回避していく必要があります。
コンポーネントとpagesの手直して見た
gitはこちら - チュートリアルをTS化したブランチ(完成形)はココ
components/layout.tsx/** @jsx jsx */ import { FC } from "react" import { jsx, css } from "@emotion/react" import { useStaticQuery, Link, graphql } from "gatsby" import { rhythm } from "../utils/typography" const Layout: FC = ({ children }) => { const data = useStaticQuery<GatsbyTypes.LayoutSiteMetadataQuery>( graphql` query LayoutSiteMetadata { site { siteMetadata { title } } } ` ) return ( <div css={css` margin: 0 auto; max-width: 700px; padding: ${rhythm(2)}; padding-top: ${rhythm(1.5)}; `} > <Link to={`/`}> <h3 css={css` margin-bottom: ${rhythm(2)}; display: inline-block; font-style: normal; `} > {data.site?.siteMetadata?.title} </h3> </Link> <Link to={`/about/`} css={css` float: right; `} > About </Link> {children} </div> ) } export default Layoutcomponents/seo.tsximport React from "react" // import PropTypes from "prop-types" import { Helmet } from "react-helmet" import { useStaticQuery, graphql } from "gatsby" interface SEOTypes { description?: string, lang?: string, meta?: any, title: string, } const SEO = ({ description, lang, meta, title }: SEOTypes) => { const { site } = useStaticQuery<GatsbyTypes.SEOsiteMetadataQuery>( graphql` query SEOsiteMetadata { site { siteMetadata { title description author } } } ` ) const metaDescription = description || site?.siteMetadata?.description if(!lang) lang = 'ja'; if(!meta) meta = {}; return ( <Helmet htmlAttributes={{ lang, }} title={title} titleTemplate={`%s | ${site?.siteMetadata?.title}`} meta={[ { name: `description`, content: metaDescription, }, { property: `og:title`, content: title, }, { property: `og:description`, content: metaDescription, }, { property: `og:type`, content: `website`, }, { name: `twitter:card`, content: `summary`, }, { name: `twitter:creator`, content: site?.siteMetadata?.author, }, { name: `twitter:title`, content: title, }, { name: `twitter:description`, content: metaDescription, }, ].concat(meta)} /> ) } export default SEOpages/index.tsx/** @jsx jsx */ import {FC} from "react" import { jsx, css } from '@emotion/react' import { Link, graphql, PageProps } from "gatsby" import { rhythm } from "../utils/typography" import Layout from "../components/layout" const Home: FC<PageProps<GatsbyTypes.MarkdownOfIndexQuery>> = ({data}) => { return ( <Layout> <div> <h1 css={css` display: inline-block; border-bottom: 1px solid; `} > Amazing Pandas Eating Things </h1> <h4>{data.allMarkdownRemark.totalCount} Posts</h4> {data.allMarkdownRemark.edges.map(({ node }) => ( <div key={node.id}> <Link to={node.fields?.slug || '/'} css={css` text-decoration: none; color: inherit; `} > <h3 css={css` margin-bottom: ${rhythm(1 / 4)}; `} > {node.frontmatter?.title}{" "} <span css={css` color: #bbb; `} > — {node.frontmatter?.date} </span> </h3> <p>{node.excerpt}</p> </Link> </div> ))} </div> </Layout> ) } export const query = graphql` query MarkdownOfIndex { allMarkdownRemark { totalCount edges { node { id frontmatter { title date(formatString: "DD MMMM, YYYY") } fields { slug } excerpt } } } } ` export default Homepages/about.tsximport React, {FC} from "react" import { graphql, PageProps } from "gatsby" import Layout from "../components/layout" const About: FC<PageProps<GatsbyTypes.AboutsiteMetadataQuery>> = ({ data }) => { return ( <Layout> <h1>About {data.site?.siteMetadata?.title}</h1> <p> We are the only site running on your computer dedicated to showing the best photos and videos of pandas eating lots of food. </p> </Layout> ) } export const query = graphql` query AboutsiteMetadata { site { siteMetadata { title } } } ` export default Aboutpages/my-files.tsximport React, {FC} from "react" import { graphql, PageProps } from "gatsby" import Layout from "../components/layout" const MyFiles: FC<PageProps<GatsbyTypes.myFilesAllFileQuery>> = ({data}) => { return ( <Layout> <div> <h1>My Sites Files</h1> <table> <thead> <tr> <th>relativePath</th> <th>prettySize</th> <th>extension</th> <th>birthTime</th> </tr> </thead> <tbody> {data.allFile.edges.map(({ node }, index) => ( <tr key={index}> <td>{node.relativePath}</td> <td>{node.prettySize}</td> <td>{node.extension}</td> <td>{node.birthTime}</td> </tr> ))} </tbody> </table> </div> </Layout> ) } export const query = graphql` query myFilesAllFile { allFile { edges { node { relativePath prettySize extension birthTime(fromNow: true) } } } } ` export default MyFilestemplates/blog-post.tsximport React, {FC} from "react" import { graphql, PageProps } from 'gatsby' import Layout from "../components/layout" import SEO from "../components/seo" const BlogPost: FC<PageProps<GatsbyTypes.blogPostRemarkQuery>> = ({data}) => { const post = data.markdownRemark return ( <Layout> <SEO title={post?.frontmatter?.title || "undefined"} description={post?.excerpt || "undefined"} /> <div> <h1>{post?.frontmatter?.title}</h1> <div dangerouslySetInnerHTML={{ __html: post?.html || "undefined"}}/> </div> </Layout> ) } export const query = graphql` query blogPostRemark($slug: String!) { markdownRemark(fields: { slug: { eq: $slug } }) { html frontmatter { title } excerpt } } ` export default BlogPost足りないライブラリを追加する
yarn add -D @types/react-helmet @types/typographyここで、悲しいことにGatsbyJSのチュートリアルで使用されいてる"typography-theme-kirkham"の@types~が探しても見つからなかったのでTS化できなかった。
developするとエラーが起こる
ここで、gatsby developで動作確認したいところだが、理由は分からないですが "gatsby-plugin-typegen"のプラグインに入ったままだと "コンポーネント側で生成されたGraphQLの型が消えてなくなり、永遠にターミナル上でReloadされ続けて動かせなくなってしまいました。"
(理由は分からないのでわかる方がいたら教えて欲しいです。developの処理の時だけuseStaticQueryのGraphQLを認識せずに型が再生成されるから?なのかも。)
消えてしまったコンポーネントのGraphQLの型は gatsby build で再ビルドすれば元に戻ります。
そのため、gatsby develop で動作確認する際には gatsby-config.jsで設定した gatsby-plugin-typegen をコメントアウトする必要がありました。ビルドしてserveしてみた
gatsby build gatsby servehttp://localhost:9000/ で立ち上げることができれば完成。
- 投稿日:2020-12-18T04:09:46+09:00
Reactの状態管理ライブラリまとめ
はじめに
今回はReactのReactの状態管理ライブラリについてまとめていきます。
勉強したアウトプットと備忘録として残していこうと思います。現在の状態管理ライブラリの状況と概要
- Redux : デファクトスタンダード?
- Recoil : Facebook社が開発
- useContext : React16.8から標準のhooksで使える
- SWR : Next.jsと同じZeit社が開発
- Apollo Client : Graph QLとの相性
- react-query : 個人的に推したい新進気鋭
下の3つは状態管理というよりもキャッシュを管理するライブラリである。
Reduxの特徴
- Action → ActionCreator → Reducer → Storeの流れで状態の変更を管理
- 流れが一方向なので状態管理をしやすい
- reselectによる値の参照とメモ化が強力
- フロントエンドにビジネスロジックが集中しても対応可能
- パフォーマンスチューニングしやすい
- Fluxフローの概念の理解が必要
- とにかく手続きが多い
useContextの特徴
- createContext()で生成したコンテクストオブジェクトで一元管理
- 配下のコンポーネントでuseContextを使って参照可能
- Reduxよりもシンプル
- 更新頻度が低い状態の管理に最適
- 管理している値が変化すると再レンダリングが走る
- パフォーマンスチューニングが面倒
react-queryの特徴
- フェッチしたデータをキャッシュとして利用する
- キャッシュをどのコンポーネントからでも簡単に参照できる
- データ再フェッチの間隔や回数を調整
- フェッチの状態を返してくれる(isLoading, errorなど)
- フロントエンド先行でバックエンドの設計を必要
- フェッチしたデータを再加工しにくい
まとめ
Redux useContext React-query 導入の容易さ ❌ ⭕️ ? パフォーマンスチューニング ⭕️ ? ⭕️ 値の参照 ⭕️ ? ⭕️ 値の管理方法 1つのオブジェクト(store) オブジェクト(複数作成可能) APIレスポンスの戻り値(クエリごとに管理) 向いているアプリ 書き込みが多い 読み取りが多い どちらもOK 参考
- 投稿日:2020-12-18T02:18:19+09:00
react-realtime-markup-editor: 単純な記法によりリアルタイムに文字装飾が可能なテキストドキュメントエディタ
この記事は 徒然 Advent Calendar 2020 - Adventar の18日目の記事らしいです
はじめに
react-realtime-markup-editor というドキュメントエディタを自作したので紹介します.
この記事は v0.12.3 について記したものです.要約7行
- 研究でノート作成システムを作っていて,メモを書くことをちゃんと考えたくなった.
- 授業受けてメモ取るなら記法で文字装飾したい.マウスとキーボード行き来したくないし.
- markdown いいんだけど,編集と閲覧が分かれるのやだな.2倍のスペースとるし.
- Scrapbox の編集体験いいじゃん!React コンポーネント公開されてないかな...
- ない!!!けわしい!!!!
- ないなら作ろう.独自機能も含めて!(法律的に大丈夫かチェックしよ...)
- react-realtime-markup-editor 誕生
どんなエディタを作りたかったか:良いメモエディタの条件
僕が研究で対象にしている,授業を受けてメモを取ることに限らず,会議の議事録を作成するなど,何か話をしながらあるいは聴きながら並行して内容を文章としてまとめていくシーンは生活の中にしばしばあるものです.そんなシーンにおいて役立つメモエディタがどんなものであるべきかを4つの条件としてまとめます.
容易な装飾:少ない操作で素早く見出し付けや強調ができること
講義や会議では,話したことを単に文字起こししたいシーンは稀だと考えています.必ず重要なところがあったり,全体がいくつかの話題からなった上で全体の結論があったりというように,構造やメリハリを持っているはずです.それらを踏まえながら,話をするあるいは聴くこと並行して,内容を文章にまとめていくためには,見出し付けや内容の強調ができればなんでも良いのではなく,容易な操作で行えなくてはいけません.
よくある RTF エディタや Word の文字装飾ステップを考えてみましょう.文字をキーボードで打って,マウスで範囲選択をして,太字ボタンを押したら,また文章を打つためにキーボードを打つ.そんなキーボードとマウスの行き来は御免です.メモを書くときくらいはキーボードから手を離さずにバババッと書きたいのです.リアルタイムな装飾:見出しや強調がリアルタイムに反映されること
せっかく表現した見出しや強調もリアルタイムに見えなければ価値が半減してしまいます.なぜなら文字の装飾は目で見てわかりやすくするためにあるからです.目で見てわかりやすくするための機能がすぐにみられない,そんなのは本末転倒と言っても良いでしょう.
先生や上司に提出したり,Web上で世界中に公開したりするためのリッチな資料を TeX で作るのは素晴らしいことです.しかしながら,自分たちのために作るメモや議事録に時間のかかるコンパイルは必要なく,むしろ余計な手間と時間を食うだけです.最小のスペース:最小限の本質的なスペースのみを必要とすること
キーボードから手を離さずに,しかもリアルタイムに文字装飾をしたいというと markdown をお勧めされるかもしれません.この記事だって markdown を使って書いていますし,その全てをを批判するつもりは毛頭ありません.しかしながら,markdown は編集ビューと閲覧ビューが分かれており,本質的に必要なスペースのおよそ2倍を占領するか,編集しているときには文字装飾の結果をリアルタイムに見ることができないか,そのどちらかを選択しなくてはいけません.
後者については既に述べていますから,前者について述べておきます.講義のメモを取るシーンや会議の議事録をまとめるシーンでは,複数のアプリや資料を開くなどして様々な情報を見ながら文章を作成することが多いでしょう.いろんな情報を一緒に見たいのに,メモのためだけに2倍のスペースを取られてしまうのは効果的であるとは言えません.メモは講義や会議の1つの要素にすぎないので,必要なスペースは最小限であって欲しいのです.豊富な資料参照:外部資料への参照を容易かつ豊富に埋め込めること
先ほども言ったように,講義のメモを取るシーンや会議の議事録をまとめるシーンでは,複数のアプリや資料を開くなどして様々な情報を見ながら文章を作成することが多いでしょう.作成しようとしている講義や会議のメモには,しばしば参照する資料や Web ページがあるものです.
メモからそうした資料を豊富に参照できれば非常に便利ですし,参照さえしておけば十分な内容についてはメモに改めて書く必要もなくなるため,作成作業のコスト削減にもつながるでしょう.そうであるとれば,資料への参照を容易に埋め込めること,Webページや資料の種類に応じて多様なマークをつけて区別できることは,非常に重要なメモの要素の1つです.react-realtime-markup-editor でできること
この章では,前の章で説明した良いメモエディタの条件を踏まえて実装した react-realtime-markup-editor について詳細に述べていきます.
デモページ
「みなまで言うな.デモを見せてくれ.」という方は,どうぞこちらに
https://kumachan-mis.github.io/react-realtime-markup-editorScrapbox より拝借したアイデア
react-realtime-markup-editor のアイデア,実装の多くは Scrapbox を参考にしています.まず Scrapboxの主要な編集体験のうち,この自作エディタに組み込んだものを紹介します.
即時反映の文字装飾で見ながら編集
react-realtime-markup-editor 最大の特徴であり,Scrapbox の編集体験の中で最も感銘を受けた機能は,記法を書くとその場で即時に反映され,カーソルののっている行は記法が見えていて,カーソルののっていない行は記法を除いたコンテンツ文字だけが見えるというものです.
この上図の機能です.カーソルののっていない1行目と3行目はコンテンツ文字である bold と underline のみが文字装飾とともに見えていますが,カーソルののっている2行目はコンテンツ文字のitalicと文字装飾だけでなく,装飾の記法[\ ...]
も見えています.
この仕組みによって,単純な記法によって文字装飾が行え(容易な装飾),リアルタイムにそれが反映されます(リアルタイムな装飾).さらに,記法がその場で直ちに反映されるので,編集ビューと閲覧ビューが分かれず,ドキュメントを書いているスペースがそのままエディタ自身が必要とするスペースとなります(最小のスペース).数式・コードブロックで理系も安心
数式やコードブロックは必要とする人が少数派であるからか,全てのドキュメントエディタがサポートしている機能であるとは言い難いのが現状です.特に,数式は markdown においても標準ではサポートされておらず,Visual Studio Code のような著名なエディタや Qiita をはじめとするブログサイトは,独自の拡張機能を追加で入れることによって数式をかけるようにしているようです.しかしながら,数式は理系の共通言語ですし,コードはエンジニアの共通言語です.これ無くして,標準でサポートせずして,我々は議論を始められません.
react-realtime-markup-editor はもちろんこれらをサポートしています.記法は,Scrapbox のそれとは若干異なりますが,ユーザにとって真新しすぎる記法ということはないでしょう.ごく最近 (v0.12.x) からは複数行にわたるコードブロックも無事サポートされました.これでエンジニアも理系学生も安心です.コードブロックはインラインモード,ディスプレイモードともに markdown と同じ記法です.
数式は TeX のそれに似ていて,$...$
でインラインモードの数式,$$...$$
でディスプレイモードの数式になります.ディスプレイモードであっても強制的な改行と数式の中央揃えは行われません.普通に大型演算子を使いたいけど,改行しないで文章の中に書きたいことってありますよね.
react-realtime-markup-editor の独自機能
Scrapbox の真似事ばかりしていてもつまらないですし,Scrapbox の機能だけでは4つ目の条件「豊富な資料参照」を満たせていません.これを満たすために,自作エディタの独自機能としてタグ付きリンクとリンクの入力推薦をサポートしています.
タグ付きリンクで豊富な参照埋め込み
タグ付きリンクは,タグ名とリンクの名前を与えるとタグに合うリンクを埋め込む機能です.
講義のメモや会議の議事録で参照する資料は,特定のサーバ上にあるpdfファイル,Google docs や Google Slides,GitHub の issue など,タイプが限られると予想しています.ありとあらゆる Web ページをあちこち参照するケースは稀でしょう.タグ付きリンクは,そういった資料のタイプをタグによって分類し,そのそれぞれについて色分け等を自由に設定できるようにしておくことで,メモの中に豊富にリンクを埋め込んだとしても見通しが良い状態が保たれることを狙います.これにより,外部資料への参照の容易かつ豊富な埋め込みを効果的に実現しています(豊富な資料参照).記法は
[tag: taggedLink]
となっています.
例えば,このエディタをインストールしたプログラマが,タグgithub
を定義し,taggedLink
の値が@user_name/repository_name
であるときにhttps://github.com/user_name/repository_nameへのリンクとなるように設定をしたとしましょう.
すると,ユーザは[github: @kumachan-mis/react-realtime-markup-editor]とか書くだけで,
https://github.com/kumachan-mis/react-realtime-markup-editorへのリンクとすることができます.
リンクの入力推薦でタイピング量の削減
タグの導入によって,リンクが分類されたり,記述が多少削減されたりしたとはいえ,まだまだリンクの記述は面倒であり,タイピングミスも頻繁に起こることが予想されます.例えば,
[github: @kumachan-mis/react-realtime-markup-editor]をたった一文字だけ打ち間違えて,
[github: @kumachan_mis/react-realtime-markup-editor]としてしまったとしましょう.すると同然ながらこのリンクの行き着く先は
404 NOT FOUND
になってしまいます.リンクを豊富に入れたいのに,リンクのためにたくさんの文字を打たなければならず,しかも打ち間違えがないかどうかを注意深く確認しなくてはいけないのです.しかしながら,メモを書いているユーザにそんな余裕はないはずです.何しろ今は,講義中・会議中なのですから.
そうであるならば,システムが自動で正しくリンク名を入力してくれる以上の解決策はないでしょう.メモから参照する資料は,タイプももちろん限られているでしょうが,その中でも特定の講義や会議で頻繁に参照する資料は決まっているものだと予想しています.少なくとも,リンクの名前を全部手打ちするよりは,よく使う似た名前のリンクを少し編集するだけの方が遥かに楽でしょうし,そういったシーンは頻繁にあると考えています.
そこで,react-realtime-markup-editor では,リンクの記法を入力した時点で入力候補の推薦を出せるようになっています.
何を推薦するべきかは,具体的なユースケースに応じて変わるでしょうし,頻度分析や自然言語処理などの複雑な推定をもって決められることもであるでしょうから,このエディタの責任とは考えていません.しかしながら,推薦したいリンク名の一覧さえ与えられれば,エディタはいつでもそれらを表示する準備ができています.開発苦労話
ここまででHCI的な自作エディタのプレゼンは終わりにして,この章ではより実装に近くい開発上の苦労話を書くことにします.苦労話には全く興味のない人もいれば,むしろ苦労話にしか興味のない人もいるでしょう.実に自由に泥臭い話を書いていきます.
Scrapbox はどうなっているか
react-realtime-markup-editor の実装にあたっては,何度も述べている通り,Scrapbox を参考にしました.この節では,Scrapbox を通じて僕が理解したドキュメントエディタの仕組みをまとめます.
ドキュメントエディタなんて今まで作ったことがありませんから,何も参考にできるものがなければその作り方は皆目見当もつきません.どうやってプレーンテキストにはない装飾を表現しているのか,どんな機能を自前で作って,どんな機能は標準のHTML要素の力を借りられるのか,その辺りについて Scrapbox をかなり参考にしました.
具体的には,
- Scrapbox のHTML や CSS とにらめっこする
- Scrapboxの開発に関する記事を読む
ことを通じて,仕組みを理解していきました.
文字は1文字ずつの span らしい
まず,基本的なところからいきましょう.文字を装飾付きで表示し,そこにカーソルを当てて編集できるようにするにはどうすれば良いか.文字を装飾付きで表示することはHTMLの豊富な表現能力があればそれなりにできそうですが,単に表示させるだけではダメでそこにカーソルを当てて編集できなければなりません.Scrapbox では,エディタ上のテキストとカーソルを全く別物のオブジェクトとして実装し,座標を通じてやりとりをしているようです.
エディタ上のテキストは各行について1文字ずつに分解され,そのそれぞれがspan要素として配置されます.自作エディタのHTMLの一部をもってくるとこんな感じです.<div class="React-Realtime-Markup-Editor-line L1"> <span> <span class="React-Realtime-Markup-Editor-char L1C0"> <span>A</span> </span> <span class="React-Realtime-Markup-Editor-char L1C1"> <span> </span> </span> <span class="React-Realtime-Markup-Editor-char L1C2"> <span>t</span> </span> <span class="React-Realtime-Markup-Editor-char L1C3"> <span>e</span> </span> <span class="React-Realtime-Markup-Editor-char L1C4"> <span>x</span> </span> <span class="React-Realtime-Markup-Editor-char L1C5"> <span>t</span> </span> </span> ... </div>これによって,
A text ...
がクラス名による座標情報(L1C0は1行目の0文字目,など)付きで表示されます.こうなってしまえば,テキスト行側における文字の装飾はCSSによる馴染み深い方法で行うことができます.カーソル側はテキスト行につけられたクラス名をもとにして,クリックされた場所のxy座標から,何行目の何文字目がクリックされたのかを検出し,カーソルのテキスト行上の座標を決定します.
この方法をとる以上は,テキスト行もカーソルも結局はただの React Component であり,テキスト行やカーソルをそれらしく見せて動かす処理はほとんど自前で作らなければいけないということです.実に恐ろしいですが,これをやる必要があるのです.カーソルはただの縦線らしい
次に,カーソルをカーソルらしく見せる方法ですが,カーソルの縦棒は本当にただの縦棒のようです.より正確に言えば,限りなく縦に長い rect 要素なようです.
自作エディタのHTMLの一部をもってくるとこんな感じです.<div class="React-Realtime-Markup-Editor-cursor" style="top: 49px; left: 88.0156px; height: 16px;"> <svg width="2px" height="16"> <rect x="0" y="0" width="1px" height="100%"></rect> </svg> </div>まだ文字が打てそうな気配がありませんが,そこは keydown イベントを割り当てて状態遷移を気合で制御をしていきます.その苦労は次の節で話します.
日本語入力は IME のためだけに隠し textarea を使うらしい
エディタをエディタたらしめる機能をほとんど自前で実装しなければならないとは言え,日本語入力の変換まで自分で実装をするのはあまりに非現実的です.Scrapbox では IME の部分はうまく標準の HTML 要素を使って代用しているようです.
具体的には,カーソルのすぐ横に隠し textarea 要素を常についてくるようにしておき,実際の文字入力やキーボード操作はその隠し textarea 要素に対して行わせるようにします.実際の文字入力は隠し textarea に対して行っているので,IMEも問題なく用いることができます.
具体的には,keydown イベントをこの隠し textarea に割り当てて,以下のように制御をします.
- 矢印キーやショートカットキーなどの特殊な入力は
event.preventDefault()
を呼んでから個別に処理する- 通常の文字入力は keydown イベントでは特殊な処理はせず,通常通りに onChange イベントを通じて textarea に入力された文字を吸い取ってエディタ上の文字として反映する
範囲選択は3つの div らしい
テキスト行とカーソルが実装できるとかなりエディタっぽくなりますが,まだ足りないのは範囲選択です.範囲選択をしてコピー&ペーストをしたり,一気に削除したりする操作は頻繁に使うものかと思いますが,ここまでのエディタではまだそれはできません.範囲選択ももちろん自前で作る必要があります.
範囲選択の簡単な実装方法として,3つの div を用いる方法があるようです.簡単に言えば,これらは範囲選択の上側と下側と真ん中です.この方針では,範囲選択を(1)1行の場合(2)2行の場合(3)3行以上の場合の3つに場合わけして考えます.(1)1行の場合
範囲選択が1行のみの場合,選択範囲の形は1つの長方形となります.
したがって,1つの長方形の div があれば表現することができます.(2)2行の場合
範囲選択が2行の場合,選択範囲の形は1行目と2行目の2つの長方形で表現できます.
したがって,2つの長方形の div があれば表現することができます.(3)3行以上の場合
3行以上の場合,1行目と最終行は半端なところから始まったり半端なところで終わったりする可能性があります.ところが,それ以外の真ん中の行は1文字目から最後の文字まで必ず選択されています.したがって,真ん中の行はその全体を1つの大きな長方形で囲ってしまえば,範囲選択を表現できます.
したがって,1行目と最終行を囲う2つの長方形と真ん中の行たちを囲う大きな1つの長方形の合計3つの長方形の div があれば表現することができます.複雑な状態遷移を1つずつ確実に,切り分けて
react-realtime-markup-editor の実装における最大の試練は,
- 多くの複雑な状態遷移を
- 拡張性と保守性,テスタビリティを担保しながら
- 1つずつ確実に実装すること
に尽きると感じました.この節ではこの点について詳細に述べていきます.
状態遷移の切り分けに Redux は便利だが
1文字の入力からカーソル移動,ショートカットキーまで全ての状態遷移を自前で制御しなければならないとなると,その処理の記述は非常に複雑で巨大なものになります.こうなると,描画と状態遷移のロジックを切り分けて別々に制御して,書きやすくしたいものです.
描画と状態遷移のロジックを切り分けるための仕組みは Redux が有名です.描画を行う container と状態遷移名を与える action と状態遷移を実際に行う reducer という形でうまく切り分けています.
しかしながら,自作エディタはあくまで1ライブラリであり,自作エディタをインストールしたプログラマに Redux のインストールまで強要するのは得策ではありません.なるべく軽量でささっと入れて試せる,そんなものでありたいのです.解決案:Redux もどきの utility 関数たち
Redux を入れないで状態遷移と描画を切り分ける方法は簡単です.なんちゃって reducer もどきを自分で作れば良いのです.具体的には,現在の props と state をもらって次の state を返す関数を作ります.
function handleOnEvent(props: Props, state: State, event: Event): Stateこれらの関数はほとんどが純粋な関数として実装できるので,複雑なものでも容易に細かく切り分けることができ,見通しがよくなります.
おわりに
react-realtime-markup-editor という自作ドキュメントエディタを紹介しました.
まとめ6行
- 単純な記法でリアルタイムに文字装飾ができ,必要なスペースも本質的な最低限です
- 数式・コードブロックもサポートしています(複数行の数式はこれからです)
- 独自機能として,タグ付きリンクとリンクの入力推薦を提供します
- 世の中のエディタは以外と泥臭く作られていて,細かく複雑な処理も自前なようです
- 細かく複雑な処理を確実に作るたに,状態遷移ロジックを関数群に切り分けています
- 今はまだα版ですが,正式リリースに向けてこれからも開発を続けます!
- 投稿日:2020-12-18T00:17:57+09:00
Firebase Realtime Databaseでよくあるクソチャットをつくる。成果物あり。成果物: https://firebase-chat2.netlify.app
成果物
https://firebase-chat2.netlify.app
使用ライブラリ周り
- Firebase Realtime Database
- Firebase Authentication(匿名ログイン)
- Netlify(ホスティング)
- TypeScript
- React
- BootStrap4
Firebase Realtime Database Rule
{ "rules": { ".write": false, "connections": { ".read": "auth != null", "$uid": { ".write": "auth.uid === $uid" } }, "boards": { ".read": "auth != null", ".write": "auth.uid != null", ".indexOn": ["uid", "createdAt"], "$bid": { ".validate": "newData.hasChildren(['uid', 'message', 'createdAt', 'updatedAt'])", "uid": { ".validate": "newData.isString() && newData.val() === auth.uid" }, "message": { ".validate": "newData.isString() && 0 < newData.val().length && newData.val().length <= 150" }, "createdAt": { ".validate": "newData.isNumber()" }, "updatedAt": { ".validate": "newData.isNumber()" }, "$other": { ".validate": false } } } } }ソース
package.json{ "scripts": { "start": "parcel src/index.html --out-dir public", "build": "parcel build ./src/index.html -d dist --no-source-maps && cp -r _headers dist/" }, "devDependencies": { "parcel-bundler": "^1.12.4", "typescript": "^4.1.3", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "@types/lodash": "^4.14.165", "@babel/core": "^7.12.10", "cssnano": "^4.1.10" }, "dependencies": { "babel-polyfill": "^6.26.0", "firebase": "^8.2.0", "lodash": "^4.17.20", "moment": "^2.29.1", "react": "^17.0.1", "react-dom": "^17.0.1", "react-string-replace": "^0.4.4" } }tsconfig.json{ "compilerOptions": { "jsx": "react", "baseUrl": ".", "paths": { "src/*": ["src/*"] }, "esModuleInterop": true, "strict": true } }src/index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>リアルタイムチャット</title> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous" /> <link rel="stylesheet" href="/style.css" /> </head> <body> <div id="app"></div> <script src="/index.tsx"></script> </body> </html>src/style.cssbody { margin: 0; } /* bootstrapの謎の右の余白を消す */ #_wrap { overflow: hidden; background: #f7f7f7; display: flex; flex-direction: column; min-height: 100vh; } main { flex: 1; } section { width: 80%; margin: 0 auto; } aside { width: 60%; } /* スマホ用 */ @media screen and (max-width: 768px) { section { width: 95%; } aside { display: none; } }src/index.tsximport * as React from 'react'; import * as ReactDOM from 'react-dom'; import * as _ from 'lodash'; import moment from 'moment'; moment.locale('ja'); const reactStringReplace = require('react-string-replace'); import firebase from 'firebase/app'; import 'firebase/auth'; import 'firebase/database'; import 'babel-polyfill'; // 定数 const Constant = { table: { boards: 'boards', connections: 'connections', }, }; const firebaseConfig = { apiKey: 'AIzaSyCa-1VYUS0d-9QKDdeY5m2eWkhWFR0kcQ4', authDomain: 'rrrr-ee1c6.firebaseapp.com', databaseURL: 'https://rrrr-ee1c6-default-rtdb.firebaseio.com', projectId: 'rrrr-ee1c6', storageBucket: 'rrrr-ee1c6.appspot.com', messagingSenderId: '873321758373', appId: '1:873321758373:web:2b4bca00a6f8b1f372574c', measurementId: 'G-P4HDPYHDTS', }; firebase.initializeApp(firebaseConfig); // ローディング画面 const Loading = () => { return ( <> <div className="position-absolute h-100 w-100 m-0 d-flex align-items-center justify-content-center"> <div className="spinner-border text-primary" role="status"> <span className="sr-only">Loading...</span> </div> </div> </> ); }; // メッセージリスト const MessageList = (params: { list: { uid: string; message: string; createdAt: number; }[]; }) => { return ( <> {params.list.map((m, i) => { return ( <div key={i} style={{ maxWidth: 600, margin: '0 auto' }}> <div className="card"> <div className="card-header" style={{ textAlign: 'center', background: '#fff' }} > <div> <span>uid: </span> <span style={{ fontSize: 13, color: 'green', fontWeight: 'bold', }} > {m.uid} </span> </div> <time style={{ fontSize: 13 }}> {moment(new Date(m.createdAt)).format('YYYY-MM-DD HH:mm:ss')} </time> </div> <div className="card-body"> <p className="card-text" style={{ whiteSpace: 'pre-line', fontWeight: 'bold', textAlign: 'center', }} > {reactStringReplace( m.message, /(https?:\/\/\S+)/g, (match: string, j: number) => ( <a href={match} key={match + j} target="_blank" rel="noopener noreferrer" > {match} </a> ) )} </p> </div> </div> <br /> </div> ); })} </> ); }; const App = () => { // ローディングフラグ const [loading, setLoading] = React.useState(true); // 投稿リスト const [list, setList] = React.useState( [] as { uid: string; message: string; createdAt: number; }[] ); // 投稿メッセージ const [message, setMessage] = React.useState(''); // 同時接続数 const [su, setSu] = React.useState(0); // 同時接続数監視 const func = async () => { const presenceRef = firebase.database().ref('/.info/connected'); const listRef = firebase .database() .ref( Constant.table.connections + '/' + (await firebase.auth().currentUser?.uid) ); const userRef = listRef.push(); presenceRef.on('value', async snap => { if (snap.val()) { userRef.onDisconnect().remove(); userRef.set(await firebase.auth().currentUser?.uid); } }); firebase .database() .ref('connections') .on('value', s => { setSu(s.numChildren()); }); }; // 投稿処理 const post = async () => { const _message = message; if (_message.trim().length === 0) { alert('メッセージを入力してください'); return; } setMessage(''); document.getElementById('textarea')?.blur(); await firebase .database() .ref(Constant.table.boards) .push({ uid: await firebase.auth().currentUser?.uid, message: message.trim(), createdAt: new Date().getTime(), updatedAt: new Date().getTime(), }); document.getElementById('textarea')?.focus(); }; // componentDidMount() React.useEffect(() => { firebase.auth().onAuthStateChanged(async data => { if (data === null) { // 匿名ログイン await firebase.auth().signInAnonymously(); } firebase .database() .ref(Constant.table.boards) .orderByChild('createdAt') .limitToLast(50) .on('value', snapshot => { let _data: any[] = []; snapshot.forEach(childSnapshot => { _data.push(childSnapshot.val()); }); setList(_.orderBy(_data, 'createdAt', 'desc')); setLoading(false); func(); }); }); }, []); if (loading) { return <Loading />; } return ( <div id="_wrap"> {/* ヘッダー */} <header> <h1 style={{ fontSize: 20 }}>リアルタイムチャット</h1> <p>接続ユーザ数: {su}</p> </header> {/* メイン */} <main> <div className="row"> <article className="col-xs-12 col-sm-12 col-md-12 col-lg-9 col-xl-9"> <section> <div className="form-group"> <textarea style={{ maxWidth: 400, margin: '0 auto' }} className="form-control" id="textarea" placeholder="メッセージ 150文字以内" maxLength={150} value={message} rows={3} onChange={e => setMessage(e.target.value)} onKeyDown={e => (e.keyCode === 13 ? post() : '')} ></textarea> </div> <div className="text-center"> <button className="btn btn-primary" onClick={() => post()}> 投稿 </button> </div> <hr /> <p>最新50件</p> <MessageList list={list} /> </section> </article> {/* サイドバー */} <aside className="col-xs-0 col-sm-0 col-md-0 col-lg-3 col-xl-3"> <div className="card" style={{ width: '90%', height: 1000 }}> 広告スペース </div> </aside> </div> </main> {/* フッター */} <footer></footer> </div> ); }; ReactDOM.render(<App />, document.getElementById('app'));
- 投稿日:2020-12-18T00:03:32+09:00
【React入門】学習メモ #3
この記事はReact Advent Calendar 2020 18日目の記事です。
前回までの記事です。
【React入門】学習メモ #1
【React入門】学習メモ #2はじめに
React学習の備忘録です。
間違い等ございましたら、ご指摘いただけますと幸いです今回は、実際にサンプルを作りながらReactにおける表示/非表示の考え方を学習していきます。成果物としては、ボタンによる切り替えプログラムです。
成果物
環境構築
プロジェクトの作成
$ npx create-react-app プロジェクト名プロジェクト名は何でもOKです。
ディレクトリ構造と使用ファイル
ディレクトリ構造
プロジェクト/ ├─ src/ ├─ index.js └─ component/ ├─ Button.jsx └─ Button.css使用するファイル
- index.js - Button.jsx - Button.cssindex.jsの変更点
ReactDOM.renderを弄って、表示させるコンポーネントを変更します
index.jsimport Button from './component/Button'; //Buttonをimport ReactDOM.render( <React.StrictMode> <Button /> {/* App -> Button に変更してください */} </React.StrictMode>, document.getElementById('root') );これで下準備は終了です。
表示/非表示の考え方
考え方は至ってシンプルです。
state
を使い、state
の切り替えによって表示/非表示を実現します。使用するのは以下2点
- state
- if文
実装の順序
- 格stateの定義
- stateを変更するメソッドを定義
- JSXとif文を組み合わせて格部品を作成
- 部品の表示
1.格stateの定義
clickボタンを押下して出現する文字列は初期状態では表示させなくてもよいので、
false
です。
clickボタンは初期状態で表示させたいので、true
です。Button.jsxconstructor(props) { super(props); this.state = { // 文字列の状態 isOpenStr: false, // clickボタンの状態 isOpenButton: true }; }2.stateを変更するメソッドを定義
Button.jsx// clickボタンを押下したときに呼び出す handleClickOpen() { // 文字列を表示 this.setState({isOpenStr: true}); // clickボタンを非表示 this.setState({isOpenButton: false}); } // とじるボタンを押下したときに呼び出す handleClickClose() { // 文字列を非表示 this.setState({isOpenStr: false}); // clickボタンを表示 this.setState({isOpenButton: true}); }コメントの通りです。
3.JSXとif文を組み合わせて格部品を作成
Button.jsxrender() { // 文字列を格納する変数 let str; // isOpenがtrue(clickボタンが押されたとき)の状態 if (this.state.isOpenStr) { str = ( <div> <h1>Hello React</h1> {/* とじるボタン(とじるボタンを押下するとhandleClickCloseメソッドによりisOpenがfalseに切り替わり要素が消える) */} <button className='close-btn' onClick={() => {this.handleClickClose()}}>とじる</button> </div> ); } // ボタンを格納する変数 let btn; // isOpenButtonがtrue(clickボタンがまだ押下されていないとき)の状態 if (this.state.isOpenButton) { btn = ( <div> {/* 表示ボタン(clickボタンを押下するとhandleClickOpenメソッドにより、isOpenがtrueに切り替わり要素が出現する) */} <button onClick={() => {this.handleClickOpen()}}>Click</button> </div> ); }それぞれのif文の条件を解説すると、
(this.state.isOpenStr)がtrueであれば、中の処理を実行する。
trueというのは、clickボタンを押下した状態のことです。(clickボタンを押下しなければtrueにはなりません)(this.state.isOpenButton)がtrueであれば、中の処理を実行する。
trueの状態は、初期状態なのでclickボタンが押されていない状態になります。
中の処理に関して解説すると、
- clickボタンを押下でhandleClickOpenメソッドが実行
- handleClickOpenメソッドによって、stateが変更される
- その結果、clickボタンは非表示になり、文字列と、とじるボタンが表示されます
- とじるボタンを押下でhandleClickCloseメソッドが実行
- handleClickCloseメソッドによって、stateが変更される
- その結果、clickボタンが表示され、文字列と、とじるボタンが非表示になります
4.部品の表示
最後にreturnの中で
中括弧{}
を用いてそれぞれの部品を表示しています。Button.jsxreturn( <div> { str } { btn } </div> );成果物
最終的な成果物になります。
コピペで直ぐに動きますので、是非ご自身のローカル環境で動かしてみてください。Button.jsximport React from 'react'; import './Button.css'; class Button extends React.Component { constructor(props) { super(props); this.state = { // 文字列の状態 isOpenStr: false, // clickボタンの状態 isOpenButton: true }; } // clickボタンを押下したときに呼び出す handleClickOpen() { // 文字列を表示 this.setState({isOpenStr: true}); // clickボタンを非表示 this.setState({isOpenButton: false}); } // とじるボタンを押下したときに呼び出す handleClickClose() { // 文字列を非表示 this.setState({isOpenStr: false}); // clickボタンを表示 this.setState({isOpenButton: true}); } render() { // 文字列を格納する変数 let str; // isOpenがtrue(clickボタンが押されたとき)の状態 if (this.state.isOpenStr) { str = ( <div> <h1>Hello React</h1> {/* とじるボタン(とじるボタンを押下するとhandleClickCloseメソッドによりisOpenがfalseに切り替わり要素が消える) */} <button className='close-btn' onClick={() => {this.handleClickClose()}}>とじる</button> </div> ); } // ボタンを格納する変数 let btn; // isOpenButtonがtrue(clickボタンがまだ押下されていないとき)の状態 if (this.state.isOpenButton) { btn = ( <div> {/* 表示ボタン(clickボタンを押下するとhandleClickOpenメソッドにより、isOpenがtrueに切り替わり要素が出現する) */} <button onClick={() => {this.handleClickOpen()}}>Click</button> </div> ); } return( <div> { str } { btn } </div> ); } } export default Button;Button.cssbody { margin: 0; padding: 0; font-family: sans-serif; text-align: center; min-width: 400px; } #root { margin-top: 15%; } .close-btn { font-size: 13px; color: #8491a5; width: 200px; padding: 16px 0; border: 0; background-color: #f0f4f9; cursor: pointer; } h1 { font-size: 64px; margin: 0 auto; } button { display: inline-block; font-size: 32px; width: 200px; height: 48px; border-radius: 4px; margin-right: 24px; margin-left: 24px; color: #fff; background-color: #66ccff; border: none; outline: none; box-shadow: 4px 4px #d8d8d8; cursor: pointer; appearance: none; } button:active { position: relative; top: 4px; left: 4px; box-shadow: none; }終わり。