- 投稿日:2019-11-30T23:57:00+09:00
【 初心者向け】ReactNativeで電卓アプリを作ろう【 チュートリアル】
こんにちは!DMM WEBCAMPでメンターをしている人です!
アドベントカレンダー16日目は、(僕の中で)最近話題のReact Nativeを使って電卓を作ってみます。チュートリアル形式でReact未経験の方でも分かるように専門用語を出来るだけ削って書いていきます。
是非トライしてみてください!
1.React Nativeとは??
Facebookが開発した「React」というJavaScriptのフレームワークの亜種で、iOSやAndroidのアプリを作ることが出来る代物です。Reactは仮装DOMが実装されているので表示が早いというメリットがあります。
・jsなら任せて!
・Reactならわかるぞ!
・さくっとアプリ作りたい!
・スマホのアプリが作りたいけどSwift・Kotlinやる時間がない・・・
なんて方にオススメのアプリケーションフレームワークです。さらに踏み込んだ解説が知りたい方はこちらの記事がオススメです。
「React Nativeとは何なのか」2.今回やること
この記事では、四則演算が出来る電卓アプリを作っていきます。
3.開発環境&構築
・Androidの開発を行う場合は、「Android studio」
・iOSの開発を行う場合は、「Xcode」が必要になります。今回はiOSのアプリを作るのでAndroidの環境構築は省かせていただきます。ごめんなさい
(Androidの開発が行いたい方はこちらの記事が分かりやすかったです!)まずはnodeとwatchmanというものをインストールします。
$ brew install node $ brew install watchman次にexpoのダウンロードです。
nodeをインストールしたときにnpmが入っているのでnpmコマンドが使えます。$ npm install expo-cli --gExpoを深く知りたい方はこちらの記事がGoodです。
これで完了!といいたいところですがExpoはXcodeがないと開けないため、Xcodeのダウンロードをしましょう。
Xcodeで開いたiOSシュミレーター(PC上で開かれるiPhone)の中にExpoアプリがインストールされるのでXcode必須です。Xcodeとは
XcodeはAppleが開発したソフトウェア開発者向けの主にMacやiPhone、iPadのアプリケーションを作るのに向いています。XcodeのおかげでMac上でiOSのシュミレーターが起動できます。
ダウンロードしたことがない方はApple Storeからダウンロードしてください!
ただ、めちゃくちゃサイズがデカいのでインストールに時間がかかります。
待ち時間にReactNativeの公式ドキュメント でも読んで時間を潰しちゃいましょう。
ここまできたら準備完了です!
プロジェクトの新規作成
以下のコマンドを実行してReactNativeアプリを新規作成しましょう!
$ expo init お好きな名前
プロジェクトのテンプレート選択
expo init ○○○後に、画像のようにテンプレートをどうするか聞かれますがここは「blank」を指定しましょう。
名前の設定をしよう
ここでは実際のアプリ名を設定します。(アプリアイコンの下に表示される名前)
リリースを考えているアプリの場合変な名前にしてはいけません!!!
Yarnは使わなくてOK!
Yarn v1.19.1 found. Use Yarn to install dependencies? (Y/n)このような質問がされる場合があります。
これは「Yarnが見つかったけどYarnは使う?どうする?」と聞かれています。
今回はnpmを使用しているので「n」を押してEnterです。最終行にこのような記述があればアプリの作成完了です!
:(省略) : Your project is ready at 〜〜略〜〜〜 To get started, you can type: cd アプリ名 npm startnpm start を実行してこの画面が表示されれば下ごしらえも完了です!
Railsに比べてシックで落ち着いたスタート画面ですね、おしゃれ。
補足:自分のスマホで開いてみよう
なんと自分の携帯にも開発しているアプリを開くこともできます。
まず。ご自身のスマホにExpoのアプリ(AppleStoreもしくはGooglePlayにて「Expo」で検索です!)をインストールします。
次に、アプリを読み取るQRコードをための作業をPCでします。
アプリが立ち上がった後のターミナルで
Press ? to show a list of all available commandsと出たら、「?」を入力します。次に「d」を押すとブラウザでデベロッパツールが開かれます。
デベロッパーツールにあるQRコードを読み取ると作っているアプリのURLが出るのでタッチしましょう!これでExpoアプリが開かれます!!
4.実装!
まず初めに、開発するアプリケーションの階層から見ていきましょう。
.expo ├ packager-info.json ├ setting.json .expo-shared ├ assets.json .assets ├ icon.png ├ splash.png .node_module ├ 色々入ってます .gitignore App.js ← 主にこれを使います babel.config.js package-lock.json package.json style.js ← こちらを新規作成します5.アプリ作成後に書かれている記述の意味は?
アプリの新規作成後にデフォルトで開かれるのは「App.js」というファイルです。そこにいろいろな記述を書いていき、最終的にはかっこいい電卓を作ります。
では、App.jsに最初から書かれている記述を見ていきましょう。
App.jsimport React from 'react'; // ① import { StyleSheet, Text, View } from 'react-native'; // ② export default function App() { // ⑤ return ( <View style={styles.container}> // ③ <Text>Open up App.js to start working on your app!</Text> </View> ); }; const styles = StyleSheet.create({ // ④ container: { flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }, });importってなに?
①と②はReact Nativeを使って開発するときのおまじないのようなものです。
①は、"react"モジュールからimport(=輸入する)して、Reactの記述を使用できるようにしています。
アプリケーションの中にnode-moduleというフォルダがあり、更にその中のreactというフォルダからimportしてきて色々読み込んでいます。
②は、"react-native"モジュールからimportして、StyleSheetやTextクラスを使えるようにしています。
こちらもnode-moduleの中のreact-nativeフォルダからimportしてきています。
「なんで全部importしておかないの?」と思った方いませんか?僕は思いました。
実は、全ファイルやコンポーネントをimportするより、各々が使いたいファイルやコンポーネントを選んでimportする方が読み込み速度が上がるらしいです。言われてみれば納得です
Reactの描画速度が早いと言われる理由はこんなところにもあるんです!
コンポーネントについて
React Nativeでは描画するために独自のコンポーネントを使用する必要があります。
理由としては、divやspanタグが使えないので、Viewなどのコンポーネントを使う必要があるというわけです。
(この概念が難しいと言うか、コードを理解不能にしている要因の1つですが、慣れると余裕です)現状のコードの中だと、VIewとTextとStyleSheetがReact Nativeのコンポーネントです。
importせずにこれらのコンポーネントを使用するとエラーを吐いてしまいます。
だから自分が使用したいコンポーネントに合わせて②に追記していく感じです。
例えば入力フォームを作成したい場合は、以下のように追記します。
App.jsimport React from 'react'; import { StyleSheet, Text, View, TextInput } from 'react-native'; //←追加!!! export default function App() { return ( <View style={styles.container}> <Text>Open up App.js to start working on your app!</Text> <TextInput style={styles.input} /> //←追加!!! </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }, input: { //←追加!!! width: 100, borderColor: "gray", borderWidth: 1, }, });<TextInput /> <TextInput> ~~ </TextInput>の省略系です、便利!
コンポーネントはたくさん種類があり公式ドキュメントにたくさん載っています。
StyleSheet.createってなに?
続いて、③と④についてです。この2つはスタイル(見た目)を担っている部分です
何をしているかというとStyleSheetというコンポーネントを作成し、その中にスタイルの記述をたくさん書いています。
では、文字を赤くさせたい!なんて時はどうすればよいでしょうか。
3ステップです。
1. stylesの中に、新しく「redColor」を定義します。(ここの命名は自由です)
2.「redColor」を反映させたいコンポーネントにstyle={styles.redColor}を足します
3. 完成App.jsimport React from 'react'; import { StyleSheet, Text, View, TextInput } from 'react-native'; export default function App() { return ( <View style={styles.container}> <Text style={styles.redColor}>Open up App.js to start working on your app!</Text> //← 追記!! <TextInput style={styles.input} /> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }, input: { width: 100, borderColor: "gray", borderWidth: 1, }, redColor: { //← 追記!!! color: "red" }, });いちいちこんなことするの面倒臭い!って方には、コンポーネントに直接スタイルを書くのがオススメです。こんな風に直書きができます。
<TextInput style={{width:100, borderColor: 'gray', borderWidth: 1 }} />ハマりポイント スタイルはキャメルケースで!
注意点としてはReact Nativeではプロパティーの部分はキャメルケースのみ使えます。
キャメルケースとは、「backgroundColor」のような、単語と単語の間にスペースや"-"を置かずに、文字の始まりを大文字にする形式です。
(ラクダのこぶに似てることからキャメルケースと呼ばれます。他にもスネークケース、ケバブケースがあります)普段「background-color」としていたところを「backgroungColor」としなければなりません。
ようやくコンポーネントが描画される!
export default function App() { // ⑤ return ( <View style={styles.container}> <Text>Open up App.js to start working on your app!</Text> </View> ); };ラストです。
ここではimportしたViewとTextコンポーネントを使用しています。HTML/CSS経験者であればこの書き方に違和感を感じますよね。
意訳ですが、HTML/CSSに変換して記述してみるとこんな感じです。
コンポーネントの使い方とイメージがつかめば全然怖くありません。意訳.html<div style="省略"> <p>Open up App.js to start working on your app!</p> </div>続いて、以下の部分です。
export default function App() { // ⑤ };exportとは先ほど出てきたimportの反対の意味です。
export内で定義した記述を他の場所で使えるようにするものです。
例えばsample.jsを新規作成し、そこにexport~~の記述をしたコンポーネントを2つ書きます。
これをApp.jsで受け取りたいとしましょう。
sample.jsimport React, {Component} from 'react'; import {Text, View} from 'react-native'; export class Sample1 extends Component { render(){ return( <View> <Text>サンプルの1です</Text> </View> ); }; }; export class Sample2 extends Component { render(){ return( <View> <Text>サンプルの2です</Text> </View> ); }; };App.jsimport React from 'react'; import { StyleSheet, View, } from 'react-native'; import {Sample1 , Sample2} from "./sample"; //←ここでimportしています export default function App() { return ( <View style={styles.container}> <Sample1 /> //←ここでimportしたSample1を <Sample2 /> //←ここでimportしたSample2を使っています </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }, });defaultオプション付きのexportは、そのファイルの中においてデフォルトでexportされるコンポーネントを決めることができます。default付きexportはimportする時の名前はなんでもよく、ついていないものはclass名をimportする側で同じにしなければなりません。(面倒くさい)
例えば先ほどのSample1クラスにdefaultを設定します
sample.jsexport default class Sample1 extends Component { //略次にApp.jsでimportします。
App.jsimport hogehoge ,{Sample2} from "./sample"; //←import名を適当にしてます //略 <View style={styles.container}> <hogehoge /> //←ここでimportしたhogehogeを使える! <Sample2 /> </View>defaultが付いていないものは{ }で囲う必要があるので要注意です!
export defaultについて詳しくはこちらです。
6.スタイルを適応させよう
次にレイアウトを整えるためにスタイル(≒CSS)を作っています。
ここはそれほど重要ではないので、何をしているか理解したらコピペしちゃって大丈夫です。
また、App.jsにスタイルの記述をしてもいいのですが個人的に見辛いのでファイルを分けました。
2ステップです。
①App.jsと同じ階層にstyle.jsを新規作成する
②以下のコードを貼り付ける
style.jsimport { StyleSheet } from "react-native"; const styles = StyleSheet.create({ container: { backgroundColor: '#FFF', }, countArea: { marginTop: 140, justifyContent: "center", alignItems: "center", marginBottom:10 }, buttonArea: { marginTop: 5, justifyContent: 'center', alignItems: 'center' }, value: { fontSize: 30 }, topbutton: { marginTop: 50 }, button: { marginTop: 5, justifyContent: 'center', alignItems: 'center', width: 50, height: 40, borderRadius: 5, borderWidth: 1, borderColor: '#fff' }, buttonText: { fontSize: 25, color: '#fff' }, buttonUp: { backgroundColor: '#A53434', fontWeight: "bold" }, buttonUpText: { backgroundColor: 'red' }, buttonMinus: { backgroundColor: '#343EA5' }, buttonTimes: { backgroundColor: 'purple' }, buttonDivide: { backgroundColor: "green" }, buttonReset: { borderColor: '#AFADAD', marginBottom: 200 }, buttonResetText: { color: '#000', fontSize:11 }, buttonNum: { backgroundColor: 'grey', fontWeight: "bold", justifyContent: 'center', alignItems: 'center', flexDirection: 'row', width: 60, height: 40, borderRadius: 5, borderWidth: 1, borderColor: '#fff' }, buttonEqual: { backgroundColor: "black" } }); export default (styles);"react-native”から”StyleSheet”コンポーネントをimportしています。
こうすることによって
3行目でStyleSheet.createが使えるようになります。
この記述をすることによって、このコンポーネントはスタイルの記述をしますよ〜!と明示することが出来ます。
(StyleSheet.createしなくても動作はしますが、一応書いたほうがいいです。詳しくはこちら の記事です。)そして、最終行では、このstyle.jsがどこでも使えるようにexportしています。
これをApp.jsでimportすればスタイルの土台は完成ですね。
7.電卓のレイアウトを作ろう
スタイルを作成したので、次はそれを適応させるためのコンポーネントを作っていきます。
App.jsに書いている記述は1度全て消してから以下のコードを書いていきましょう!ここからスタートです!!まずは先ほどのstyle.jsをApp.jsでimportします。
ついでに、この後使う「TouchableOpactity」というコンポーネントもimportしておきます。
App.jsimport React, {Component} from 'react'; import { Text, View, TouchableOpacity } from 'react-native'; //←StyleSheetとInputTextがある方は削除でOK! import styles from "./style" //←追記!次にコンポーネントの作成を行っていきます。
App.js//↓import ~~~の下に↓ export default class App extends Component { render() { return ( <View> <View style={styles.countArea}> <Text style={styles.value}>0 ? 0 = 0</Text> </View> </View> ); }; };計算用ボタンの設置
次に電卓のボタンを作っていきましょう。
先ほどimportした「TouchableOpacity」がここで出番です。
TouchableOpacityとは、簡単に言うとクリックできる要素のことです。
これを用いることでボタンが簡単に作れます。App.js//↓import ~~~の下に↓ export default class App extends Component { render() { return ( <View> <View style={styles.countArea}> <Text style={styles.value}>0 ? 0 = 0</Text> </View> <View style={styles.buttonArea}> <View style={{ flexDirection: 'row'}}> <TouchableOpacity style={[styles.button, styles.buttonUpText]}> <Text style={styles.buttonText}>+</Text> </TouchableOpacity> </View> </View> </View> ); }; };画像のようなボタンが表示されたでしょうか?
あとは、数字のボタンや四則演算のボタンを設置していきます!
一気に書いていきましょ〜!
App.jsexport default class App extends Component { render() { return ( <View> <View style={styles.countArea}> <Text style={styles.value}>0 ? 0 = 0</Text> </View> <View style={styles.buttonArea}> //足し算ボタン <View style={{ flexDirection: 'row'}}> <TouchableOpacity style={[styles.button, styles.buttonUpText]}> <Text style={styles.buttonText}>+</Text> </TouchableOpacity> //引き算ボタン <TouchableOpacity style={[styles.button, styles.buttonMinus]} > <Text style={styles.buttonText}>-</Text> </TouchableOpacity> //掛け算ボタン <TouchableOpacity style={[styles.button, styles.buttonTimes]} > <Text style={styles.buttonText}>x</Text> </TouchableOpacity> //割り算ボタン <TouchableOpacity style={[styles.button, styles.buttonDivide]} > <Text style={styles.buttonText}>÷</Text> </TouchableOpacity> //=ボタン <TouchableOpacity style={[styles.button, styles.buttonEqual]} > <Text style={styles.buttonText}>=</Text> </TouchableOpacity> //リセットボタン <TouchableOpacity style={[styles.button, styles.buttonReset]} > <Text style={[styles.buttonResetText]}>リセット</Text> </TouchableOpacity> </View> </View> </View> ); }; };数字ボタンの設置
次は0〜9の数字ボタンを作っていきます。
先ほどの計算ボタンはボタンごとに色や文言が変わるので個別に作成しました。
しかし、0〜9の数字ボタンは値やボタンの文字が変わるだけです。なので繰り返し処理を使って簡潔に記述しましょう!
まずは、以下のように配列を定義してあげます。
App.js//〜〜〜略 exporet ~~ render() { return ( <View> //略 </View> ); }; }; //↓ここの下に記述 const ZeroToFour = []; for (let i = 0; i< 5; i++) { ZeroToFour[i] = i }; const FiveToNine = []; for (let i = 5; i< 10; i++) { FiveToNine[i] = i };まずは繰り返し処理に使う配列を2つ用意します。ZeroToFourは0〜4、FiveToNineは5〜9のボタンを作ります。
(レイアウト調整のためここは敢えて配列2つ使っています、10個まとめて作るとレイアウトが綺麗に整えられませんでした。甘えです、ごめんなさい)App.js//略 export default class App extends Component { render() { return ( <View> <View style={styles.countArea}> <Text style={styles.value}>0 ? 0 = 0</Text> </View> <View style={styles.buttonArea}> //↓追加↓ <View style={{flexDirection: 'row'}}> {ZeroToFour.map(i => ( <TouchableOpacity style={styles.topbutton,styles.buttonNum} key={i} > <Text style={styles.buttonText}> {i} </Text> </TouchableOpacity> ))}; </View> <View style={{ flexDirection: 'row'}}> {FiveToNine.map(i => ( <TouchableOpacity style={styles.topbutton, styles.buttonNum} key={i}> <Text style={styles.buttonText}> {i} </Text> </TouchableOpacity> ))}; </View> //ここより上を追加 //足し算ボタン //<View style={{ flexDirection: 'row'}}> //<TouchableOpacity style={[styles.button, styles.buttonUpText]}> //<Text style={styles.buttonText}>+</Text> //</TouchableOpacity>少し複雑な記述です。
ここはZeroToFourとFiveToNineの値を繰り返し処理(map)で出力させています。
実際は見本のように描画されていますが10個分の記述を書くとなると非常に長いので繰り返し処理を用いてボタンを作っています。
styleのflexDirection: "row"は、要素を横並びにするスタイルです。
このおかげでかっこいい電卓のレイアウトが出来ましたね。見本<View style={{flexDirection: 'row'}}> <TouchableOpacity style={styles.topbutton,styles.buttonNum}> <Text style={styles.buttonText}> 0 </Text> </TouchableOpacity> . . (1~8省略) . <TouchableOpacity style={styles.topbutton,styles.buttonNum}> <Text style={styles.buttonText}> 9 </Text> </TouchableOpacity> </View>ようやくここまででレイアウトが整いました。お疲れ様です
後半では実際にデータをやりとりしたり計算する処理を作っていきます。
前半戦の完全版コードをこちらに置いておきます。うまくいかない方は確認してみましょう!
後編はこちらです!!!
8.数字ボタンを押してその値を描画しよう!
レイアウトが完成したので次は機能を作っていきます。
複雑な部分が多いので頑張っていきましょうまずは、数字ボタンが押されたらその数字が描画されるところまでやっていきましょう。
①「ボタンが押されたら値(= 押したボタンの数字)が送信される」という記述を作る
②送信された値を受け取る
③その値を描画する
という流れで作っていきます。
①「ボタンが押されたら値が送信される」という記述を作る
早速作っていきましょう。ボタンを作っているコードに追加していきます。
App.js<View style={styles.buttonArea}> <View style={{flexDirection: 'row'}}> {ZeroToFour.map(i => ( <TouchableOpacity style={styles.topbutton,styles.buttonNum} key={i} onPress={() => this.onNum({i})}> <Text style={styles.buttonText}> {i} </Text> </TouchableOpacity> ))} </View> <View style={{ flexDirection: 'row'}}> {FiveToNine.map(i => ( <TouchableOpacity style={styles.topbutton, styles.buttonNum} key={i} onPress={() => this.onNum({i})} > <Text style={styles.buttonText}> {i} </Text> </TouchableOpacity> ))} </View>onPress={() => this.onNum({i})}という記述を追加しています。
onPressはjsやJQueryの「click関数」とほぼ同じ意味です。
{i}には繰り返し処理中のindexが入るので、0〜9の値が入りますよね。
これを利用して押されたボタンの数字を取得して送信しているという仕組みです。②送信された値を受け取る
値の送信は完了したので、その値を処理して受け取る作業をしていきます。
①に追記したonNumは未定義のものなので、定義してあげましょう。
App.js//import 略 export default class App extends Component { //ここから constructor(props){ super(props); this.state = { leftNum: 0 } } onNum(input){ console.log(input); //←デバック用 this.setState({ leftNum: input["i"] }) } //ここまで //render(){ //return(onPressイベントから送信された値は、onNumイベントで受け取っています。引数(input)に値が送られてきています。
console.log(input)でどんなものが送られてきたか確認してみましょう!
「7」のボタンを押すと、こんなデータが送信されていることが分かりました。
7のボタン//inputの中身 Object { "i": 7, }オブジェクトとして送信されていて、"i"がkey、7がvalueですね。
では、ここから「7」を取得したい場合は以下のようにすれば良いですよね。
input["i"] //valueの7が取れるそして、最後に取得した値を保存します。
保存this.setState({ leftNum: input["i"] })この記述を行うことでstateにあるlaftNumにinput["i"]の値を保存しています。
setStateはstateを変化させたい時に使用するもので「stateにsetする」的なイメージを持ってくれたらOKです。
こうすることで、
this.state = { leftNum: 0 }0だったleftNumが
this.state = { leftNum: 7 }7に変化します!
③保存した値を描画する
ラストは保存した値を描画する作業です。これは秒です、2秒です。
先ほどまで、「0 ? 0 = 0」としていたところを少し変更します!
1番左の0の部分を{this.state.leftNum}にするだけです。
これで現在のleftNumの値を描画してくれるというわけです。(すごい!)
App.js//略 render() { return ( <View> <View style={styles.countArea}> <Text style={styles.value}>{this.state.leftNum} ? 0 = 0</Text> </View>9. 2桁以上の入力をできるようにしよう!
ここまででようやく押したボタンの数値が適応されるようになりました。でも、現状だと数字ボタンを押しても値が変更されるだけで1桁しか表示されません。
1桁しか計算できない電卓なんで全然かっこよくないですよね。むしろダサいです。
しかし、僕は2桁以上の入力を出来るようにするやり方に悩みに悩みました、何日も悩みました。1度考えることを辞めた僕はなぜか閃きました。
「1回文字型に変換して足して、数値型に戻せば良いんだ!!!!」
(僕はこのやり方しか思いつかなかったので他にもあれば教えていただきたいです。)
数値型のまま、既存のstateの値と入力された値を足すと普通に足し算されてしまいます。
悪い例//デフォルトでleftNumに2、input["i"]に4があるとする this.state.leftNum + input["i"] 2 + 4 = 6になってしまう成功例//デフォルトでleftNumに2、input["i"]に4があるとする String(this.state.leftNum) + String(input["i"]) (文字列の)"2" + (文字列の)"4" = "24"既存の値と入力された値の両者を文字列に変換して、+してあげると文字列同士の足し算が行われるのでこれを利用しました。
これを使って記述を変更しましょう!
App.jsonNum(input){ //console.log(input); //this.setState({ leftNum: input["i"] }) const num = Number(String(this.state.leftNum) + String(input["i"])) //文字列同士の足し算を行い、最後に数値型に変換 this.setState({ leftNum: num }) //leftNumに計算した値をset! }だんだん完成に近付いてきました。現在70%くらいの完成度です。ラストスパートです!
10.四則演算の選択を可能に!
左側の数値の入力は可能になったので、計算方法の選択が出来るようにしましょう。
数字ボタンの時とやることは同じなのでサクッといきましょう!
App.jsexport default class App extends Component { constructor(props){ super(props); this.state = { leftNum: 0, mark: "?", } } //~~~略 render() { return ( <View> <View style={styles.countArea}> <Text style={styles.value}>{this.state.leftNum} {this.state.mark} 0 = 0</Text> </View> //~~~略 //~~~略 <View style={{ flexDirection: 'row'}}> //以下4つにonPressを追記 <TouchableOpacity style={[styles.button, styles.buttonUpText]} onPress={() => this.onMark("plus")}> <Text style={styles.buttonText}>+</Text> </TouchableOpacity> <TouchableOpacity style={[styles.button, styles.buttonMinus]} onPress={() => this.onMark("minus")}> <Text style={styles.buttonText}>-</Text> </TouchableOpacity> <TouchableOpacity style={[styles.button, styles.buttonTimes]} onPress={() => this.onMark("times")}> <Text style={styles.buttonText}>x</Text> </TouchableOpacity> <TouchableOpacity style={[styles.button, styles.buttonDivide]} onPress={() => this.onMark("divide")}> <Text style={styles.buttonText}>÷</Text> </TouchableOpacity>やっていることは3つです。
①stateに[+ ー ➗X]を表すための「mark」を追加
②常に今のmarkの値を描画するために{ this.state.mark }を追加
③四則演算のボタンにonPressイベントを追加
この3つは数字ボタンと流れは同じですよね。
この状態でボタンを押してもエラーが出るので、入力したボタンの値を受け取って正常に描画させましょう。
入力してきた値が"plus"ならmarkを「+」に変更というようなイメージです
たくさん条件分岐させていきます。App.jsonMark(input){ if (input == "plus"){ this.setState({ mark: "+" }) } else if (input == "minus") { this.setState({ mark: "-" }) } else if (input == "times") { this.setState({ mark: "x"} ) } else if (input == "divide") { this.setState({ mark: "➗"}) } } }(↑の➗マークが見えづらくなってます。)
11.右側の数字を入力可能に使用!
現状は左の数値と四則演算のマークの入力は可能になっています。
次は右側の数値の入力ができるようにしましょう!ここも今までと同じ流れで完了です。
App.jsexport default class App extends Component { constructor(props){ super(props); this.state = { leftNum: 0, mark: "?", rightNum: 0, //←追記 } } onNum(input){ //以下追記 if (this.state.mark == "?"){ let num = Number(String(this.state.leftNum) + String(input["i"])) this.setState({ leftNum: num }) } else { let num = Number(String(this.state.rightNum) + String(input["i"])) this.setState({ rightNum: num }) } } //~~~~~略 render() { return ( <View> <View style={styles.countArea}> //以下追記 <Text style={styles.value}>{this.state.leftNum} {this.state.mark} {this.state.rightNum} = 0</Text> </View>ここまでのコードはこちらです。困ったら確認してください
12. 「=」を押したら計算を実行する!
ようやく計算式の入力が出来るようになりました。
「=」が押されたら四則演算のマークに応じて計算が行われるようにしましょう。
まずは「=」ボタンの所に追記しましょう。
App.js<TouchableOpacity style={[styles.button, styles.buttonEqual]} onPress={() => this.onEqual()} > <Text style={styles.buttonText}> = </Text> </TouchableOpacity>onEqualを定義して、markの値によって条件分岐を行います。
そして、結果の値をsetすれば完了です。
App.jsexport default class App extends Component { constructor(props){ super(props); this.state = { leftNum: 0, mark: "?", rightNum: 0, result: 0 //追記 } } : :略 : onEqual(){ if (this.state.mark == "+") { this.setState( { result: this.state.leftNum + this.state.rightNum }) } else if (this.state.mark == "-") { this.setState( { result: this.state.leftNum - this.state.rightNum}) } else if (this.state.mark == "x") { this.setState( { result: this.state.leftNum * this.state.rightNum}) } else if (this.state.mark == "➗") { this.setState( { result: this.state.leftNum / this.state.rightNum}) } } render() { return ( <View> <View style={styles.countArea}> <Text style={styles.value}>{this.state.leftNum} {this.state.mark} {this.state.rightNum} = {this.state.result}</Text> //追記 </View>「=」ボタンを押したらonEqualが発火します。
onEqualイベントは、markの値によって条件分岐しています。
例えばmarkが「➗」だったら、leftNumとrightNumを割り算して、resultにsetしています。
これでようやく計算ができるようになりました!!!!!
次でラストです!!!
13.リセットを反映させよう!
最後はリセットボタンです。iPhoneアプリの電卓なら「AC」にあたるところです。
リセットボタンを押したらstateにあるleftNumとrightNumを「0」に、markを「?」に変更します。
App.js: :略(onEqual)の下に記述 : onReset(){ this.setState({ leftNum: 0, mark: "?", rightNum:0, result:0 }) } : :略 リセットボタンに追記 <TouchableOpacity style={[styles.button, styles.buttonReset]} onPress={() => this.onReset()}> <Text style={[styles.buttonResetText]}>リセット</Text> </TouchableOpacity>これで値のリセットが完了しました!
まとめ
ようやく電卓が完成しました。お疲れ様でした。
Reactが初めての方はとても大変でしたよね。今回のアドベントカレンダーでアウトプットの重要性が身に染みてわかりました。
この記事を書くために調べて、実践して、改善してと非常に良い勉強になりました。とりあえず本当にお疲れ様でした!
完成版コードの確認をしたい方はこちらです。
- 投稿日:2019-11-30T22:27:47+09:00
冬空の中でReactの世界観をちょっとだけ広げたかった
それはただ美しかった。
コンポーネントという呪縛がある一方で、開発者の自由さを守りながら宣言的UIによる美しい構成が可能となった。
数千行に渡るDOMイベント管理の苦行から開放された。
見上げればそこにはReactがあった。
まるでオリオン座のように。
だがしかしBUT
仮想DOMというバーチャルな世界を実現するために、「Hello World」を表示するには現実的にWebpackやRollup等のバンドルが必要になった。
これを私は「コンポーネントの呪縛」と勝手に思っている。(悪気はない。)
初学者だった頃のjQuery使いの私にはしばらく難しかった。
とりあえずこれのようなminimalなスターターを書いて、そっと公式ドキュメントを閉じた。
数年の月日を経て
複雑なステート管理にはReduxが有名に、事実上のデファクトとなった。
やがてHooksが誕生した。
Hooksの登場により再度公式ドキュメントを開き、そしてそれをそっと閉じた。
どうしてもReduxのような管理方法が必要になった
JSerとしての経験値が上がるにつれて、jQuery思想の自分にはやがて限界が訪れた。
コンポーネントである以上基本は親が子を教育するのである。
したがってグローバルなstoreへどのコンポーネントからもアクセスできる機構が必要になったのだ。
dispatchめ。(悪気はない。)
おや...
結局グローバルstoreは親から子へ移譲しているだけではないか。
親から子へデータを渡している?
それは究極の発想ではview template engineに近しいのではなかろうか。
JSならejsやhandlebars、PHPあたりではbladeとかそういった、あの{{ data }}ではなかろうか。
あの古き良き、サーバーから受け取ったデータをビューへ渡す方法ではなかろうか。
Reactを view template engine にできないだろうか
調べたらあった。
https://github.com/reactjs/express-react-views
サーバーからデータを渡す:
// views/index.jsxをレンダリング res.render('index', { message: 'Hello World' });すると
views/index.jsx
のpropsにデータが伝搬される:var React = require('react'); function IndexPage(props) { return <div>{props.message}</div>; } module.exports = IndexPage;素晴らしいではないか。
と思ったら
It renders static markup and does not support mounting those views on the client.
静的マークアップをレンダリングするだけでReactはマウントされないだと。。。
Reactにする意味はあるのか?(ビューの数千行が数百行くらいにはなるかもしれないが。。。)
JSのイベントはどうする?
CSS-in-JSはどうなる?
どうしてもReactをマウントしたかった
https://github.com/saltyshiomix/react-ssr
$ npm install --save @react-ssr/expresspackage.json{ "scripts": { "start": "node server.js" } }server.jsconst express = require('express'); const register = require('@react-ssr/express/register'); const app = express(); (async () => { // Reactビューエンジンとして`.jsx`を登録 await register(app); app.get('/', (req, res) => { const message = 'Hello World!'; res.render('index', { message }); }); app.listen(3000, () => { console.log('> Ready on http://localhost:3000'); }); })();views/index.jsxexport default function Index({ message }) { return <p>{message}</p>; }そう、Reactをそのまま利用できるビューエンジンにしたかった
views/index.jsximport React from 'react'; const IndexPage = (props) => { const [message, setMessage] = React.useState('waiting...'); const onClick = () => setMessage('This is a react-ssr!'); return ( <React.Fragment> <button onClick={onClick}>Click Me</button> <p>Message from state: {message}</p> </React.Fragment> ); }; export default IndexPage;↓↓↓
想像してみてほしい
server.jsconst posts = [ { id: 1, body: 'This is a first post.' }, { id: 2, body: 'This is a second post.' }, { id: 3, body: 'This is a last post.' }, ]; app.get('/', (req, res) => { res.render('index', { posts }); }); app.get('/posts/:postId', (req, res) => { const { postId } = req.params; const post = findById(postId); res.render('post', { post }); });この古き良きデータ流しを、
views/index.jsximport React from 'react'; const IndexPage = ({ posts }) => { return ( <React.Fragment> {posts.map((post, index) => { return ( <p key={index}> <a href={'/posts/' + post.id}>{post.body}</a> </p> ); })} </React.Fragment> ); }; export default IndexPage;↓↓↓
http://localhost:3000
views/post.jsximport React from 'react'; const PostPage = ({ post }) => { return ( <React.Fragment> <p>{post.body}</p> </React.Fragment> ); }; export default PostPage;↓↓↓
http://localhost:3000/posts/1
Reactで扱える喜びを...!!!
NEXT.jsとの比較
データの非同期取得
クライアント側のデータ非同期取得は、どちらも
React.useEffect()
で非同期関数を処理する必要がある。NEXT.jsの場合
pages/index.jsximport React, { useState, useEffect } from 'react'; const HomePage = () => { const [posts, setPosts] = useState([]); useEffect(() => { // componentDidMount like fn() { const res = await fetch('http://localhost:4000/api/posts'); const posts = await res.json(); setPosts(posts); } fn(); return () => { // componentWillUnmount like }; }, []); return ( <React.Fragment> {posts.map((post, index) => { return ( <p key={index}> <a href={'/posts/' + post.id}>{post.body}</a> </p> ); })} </React.Fragment> ); }; export default HomePage;しかしこれではSSRされないのでSEO対策にならない。
仕方なく以下のようなNEXT.js wayに従って書き直す。
pages/index.jsximport React from 'react'; const HomePage = ({ posts }) => { return ( <React.Fragment> {posts.map((post, index) => { return ( <p key={index}> <a href={'/posts/' + post.id}>{post.body}</a> </p> ); })} </React.Fragment> ); }; HomePage.getInitialProps = async () => { const res = await fetch('http://localhost:4000/api/posts'); const posts = await res.json(); return { posts }; }; export default HomePage;だいぶすっきりしたものの、非同期データをSSRさせたい場合、この
getInitialProps
という魔法の呪文を唱える必要がある。react-ssrの場合
すでに紹介したように、view template engineとして機能するため、APIサーバーから取得したデータをそのままビューへ渡せば良い。
そもそも同一サーバーならAPIサーバーを建てる必要もなく、直接DBから取得したデータをビューへ渡せばpropsから取得データへアクセスできる。
server.js(async () => { const posts = await fetchFromDbOrAPIServer(); app.get('/posts', (req, res) => { res.render('posts', { posts }); }); })();views/index.jsximport React from 'react'; const HomePage = ({ posts }) => { return ( <React.Fragment> {posts.map((post, index) => { return ( <p key={index}> <a href={'/posts/' + post.id}>{post.body}</a> </p> ); })} </React.Fragment> ); }; export default HomePage;ユーザー認証
NEXT.jsの場合
iaincollins/next-authというライブラリがあるが、これもクライアント側では
getInitialProps
を利用する。しかしサーバー側の使い方が、NEXT.js wayなサーバーの書き方をする必要があったり、そもそもドキュメントが充分とは思えないのであまり使う気にはなれなかった。
server.jsconst next = require('next') const nextAuth = require('next-auth') const nextAuthConfig = require('./next-auth.config') require('dotenv').load() const nextApp = next({ dir: '.', dev: (process.env.NODE_ENV === 'development') }) nextApp.prepare() .then(async () => { const nextAuthOptions = await nextAuthConfig() const nextAuthApp = await nextAuth(nextApp, nextAuthOptions) console.log(`Ready on http://localhost:${process.env.PORT || 3000}`) }) .catch(err => { console.log('An error occurred, unable to start the server') console.log(err) })↑を読んでこれから何が起こるのかわかるエスパーはいるのだろうか。
react-ssrの場合
react-ssrはExpressのview template engineとして振る舞うので、古き良きExpress wayなユーザー認証を利用できる。
例えばメールアドレス・パスワードによる認証であればpassportとpassport-localを利用する。
ソースコードは長くなるため割愛するが、その分サーバー側で柔軟な調整ができる。
- ユーザーデータのシリアライズ・デシリアライズ方法の調整
- sessionの保存方法の調整(たとえばDBにsession情報を保存する等)
- リダイレクトURLの柔軟な調整(条件分岐等)
サーバー側のバグはクリティカルであることが多い。
「なんでも屋」すぎるライブラリには頼らず、小さくて優秀なライブラリを複数利用しつつ細かい調整は自前で実装するのがベストプラクティスだと筆者は考える。react-ssrの実装サンプル
さいごに
最終的にreact-ssrの宣伝っぽくなってしまってすみません。
ですが古き良きビューエンジンの形でReactを利用したいと思ったことはありませんか?
小規模開発でもわざわざAPIサーバーとクライアントを分けなければならない煩雑さを感じたことはありませんか?
そういった疑問を感じたことがある方にとって、少しでも可能性が広がる記事になれば幸いです。
筆者の稚拙な技術記事であるため、間違い等はコメントにてご指摘ください。
ここまで読んでくださりありがとうございました。
ちなみに私はベテルギウスが好きです。(一番高いところにあるから。)
- 投稿日:2019-11-30T21:12:36+09:00
Electron && React のconsole error対応メモ
Electron
Electron Security Warning
Node.js Integration with Remote Content
Electron Security Warning (Node.js Integration with Remote Content) This renderer process has Node.js integration enabled and attempted to load remote content from 'http://localhost:3000/'. This exposes users of this app to severe security risks. For more information and help, consult https://electronjs.org/docs/tutorial/security. This warning will not show up once the app is packaged.electron上で、http経由のコンテンツを読み込んだ時に発生。
デバック中は、local上でReactを動かしているので発生する。
パッケージ化すれば発生しないエラー。process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = '1';を、
electron.js
にて追記すれば表示されなくなる。[参考]
- Security, Native Capabilities, and Your Responsibility | Electron
- Electron Security Warning (Node.js Integration with Remote Content) · Issue #213 · maximegris/angular-electronInsecure Resources
/.../node_modules/electron/dist/Electron.app/Contents/Resources/electron.asar/renderer/security-warnings.js:95 Electron Security Warning (Insecure Resources) This renderer process loads resources using insecure protocols.This exposes users of this app to unnecessary security risks. Consider loading the following resources over HTTPS or FTPS. - http://localhost:3000/static/js/bundle.js - http://localhost:3000/static/js/0.chunk.js - http://localhost:3000/static/js/main.chunk.js For more information and help, consult https://electronjs.org/docs/tutorial/security. This warning will not show up once the app is packaged.同上
Insecure Content-Security-Policy
Electron Security Warning (Insecure Content-Security-Policy) This renderer process has either no Content Security Policy set or a policy with "unsafe-eval" enabled. This exposes users of this app to unnecessary security risks. For more information and help, consult https://electronjs.org/docs/tutorial/security. This warning will not show up once the app is packaged.同上
React
semantic ui
table
Warning: validateDOMNesting(...): <th> cannot appear as a child of <thead>.<Table.Header> <Table.Row> <- 抜けてたw <Table.HeaderCell>[参考]
- Table - Semantic UI React
- 投稿日:2019-11-30T20:47:21+09:00
【React】コンポーネントの外がクリックされたら閉じるポップアップメニューを実装しよう
ポップアップのカラーピッカーを React で作っているときに、いくつかの問題に直面したのでまとめておこうと思います。
作ったものは下のGIFのようなカラーピッカーです。
この記事では、カラーピッカー機能を取り除いた、下のポップアップメニューの作り方を紹介します。
完成品の CodePen を先に置いておくので、コピペしたい人はこちらをお使いください。See the Pen React Popup Menu by ジフォ (@G4RDS) on CodePen.
ポップアップメニューの機能
今回作るポップアップメニューには以下のような機能があります。
- ボタンがクリックされるまでポップアップメニューを表示しない。
- ポップアップメニュー範囲外をクリックすると、ポップアップメニューを閉じる。
- ポップアップメニューの内側をクリックされても、ポップアップメニューは閉じない。
- ポップアップメニューの内側に、ポップアップメニューを閉じるボタンを用意する。
ポップアップメニューを作ろう
Functional Component で作っていきます。
Hooks を使うので、基礎を理解している必要があります。まずはベースを作ろう
メニューの表示を切り替えるボタンと、メニューを持つコンポーネントを作ります。
// PopupMenu.js import './PopupMenu.scss' const PopupMenu = () => { return ( <div className="popup-menu-container"> <button> Toggle Menu </button> <div className="popoup-menu"> <div>menu</div> <button> Close Menu </button> </div> </div> ) }適当なスタイルを当てておきます。
.popup-menu
がレイアウトに影響しないよう、position: absolute
を適用します。// PopupMenu.scss .popup-menu-container { position: relative; } .popup-menu { position: absolute; z-index: 2; width: 10rem; margin-top: 0.5rem; padding: 1rem; background: #fff; border-radius: 0.5rem; }メニューの表示を切り替えよう
React Hook の
useState
を使って、メニューを表示したり非表示にしたりできるようにします。// PopupMenu.js import React, { useState } from 'react' const PopupMenu = () => { const [isShown, setIsShown] = useState(false) const handleToggleButtonClick = () => { setIsShown(true) } const handleCloseButtonClick = () => { setIsShown(false) } return ( <div className="popup-menu-container"> <button onClick={handleToggleButtonClick}> Toggle Menu </button> <div className={`popup-menu ${isShown ? 'shown' : ''}`}> <div>menu</div> <button onClick={handleCloseButtonClick}> Close Menu </button> </div> </div> ) }// PopupMenu.scss .popup-menu { transform: scale(0); transform-origin: top left; transition: transform 0.2s; &.shown { transform: scale(1); } }ポップアップメニュー範囲外がクリックされたら閉じるようにしよう
click イベントは通常、クリックされた場所に一番近い要素から遠い要素へ順番にイベントリスナーを実行していきます。
つまり、HTML DOM の一番外側であるbody
に設定されたイベントリスナーは、そこに伝播するまでの要素に設定されたイベントリスナーが伝播を止めない限り、どこがクリックされても必ず実行されます。とりあえず、どこをクリックしても閉じるようにしよう
この機能を実装するには、
document.body
にポップアップメニューを閉じるためのイベントリスナーを設定すれば良さそうです。const handleToggleButtonClick = () => { setIsShown(true) document.body.addEventListener('click', e => { setIsShown(false) document.body.removeEventListener('click', documentClickHandler) }) }しかし、このコードには問題があります。
React DOM の onClick などのイベントリスナーは、HTML DOM のイベントリスナーよりも後で呼ばれることが確定しているため、トグルボタンをクリックした時に
isShown
がdocument.body
に設定したイベントリスナーによってfalse
になった後、トグルボタンのイベントリスナーがtrue
にしてしまうのです。
また、isShown
をtrue
にした後、document.body
にイベントリスナーを再設定してしまいます。これを解決するには、範囲外イベントリスナーを React DOM イベントリスナーよりも後に実行される場所に設定する必要があります。
それは、document
です。
React DOM はdocument
にクリックイベントリスナーを設定することで、どの React DOM 要素がクリックされたのかを判断しています。
同じ要素に複数のリスナーが設定されている場合、先に設定されたリスナーから順番に実行されます。
つまり、React DOM と同じdocument
にイベントリスナーを設定することで、React DOM のイベントリスナーが処理された後に実行されるようにできるのです。document.addEventListener('click', e => { setIsShown(false) document.removeEventListener('click', documentClickHandler) })ポップアップメニューの範囲がクリックされたのなら閉じないようにしよう
現状だとメニューを利用することができないので、ポップアップメニューの範囲内かどうかを判定し、範囲内なら閉じる処理を行わないようにしましょう。
これには、二つの実装方法が考えられます。
- ポップアップメニュー
.popup-menu
に、これ以上イベントが伝播しないようにするイベントリスナーを設定する。document
に設定したイベントリスナーで、クリック対象の要素がポップアップメニュー要素もしくはその子要素であるかどうかを判定する。簡単なのは前者ですが、React DOM のイベントリスナーで
e.stopPropagation()
を実行しても、HTML DOM のイベント伝播は止まらないため、機能しません。
面倒ですが、後者の方法で実装します。ポップアップメニュー要素にアクセスできるようにする
まず、ポップアップメニュー要素を取得するため、
useRef
フックを使います。const popupRef = useRef() // ... <div className={`popup-menu ${isShown ? 'shown' : ''}`} ref={popupRef} > ... </div>これで、
popupRef.current
でポップアップメニュー要素にアクセスできるようになりました。クリックされた要素がポップアップメニュー要素以下であるかを判定する
popupRef.current
で取得できるElement
のスーパークラスであるNode
には、引数に指定したノードがこのノードの子孫ノード(自分自身を含む)であるかどうかを判定する Node.contains メソッドがあります。
これを使うことで、クリックされた要素がポップアップメニュー要素以下であるかどうかを判定できます。const documentClickHandler = e => { if (popupRef.current.contains(e.target)) return setIsShown(false) document.body.removeEventListener('click', documentClickHandler) }ポップアップメニュー内に閉じるボタンを用意しよう
一番初めに紹介したカラーピッカーでは、カラーが選択されたらポップアップメニューを閉じるようにしています。
この機能を実装してみましょう。閉じるときに、
document
に設定したイベントリスナーを削除するのを忘れないようにしましょう!const documentClickHandler = e => { if (popupRef.current.contains(e.target)) return setIsShown(false) removeDocumentClickHandler() } const removeDocumentClickHandler = () => { document.removeEventListener('click', documentClickHandler) } const handleCloseButtonClick = () => { setIsShown(false) removeDocumentClickHandler() }しかし、このコードは正しく動作しません。
removeEventListner
は第二引数が同じ参照を指すイベントリスナーを除去するという働きをする関数ですが、documentClickHandler
がReact がレンダリングするたびに新しく生成されてしまうため、イベントリスナーを除去できずに残り続けてしまいます。
これを解決するために、useRef を使ったdocumentClickHandler
インスタンス変数を用意し、useEffect
で最初のレンダリング後に関数をセットするようにします。const PopupMenu = () => { // ... const documentClickHandler = useRef() useEffect(() => { documentClickHandler.current = e => { // ... } }, []) const removeDocumentClickHandler = () => { document.removeEventListener('click', documentClickHandler.current) } const handleToggleButtonClick = () => { // ... document.addEventListener('click', documentClickHandler.current) }正しく動作するようになりましたね!
? 完成! お疲れ様でした
React DOM は HTML DOM の要素にセットしているイベントリスナーはダミーで、実際は
document
にセットしたイベントリスナーが処理していたとは、まったく気づきませんでした。今回はメニューの内部も含めて一つのコンポーネントにしましたが、実際に運用する React プロジェクトでは機能だけを提供するコンポーネントを用意して、再利用できるようにすると良いのかなと思います。
最後に宣伝をさせてください!
今年の春ごろに、毎日やりたいことを習慣づけるアプリ「コツコツ忍者」を作りました!
毎日開発する!とか、毎週土日はランニングする!とかの目標を作り、記録することでモチベーションを保つアプリです!
Nuxt.js で作ったもので、何を使っているのかなどを書いた記事もありますので、ぜひそちらもご覧ください。また、英語リスニング学習アプリを Flutter で開発中です。
ある程度までできたら、事前登録サイトを用意しようと思っていますので、Twitterをフォローしていただけると幸いです!最後までご覧いただきありがとうございました!
参考文献
- DOMイベントのキャプチャ/バブリングを整理する 〜 JSおくのほそ道 #017
https://qiita.com/hosomichi/items/49500fea5fdf43f59c58- Event propagation with React bubbling out of order
https://stackoverflow.com/questions/45212840/event-propagation-with-react-bubbling-out-of-order
- 投稿日:2019-11-30T15:02:31+09:00
よく使う画面遷移のCSS
iosにありがちなモーダルの遷移とプッシュの遷移のサンプル
デモ
https://nakadoribooks.github.io/modal_and_push/使うもの
create-react-app
styled-componentsコード
- styleの定義
- Reactの実装
スタイル
アニメーションはだいたいこのくらい
duration: 600
ease: 'cubic-bezier(.17,.71,0,1)'
styles.jsimport React from 'react'; import styled from 'styled-components'; const sizes = { headerHeight: 72 } // だいたいこのくらい const animation = { page: { duration: 600, ease: 'cubic-bezier(.17,.71,0,1)' }, } animation.page.transition = `transform ${animation.page.duration}ms ${animation.page.ease}, opacity ${animation.page.duration}ms ${animation.page.ease}` const colors = { primary: "#3880ff", secondary: "#0cd1e8", border: "#eeeeee", white: "#ffffff", black: "#000000", } const Header = styled.div` width: 100%; padding:0px 20px; position:absolute; height: ${sizes.headerHeight}px; border-bottom:1px solid ${colors.border}; background-color:${colors.white}; padding-top:1px; justify-content:space-between; display:flex; align-items:center; box-shadow: 0 2px 2px rgba(200,200,200,.1); h1{ font-size:20px; } ` const HeaderButton = styled.a` display:inline-block; height:40px; font-size:14px; line-height:38px; border: 1px solid ${colors.primary}; border-radius:5px; color:${colors.primary}; padding:0px 20px; min-width: 100px; text-align:center; cursor:pointer; :hover{ background-color:${colors.primary}; color:${colors.white}; } ` const HeaderSpace = styled.div` width:100px; ` const RootView = styled.div`` const PageWrapper = styled.div` position:fixed; top:0px;left:0px;right:0px;bottom:0px; ` // 後ろにある半透明の黒 const BehindLayer = styled.div` background-color:${colors.black}; opacity: 0; position:absolute; top:0px;left:0px;right:0px;bottom:0px; transition: opacity ${animation.page.duration}ms ease; ${(props)=>props.showed && ` opacity:0.3; `} ` const PageContainer = styled.div` height:100%; position:relative; top:0px; transition: ${animation.page.transition}; ` const RootContainer = styled(PageContainer)` /* 上にモーダルきた時に少しちっさくなるやつ */ ${props=>props.modaling && ` transform: scale(0.99) `} /* プッシュされた時につこし左に行くやつ */ ${props=>props.pushing && ` transform: translateX(-10vh) `} ` const ModalContainer = styled(PageContainer)` transform: translateY(100vh); ${props=>props.showed && ` transform: translateY(0vh); `} ` const PushContainer = styled(PageContainer)` transform: translateX(50vh); opacity: 0; ${props=>props.showed && ` opacity: 1.0; transform: translateX(0vh); `} ` const PageContents = styled.div` height:calc(100vh - ${sizes.headerHeight}px); position:relative; top:${sizes.headerHeight}px; overflow-y:scroll; background-color:${colors.white}; ` const LinkButton = styled.p` text-align:center; a{ display:inline-block; padding:10px 20px; border-radius: 10px; border: 1px solid ${colors.border}; cursor:pointer; transition: background-color 300ms ease, transform 300ms ease; :hover{ opacity: 0.7; } } /* フォーカス残るよう */ ${props=>props.focus && ` a{ background-color:${colors.border}; transofrm:scale(0.99); } `} ` const CenterContainer = styled.div` width:100%; height:100%; display: flex; justify-content: center; align-items: center; ` export { animation, Header, HeaderButton, HeaderSpace, RootView, PageWrapper, BehindLayer, RootContainer, ModalContainer, PushContainer , PageContents, LinkButton, CenterContainer }React 実装
遷移先は didMountで少し遅らせてアニメーション表示
setTimeout(()=>{ this.setState({showed: true}) }, 10)遷移元は アニメーション中のフラグとmodal/pushを表示する用のフラグ二つづつもつ。
const {modaling, pushing, showingModal, showingPush} = this.state
App.jsimport React from 'react'; import { animation, Header, HeaderButton, HeaderSpace, RootView, PageWrapper, BehindLayer, RootContainer, ModalContainer, PushContainer , PageContents, LinkButton, CenterContainer } from './styles' class ModalPage extends React.Component{ state = { showed: false } componentDidMount(){ // アニメーションで表示 setTimeout(()=>{ this.setState({showed: true}) }, 10) } render = () =>{ const {showed} = this.state return <PageWrapper> <BehindLayer showed={showed} /> <ModalContainer showed={showed}> <Header> <HeaderButton onClick={this.handleClickClose}>閉じる</HeaderButton> <h1>モーダル</h1> <HeaderSpace /> </Header> <PageContents> <CenterContainer> <h1>モーダル</h1> </CenterContainer> </PageContents> </ModalContainer> </PageWrapper> } handleClickClose = (event) => { this.setState({showed: false}) this.props.onClose() } } class PushPage extends React.Component{ state = { showed: false } componentDidMount(){ // アニメーションで表示 setTimeout(()=>{ this.setState({showed: true}) }, 10) } render = () =>{ const {showed} = this.state return <PageWrapper> <BehindLayer showed={showed} /> <PushContainer showed={showed}> <Header> <HeaderButton onClick={this.handleClickClose}>戻る</HeaderButton> <h1>プッシュ</h1> <HeaderSpace /> </Header> <PageContents> <CenterContainer> <h1>プッシュ</h1> </CenterContainer> </PageContents> </PushContainer> </PageWrapper> } handleClickClose = (event) => { this.setState({showed: false}) this.props.onBack() } } class App extends React.Component{ state = { pushing: false, modaling: false, showingModal: false, showingPush: false } render = () => { const {modaling, pushing, showingModal, showingPush} = this.state return <RootView> <PageWrapper> <RootContainer modaling={modaling} pushing={pushing}> <Header><div /><h1>ページ遷移サンプル</h1><div /></Header> <PageContents> <CenterContainer> <div> {/* 戻ってきた時に分かりやすいようにフォーカスを残しておく */} <LinkButton focus={showingModal}><a onClick={this.handleClickModal}>モーダル</a></LinkButton> <LinkButton focus={showingPush}><a onClick={this.handleClickPush}>プッシュ</a></LinkButton> </div> </CenterContainer> </PageContents> </RootContainer> </PageWrapper> {showingModal && <ModalPage onClose={this.handleCloseModal} />} {showingPush && <PushPage onBack={this.handleBackPush} />} </RootView> } handleClickModal = (event) => { this.setState({modaling: true, showingModal:true}) } handleClickPush = (event) => { this.setState({pushing: true, showingPush: true}) } handleCloseModal = (event) => { this.setState({ modaling: false }) setTimeout(()=>{ this.setState({showingModal: false}) }, animation.page.duration) } handleBackPush = (event) => { this.setState({pushing: false}) setTimeout(()=>{ this.setState({showingPush: false}) }, animation.page.duration) } } export default App;
- 投稿日:2019-11-30T02:53:39+09:00
Redux ExampleのTodo ListをはじめからていねいにをもういちどTypescriptとImmerで
概要
immerがよいと聞いたので、試してみようと思った。
以前、Redux ExampleのTodo Listをはじめからていねいに(1)を
参考にした記事でやろうと思ったが、時間が経っていて古いので改めて環境作成から行った。
ハマった。環境
バージョン
- Windows 10 Home
- Vagrant 2.2.6
- virtualbox 6.0.14
- Ubuntu 18.04 LTS (Bionic Beaver)
- Docker version 19.03.2, build 6a30dfc
- docker-compose version 1.24.1, build 4667896b
ライブラリのバージョン
{ "name": "app", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "immer": "^5.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", "react-redux": "^7.1.3", "redux": "^4.0.4" }, "devDependencies": { "@types/jest": "^24.0.23", "@types/node": "^12.12.14", "@types/react": "^16.9.13", "@types/react-dom": "^16.9.4", "@types/react-redux": "^7.1.5", "@types/redux": "^3.6.0", "react-scripts": "^3.2.0", "typescript": "^3.7.2" } }ディレクトリ構成(Hello world 時)
app - tsconfig.json # ビルドツールで利用するTypescript設定 - package.json # ライブラリを記載・また、起動スクリプトなどを記載 - yarn.lock # npmでインストールするライブラリのバージョン固定用 - src - index.tsx # エントリーポイント - components # Reactコンポーネント - App.tsx - react-app-env.d.ts # TSコンパイラへのファイル間の依存関係の宣言 - public # 開発サーバのベースとなるフォルダ - index.html # 開発サーバのホーム。 + dist # ビルドされたファイルの格納先 - docker # dockerファイルをまとめたフォルダ - docker-compose.yml # コンテナ起動時設定ファイル - node - Dockerfile # コンテナ作成ファイル - bin # docker-composeの操作をシェル化 - start.sh # 開発サーバの起動 - build.sh # dist内にjsファイルをビルドソースのもとについて
以下で構築したフォルダ構成を参考に作成
npm install create-react-app npx create-react-app my-app --typescript設定ファイル
ie のサポートはしない予定なので設定ファイルを書換。(*)(**)
tsconfig.json{ "compilerOptions": { - "target": "es5", - "allowJs": true, - "skipLibCheck": true, - "esModuleInterop": true, + "target": "es6", + "allowJs": false, + "skipLibCheck": false, + "esModuleInterop": false, "allowSyntheticDefaultImports": true,docker 設定
docker/docker-compose.yml# lesson_buildtool_react_tsというコンテナ名で作成 my_react_ts: # Dockerfileビルド build: ./node # ディレクトリを共有する。 volumes: # ビルドするソースファイル - ../src:/app/src # ビルドファイルの出力先 - ../dist:/app/build # 開発用サーバのホームページに使用するhtml用ディレクトリ - ../public:/app/public # package.json上書き - ../package.json:/app/package.json # typescriptの設定ファイル - ../tsconfig.json:/app/tsconfig.json # ホストのポート8080をコンテナのポート3000にポートフォワーディング ports: - 8080:3000 # ホスト:コンテナでポート指定 environment: - CHOKIDAR_USEPOLLING=true # デフォルトの設定の場合、vagrantだとファイルの変更を検知できない。ホットリロードのためにpollingが必要。 # docker-compose run を行ったときにコンテナ上で下のコマンドを行う command: [yarn, start]開発サーバ用 HTML
public/index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>React App</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> </body> </html>1. Hello World
create-react-app で作成された状態のソースは余分なものも含まれているので、まずはシンプルにする。
整理前のソースソース
src/index.tsximport React from "react"; import ReactDOM from "react-dom"; import App from "./components/App"; // reactのコンポーネントを#root以下に作成する ReactDOM.render(<App />, document.getElementById("root"));src/components/App.tsximport React from "react"; // React.FC は React.FunctionComponent の短縮形 // @types/reactとlib.dom.d.tsで型が衝突することがあるため、慣習としてReactの型はnamed import({FC} from 'react'みたいなやつ)を避ける const App: React.FC = () => { return <div> Hello World!!! </div>; }; export default App;実行
開発用サーバを起動。
./bin/start.sh
ブラウザでアクセスして確認。
2. actionCreator で発行した action を reducer に渡して store の state を更新する
参考*をもとに作成。
Flux Standard Action という考えかたがあるよう(*)なので、type(必須) と payload(オプション) の組にしてみる。サンプルから少し逸脱。Acitions
src/actions/index.ts// TypeScript3.4で導入された const assertion を利用することで各定数がstringではなく、その文字列の型として定義される const ADD_TODO = "ADD_TODO" as const; let nextTodoId = 0; // actionを発行する関数 export const addTodo = (text: string) => { // actionはtypeを持つオブジェクト // この場合、アクションタイプはADD_TODO // データはpayloadとなる。 return { type: ADD_TODO, payload: { id: nextTodoId++, text } }; }; // TypeScript2.8で導入されたReturnTypeで型をかえす export type AddTodoAction = ReturnType<typeof addTodo>;Reducers
immer を使ってみた(*)。
produce 関数で、普通(ミュータブル)に書いたものが、イミュータブルなオブジェクトとして返されることとなる(*)。src/reducers/index.tsimport produce, { Draft } from "immer"; import { AddTodoAction } from "../actions"; export class TodoState { constructor(public id: number, public text: string) {} } const todo = produce((draft: Draft<TodoState>, action: AddTodoAction) => { switch (action.type) { case "ADD_TODO": const { payload: { id, text } } = action; draft.id = id; draft.text = text; return draft; default: return draft; } }); export default todo;この状態だと、以下のエラーが出てハマった。
my_react_ts_1 | Failed to compile. my_react_ts_1 | my_react_ts_1 | /app/src/index.tsx my_react_ts_1 | TypeScript error in /app/src/index.tsx(7,27): my_react_ts_1 | No overload matches this call. my_react_ts_1 | Overload 1 of 2, '(reducer: Reducer<unknown, Action<any>>, enhancer?: StoreEnhancer<unknown, unknown> | undefined): Store<unknown, Action<any>>', gave the following error. my_react_ts_1 | Argument of type '<Base extends { readonly id: number; readonly text: string; }>(base: Base, action: { type: "ADD_TODO"; payload: { id: number; text: string; }; }) => Base' is not assignable to parameter of type 'Reducer<unknown, Action<any>>'. my_react_ts_1 | Types of parameters 'base' and 'state' are incompatible. my_react_ts_1 | Type 'unknown' is not assignable to type '{ readonly id: number; readonly text: string; }'. my_react_ts_1 | Overload 2 of 2, '(reducer: Reducer<unknown, Action<any>>, preloadedState?: DeepPartial<unknown> | undefined, enhancer?: StoreEnhancer<unknown, {}> | undefined): Store<...>', gave the following error. my_react_ts_1 | Argument of type '<Base extends { readonly id: number; readonly text: string; }>(base: Base, action: { type: "ADD_TODO"; payload: { id: number; text: string; }; }) => Base' is not assignable to parameter of type 'Reducer<unknown, Action<any>>'. TS2769 my_react_ts_1 | my_react_ts_1 | 5 | import todo from "./reducers"; my_react_ts_1 | 6 | import { addTodo } from "./actions"; my_react_ts_1 | > 7 | const store = createStore(todo);tsconfig の設定を
"strict": false,
にすると動く。型の問題だけっぽい。
Draft<TodoState>
が違っていた模様。以下のようにしたら動いた。src/reducers/index.tsimport produce from "immer"; import { AddTodoAction } from "../actions"; import { Reducer } from "redux"; export class TodoState { constructor(public id: number, public text: string) {} } export const initialState = () => new TodoState(0, ""); const todo: Reducer<TodoState, AddTodoAction> = produce((draft, action) => { switch (action.type) { // actionTypeがADD_TODOのとき、 // 新しいTodoStateを返す case "ADD_TODO": // es6の分割代入でpayloadからidとtextを取り出す const { payload: { id, text } } = action; draft.id = id; draft.text = text; return draft; // それ以外のときはstateを変化させない。何もしないproduceは、元の状態を返す。 // default: // return draft; } }); export default todo;src/index.tsximport React from "react"; import ReactDOM from "react-dom"; import App from "./components/App"; import { createStore } from "redux"; import todo, { initialState } from "./reducers"; import { addTodo } from "./actions"; // createStoreの引数が1つだと初期値がなくてエラーとなる const store = createStore(todo, initialState()); store.dispatch(addTodo("Hello World!")); console.log(store.getState()); ReactDOM.render(<App />, document.getElementById("root"));2.5 store, reducer の整理
reducer
- 名前が分かりにくいので todo から reducer にリファクタリング
- todoList を作るようの interface を定義
- todoList を作る用の reducer に変更
- 今後 reducer は増えていくので、集約が出来るように準備をしておく。
- store フォルダに格納
src/stores/todos/index.tsimport produce from "immer"; import { AddTodoAction } from "../../actions"; import { Reducer } from "redux"; interface Todo { id: number; text: string; } interface State { todos: Todo[]; } export function initialState(): State { return { todos: [] }; } export const reducer: Reducer<State, AddTodoAction> = produce( (draft, action) => { switch (action.type) { case "ADD_TODO": const { payload } = action; draft.todos.push(payload); return draft; } } );
- combineReducers を使って集約する。
- 今回は1つだけなのであまり意味はない。
src/store/reducer.tsimport { combineReducers } from "redux"; import * as Todos from "./todos"; export function initialState() { return { todos: Todos.initialState() }; } export const reducer = combineReducers({ todos: Todos.reducer });store
src/index.tsx にあった createStore を関数に切り出してフォルダを分ける。
src/store/index.tsimport { createStore, Store } from "redux"; import { initialState, reducer } from "./reducers"; export type StoreState = ReturnType<typeof initialState>; export type ReduxStoreInstance = Store<StoreState>; export function initStore(state = initialState()) { // createStoreの引数が1つだと初期値がなくてエラーとなる return createStore(reducer, state); }3. store で保持した state を View で表示する
components
src/components/Todo.tsximport React from "react"; const Todo: React.FC<{ text: string }> = ({ text }) => { return <li>{text}</li>; }; export default Todo;src/components/TodoList.tsximport React from "react"; import Todo from "./Todo"; import { State } from "../store/todos"; const TodoList: React.FC<State> = props => { return ( <ul> {props.todos.map(todo => ( <Todo key={todo.id} {...todo} /> ))} </ul> ); }; export default TodoList;container
src/container/VisibleTodoList.tsximport { connect } from "react-redux"; import TodoList from "../components/TodoList"; import { StoreState } from "../store"; const mapStateToProps = (store: StoreState) => { return { todos: store.todos.todos }; }; const VisibleTodoList = connect(mapStateToProps)(TodoList); export default VisibleTodoList;App
src/components/App.tsximport React from "react"; import VisibleTodoList from "../containers/VisibleTodoList"; const App: React.FC = () => { return ( <div> <VisibleTodoList /> </div> ); }; export default App;index
index.tsx
import React from "react"; import ReactDOM from "react-dom"; import { Provider } from "react-redux"; import App from "./components/App"; import { addTodo } from "./actions"; import { initStore } from "./store"; const store = initStore(); store.dispatch(addTodo("Hello World!")); ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById("root") );ここまでで、画面にリストが出ることを確認できる。
いつのまにか以下のエラーがでるようになっていた。。
Reducer "todos" returned undefined during initialization. If the state passed to the reducer is undefined, you must explicitly return the initial state. The initial state may not be undefined. If you don't want to set a value for this reducer, you can use null instead of undefined.以下のように reducer を直したらエラーは発生しなくなった。*
src/stores/todos/index.tsexport const reducer: Reducer<State, AddTodoAction> = produce( - (draft, action) => { + (draft = initialState(), action) => { switch (action.type) { case "ADD_TODO": const { payload } = action; draft.todos.push(payload); return draft; default: return draft; } } );4.フォームから todo を追加
containers/AddTodo.tsximport React from "react"; import { Dispatch } from "redux"; import { connect } from "react-redux"; import { addTodo } from "../actions"; const mapDispatchToProps = (dispatch: Dispatch) => ({ handleClick: (text: string) => dispatch(addTodo(text)) }); let AddTodo: React.FC<any> = ({ handleClick }) => { let input: HTMLInputElement; return ( <div> <input ref={node => { input = node!; // nodeがnullはありえないので、!でnullでないことを示す }} /> <button onClick={() => { handleClick(input.value); input.value = ""; }} > Add Todo </button> </div> ); }; AddTodo = connect(null, mapDispatchToProps)(AddTodo); export default AddTodo;src/comonents/App.tsximport React from "react"; import VisibleTodoList from "../containers/VisibleTodoList"; +import AddTodo from "../containers/AddTodo"; const App: React.FC = () => { return ( <div> + <AddTodo /> <VisibleTodoList /> </div> ); }; export default App;参考
【Typescript×React】tsconfig.json の設定項目を詳しく紹介
tsconfig 日本語訳(3.03)
[TypeScript] create-react-app で始めるだいたいストレスフリーな開発環境の構築 2
Redux 開発で絶対使うべき Redux DevTools Extension 解説
Redux Example の Todo List をはじめからていねいにを Typescript で(1)
Immerjs で Redux 周りをスッキリさせたい
create-react-app で作った雛形のコードが Service Worker で何をしているのか
React + TypeScript の ESLint ルールをカスタマイズしたり、Airbnb のやつを導入するぞ。
トリプルスラッシュ・ディレクティブ
関東最速で React+Redux+TypeScript なアプリの開発環境を作る
redux を typescript で使うならこれを使うしかない。(typescript-fsa がすごい)
typescript-fsa に頼らない React × Redux
TypeScript で Redux の Reducer 部分を型安全かつスッキリ書く
TypeScript 2.4+における Redux Action
Flux Standard Action(FSA)の説明
Redux の createStore()の処理を追う(Middleware 有りの場合)
React ビギナーズガイドを typescript で勉強し直してわかったこと ②【propTypes の必要性について】
React を TypeScript で書く 3: React 編
react-redux-typescript-guide
React を TypeScript で書ける環境で、Redux の Tutorial をしてみる
React (TypeScript): ベストプラクティス
エラー「Reducer returned undefined during initialization」(React/Redux)
Redux をソースコードから理解する その 1