- 投稿日:2019-12-22T23:40:24+09:00
Three.jsとIndexedDBでMMDモデルビューアーを作った
はじめに
Three.jsにはMMDモデルを読み込んでくれるMMDLoaderというツールがあり、これでMMDモデルを確認できるビューアーアプリを作りたいと思ったのですが、MMDモデルをサーバーにアップロードする形だと著作権的にグレーな気がしました。
そのとき、「ローカルで見るだけならわざわざサーバーに送る必要ないのでは?」ということに気づき、サーバーに送らなくて済むタイプのMMDモデルビューアを作ってみることにしました。
そのために一時的にブラウザからアクセス可能なローカルの領域を用意する必要があるのですが、今回はそれにIndexedDBを使いました。
(Githubのissueを調べたところ、IndexedDBについては何かしら議論したあとがあったのですが、この感じだとまだサポートは無いんだと思います)作ったもの
デモ
こちらのデモをご覧ください。
MMDモデル(pmd/pmx)のファイルを選択すると、そのMMDモデルが表示されるはずです。
※MMDモデルはてきとうに「MMDモデル 配布」とかでググって見つけてください。
※申し訳ありませんがまだテクスチャは貼れません...プログラム
https://github.com/one-color-low/MMDSaver
app/MMDSaver.js
というのがIndexedDBのあれこれを操作してるjsです。app/js/loaders/MMDLoader.js
はarraybufferを直接読み込めるように改造してあります。仕組み
MMDLoaderに限らず、Three.jsの3Dモデルローダーは第一引数に静的ファイルのパスを渡す仕様になっていると思います。MMDLoader内ではこのパスに基づいて xhrリクエストをサーバーに送信し、レスポンスとしてArrayBuffer形式のモデルファイルを受け取っています。 このことから、ローダーにパスではなく直接ArrayBufferを渡せばいいと思いました。
流れ
- フォームからバイナリ形式のファイルを取得
- ファイルを
readAsArrayBuffer
でArrayBufferに変換- IndexedDBに保存
- IndexedDBから取り出し
- 一度blobに変換しローダーに渡す ※なぜかArrayBufferのままではローダーに渡せなかったため
- ローダー内でblobに対し
readAsArrayBuffer
をかけArrayBufferに戻す- 本来xhr処理するはずだった場所でこのArrayBufferを受け取る
さいごに
初心者なので間違ったことを書いてしまっているかもしれません。もし何かあればコメントください!!
- 投稿日:2019-12-22T23:39:12+09:00
Javascript⑤
連想配列のループ処理
連想配列とはキーと値のペアで構成される配列です。
const array = { key: 'value', key:'value', }プロパティを指定するには2種類の方法があります。
//ドット記法 array.key //ブラケット記法 array['key']const obj = {} for(const i=0; i<5; i++){ obj[i] = i*100; } for(const key in obj){ if(obj.hasOwnProperty(key)){ const val = obj[key]; console.log(val); } //for in文でループ処理をしてプロパティを出力しています。動的変数
varでの変数宣言はやめましょう。
let:動的変数、最大代入可能
const:静的変数、再代入不可能関数内で宣言した変数は外部から参照できず、constは値の変更はできません。
textareaオブジェクト
textarea要素を扱うことが出来ます。
```
document.getElementById('wanko').value
//valueでプロパティを使用できます
document.getElementById('wanko').value = pomedocument.getElementById('wanko').value.row = '10'
const input_message = document.getElementById('message').value;
message = 'input_message';
document.getElemtntById('output_message').innerHTML = message;
```Javascriptの実行が遅い時
JSは外部ファイルに記述する
JSファイルの読み込みは
- 投稿日:2019-12-22T23:39:12+09:00
Javascriptレシピ⑤
Javascriptの簡単なメモです。
アプリ制作に応用できる基本レシピですので、参考にされたい方はどうぞ。連想配列のループ処理
連想配列とはキーと値のペアで構成される配列です。
const array = { key: 'value', key:'value', }プロパティを指定するには2種類の方法があります。
//ドット記法 array.key //ブラケット記法 array['key']const obj = {} for(const i=0; i<5; i++){ obj[i] = i*100; } for(const key in obj){ if(obj.hasOwnProperty(key)){ const val = obj[key]; console.log(val); } //for in文でループ処理をしてプロパティを出力しています。Javascriptの実行が遅い時
JSは外部ファイルに記述する
JSファイルの読み込みは/bodyの直前にする
無駄な変数宣言や毎回のDOM生成をしないファイルのダウンロード
Blobオブジェクトとwindow.URL.createObjectURLメソッドを使います。
<a href='./wanko.txt' download='wanko'>ダウンロード</a> const blob = new Blob( [text], {'type':'text'}) const url =window.URL.createObjectURL(blob) document.getElemntById('link') = url }JSでselect要素にoption追加
selectタグを取得
optionタグを作成、テキストとvalueを設定
appendChildでタグ追加<button onVlick='add()'>add</button> <select name='name' id='select'> <option value='1'>1</option> <option value='2'>2</option> </select> function add(){ const select = document.getElementById('select'); const option = dociument.createElement(option); option.text=3; option.value=3; select.appendChild(option); }JSでのセッションの情報管理
セッションとはフロント側でデータを一時保存できる機能のことです。
//SessionStorageが使用可能か判定 if(('sessionStrage' in window) && (window.sessionStotagr !== null)){ //ok } else{ //no } //SessionStorageへデータを保存 sessionStorage.setItem('session_save',1); window.sessionStorage.seiItem('session_save',1); sessionStorage.session_save = 1; //データを取り出す get_storage = sessionStorage.getItem(session_save); get_storage = window.sessionStorage.getItem(''session_save); get_storage = sessionStorage.session_save; //データを削除 sessionStorage.removeItem('session_save'); window.sessioStorage.removeItem('session_save'); //全削除(初期化)する sessionStorage.clear(); window.sessionStorage.clear();文字コードを変換
文字コードとは機械語を人が読める文字に変換するための規約です。
文字列の1文字の文字コードの確認には
文字列.charCodeAt(数値)文字コードのデコードには
String.fromCharCode(数値);処理の順番の変更
console.log('a'); cetTimeout(()=>{ console.log('b')},0 ); console.log('c'); let num = 100; console.log(num); new Promise((resolve,reject) => { num +=100; console.log(num); resolve(num); }).then((num) =>{ setTimeout(()=>{ num+=200},0); console.log(num); }).then((num) =>{ console.log(num); }) //100 200 400 400配列の最後の要素を書き換える
const array = ['a','b','c'] array[array.length - 1] = 'e'; array.pop(); //削除 delete.array[2];配列の最後の要素を書き換える
const array = ['a','b','c'] array[array.length - 1] = 'e'; array.pop(); //削除 delete.array[2];動的変数
varでの変数宣言はやめましょう。
let:動的変数、最大代入可能
const:静的変数、再代入不可能関数内で宣言した変数は外部から参照できず、constは値の変更はできません。
textareaオブジェクト
textarea要素を扱うことが出来ます。
document.getElementById('wanko').value //valueでプロパティを使用できます document.getElementById('wanko').value = pome document.getElementById('wanko').value.row = '10' const input_message = document.getElementById('message').value; message = 'input_message'; document.getElemtntById('output_message').innerHTML = message;
- 投稿日:2019-12-22T23:35:32+09:00
実務経験4ヶ月のインターン生がGASを用いて勤務報告を自動化してみた
初めに
閲覧していただき、ありがとうございます!
現在からくり株式会社でiOSエンジニアとしてインターンしております。
今回勤務報告を自動化してみたので、そちらを記事にしたいと思います?背景
インターン生(アルバイト)は月に一回勤務報告をSlackでしないといけないのですが、
いつも手入力で報告しており、これが滅茶苦茶めんどくさい、、、
「月1の作業くらい頑張れよ?」という野次が飛んできそうですが、
なんせエンジニアなので楽したい性分なのです?そもそもGASとは??
Google Apps Script(GAS)とは、Googleが提供するサーバーサイド・スクリプト環境のことです。
スプレッドシートやGoogleフォームなどのサービスを、
JavaScriptをベースとしたプログラム言語を使って操作することができます。実装方針
インターン生は上記写真のように出勤日時をGoogleカレンダーに記入しなくてはいけないので、
これらイベント情報を抽出して出勤時間を計算していきたいと思います。1.カレンダーから出勤のイベントを抽出
const myCalendar = CalendarApp.getCalendarById('自分のカレンダーID'); const attendanceDays = myCalendar.getEvents(getFirstDate(new Date()), getLastDate(new Date()), {search: "出勤"});//月の初めを取得 function getFirstDate (date) { return new Date(date.getFullYear(), date.getMonth(), 1); } //月の最後を取得 function getLastDate (date) { return new Date(date.getFullYear(), date.getMonth() + 1, 0); }
getCalendarById
メソッドを用いて自分のGoogleカレンダーを取得後、
getEvents
メソッドを用いて出勤のイベントだけを抽出します。2.勤務時間を計算
for(var i = 0; i < attendanceDays.length; i++) { var attendanceTime = attendanceDays[i].getStartTime() var leavingTime = attendanceDays[i].getEndTime() var actualWorkingHours = getActualWorkingHours(leavingTime.getHours(), attendanceTime.getHours()) }function getActualWorkingHours(leavingTime, attendanceTime) { actualWorkingHours = leavingTime - attendanceTime if (actualWorkingHours == 9) { return actualWorkingHours - 1 } else { return actualWorkingHours } }取得してきた各々のイベントの終了時刻
getEndTime()
と開始時刻getStartTime()
を取得し、
その差分を計算します。
また、フル勤務の際(10:00~19:00)は1時間休憩をとる必要があり、
実働時間を正確に計算する為、差分から1時間引きます。3.Slackに投稿
function postMessage() { var url = "https://slack.com/api/chat.postMessage"; var payload = { "token" : "アクセストークン", "channel" : "投稿したいチャンネルのID", "text" : "<@メンションしたい相手のID>投稿するメッセージ, "as_user" : true }; var params = { "method" : "post", "payload" : payload }; UrlFetchApp.fetch(url, params); }成果物
これで毎月の勤務報告を手入力をしなくても
自動で勤務報告ができるようになりました!(送り忘れることもなし)まとめ
普段Swiftを書いていて、今回初めてJavaScript(GAS)を触ったのですが、
なんとか形にすることができてとりあえずホッとしてます笑(なんせこの土日で勉強して書いた為、、、ギリギリ?)
まだ交通費の記入や15分単位での計算に対応できてないので、
その辺りを改善していきたいなです。
また、コードを書いている内にあれも自動化したいな〜?
みたいなのが色々出てきたので、社員の方がより重要な業務に時間を割けるように
無駄な作業はインターン生で自動化していければなと思います!
(社員さんは忙しくてなかなか手が回らないので、、、)最後に
僕がインターンをしているからくり株式会社では現在エンジニアを募集しているそうです!
(インターン、新卒、中途問わず)
興味がありましたら、是非一度遊びに来てくださいね〜!!
Wantedly
- 投稿日:2019-12-22T23:27:44+09:00
JavaScriptで要素が画面に表示されているかの判定
Vue.jsで要素のCSSアニメーションをつける際に「要素が画面に入ってきたら」という条件を判定したく、調査をして実装したのでそのまとめとなります。
elementの取得方法については今回はVue.jsの機能で取得していますが、document.getElementByIdなどで取得しても同様に動きます。
こちらのコンポーネントで実装しています。
// 画面最上部と要素の上端の距離 // こちらの値はスクロール値がすでに引いてある値となるので注意。 // 要するにこの値が0の場合要素の上端が画面の上端となっている状態 var positionY = this.$el.getBoundingClientRect().y // 要素の縦幅 var clientHeight = this.$el.getBoundingClientRect().height // 表示している画面の領域の高さ var windowSize = window.innerHeight /** * 画面上部の判定 * 画面最上部との距離 + 要素サイズ > 0 * = 画面上部に要素の一番下部が表示されているか * かつ * 画面下部の判定 * 画面最上部との距離 < 画面サイズ * = 画面下部に要素の一番上部が表示されているか */ if (positionY + clientHeight > 0 && positionY < windowSize) { // 画面に要素が少しでも表示されている場合 } else { // それ以外 }具体的には、下記のようにしています。
if (positionY + clientHeight > 0 && positionY < windowSize) { // 画面に要素が少しでも表示されている場合 // 画面に表示されるclassを付与する } else { // それ以外 // 画面に表示されなくなるclassを付与する }表示だけでなく、様々なエフェクトをかけたい場合はここからさらにsetTimeoutなどの非同期処理で順を追ってclassを付与していくと良いと思います。
- 投稿日:2019-12-22T23:25:09+09:00
一人でこっそり使ってた仕組みを、他の人にも使ってもらえるようにした話。
はじめに
この記事はHameeアドベントカレンダー2019 24日目のものです。
こっそりで一人で使っていた仕組みを、社内の他の人たちにも使ってもらえるようにするために、一手間加えた、という話をします。そもそも...
そもそも、こっそりと何を作って使っていたのか、から話さないといけませんね......
Hameeでは、GoogleCalendarを社員のスケジュール管理に使用しています。僕も毎日ではありませんが、MTGや社内の勉強会などの予定が入っています。その日のスケジュールは朝に把握して、今日はMTGがあるから、この時間までに、これをやろう、みたいなことを考えています。それを、僕は朝起きてからすぐにやりたかったのですが、それはデフォルトではできませんでした。社内のカレンダーを、自分のスマホで確認することはできないからです。そこで、GoogleCalendarに入っている自分の予定をGoogleAppsScriptを用いて取得し、それを社内で使用しているChatworkのマイチャットに整形して送信する仕組みを実装しました。これでスマホでChatworkから、毎朝、起きた時に1日のスケジュールを把握することができます、やったね!
GoogleAppsScriptは、ざっくりいうと、Googleが提供するJavaScriptの実行環境です。
GoogleCalendarやGoogleSpreadSheetをはじめとするGoogleの各種サービスを、JavaScriptで操作することができます。このときは、GoogleCalendarを操作できるように用意されている CalendarAppを用い、calendarIdを指定して自分のカレンダーを取得しています。カレンダーを取得し、予定の詳細を保持しているCalendarEventから、
・ イベントのタイトル
・ 開始時刻と終了時刻
・ カレンダーイベントへのリンク(ここを参考に)
を取得し、[info][title]Today's Your Schedules! 2019/12/23 (Mon) [/title]Title: 予定のテスト StartTime:14:00 EndTime :15:00 Location:小田原のどこか URL: https://www.google.com/calendar/event?eid= [/info]のような文面を生成して、以下の図のようなメッセージをマイチャットに送信しています。
マイチャットへの送信は、ChatWorkが公式で提供しているAPIを用いて、以下のようなエンドポイントでマイチャットのルームIDを指定して行なっています。
/rooms/{room_id}/messagesこれをGoogleAppsScript(GAS)から実行するのですが、GAS用の(非公式)のライブラリ、Chatwork Client for Google Apps Scriptがあったのでそれを使用させていただいています。
ここまでのコードをまとめると、以下のようになります。
// sender function sendChat(message, roomId, client) { client.sendMessage({ room_id: roomId, body: message }); } // calendarIdに対応するcalendarからその日の予定を取得する function getMyEvents(calendarId) { var myCal = CalendarApp.getCalendarById(calendarId); var date = new Date(); return myCal.getEventsForDay(date); } //全ての予定に対して必要な情報を集めた配列を取得 function getMySchedules(calendarId) { var myEvents = getMyEvents(calendarId); var mySchedules = []; if (myEvents.length === 0) { return mySchedules; } for (var i = 0; i < myEvents.length; i++) { var myEvent = myEvents[i]; var splitEventId = myEvent.getId().split('@'); var eventURL = "https://www.google.com/calendar/event?eid=" + Utilities.base64Encode(splitEventId[0] + " " + calendarId); var startTimeStr = Utilities.formatDate(myEvent.getStartTime(), "JST", "HH:mm"); var endTimeStr = Utilities.formatDate(myEvent.getEndTime(), "JST", "HH:mm"); var mySchedule = 'Title: ' + myEvent.getTitle() + '\nStartTime:' + startTimeStr + '\nEndTime :' + endTimeStr + '\nLocation:' + myEvent.getLocation() + '\n'; mySchedule += 'URL: ' + eventURL + '\n'; mySchedules.push(mySchedule); } return mySchedules; } // マイチャットに、1日のスケジュールを配信する function sendMyTodaysSchedules(roomId, calendarId, chatworkToken) { // chatworkに配信するためのclientインスタンス var client = ChatWorkClient.factory({ token: chatworkToken }); // 取得したカレンダーの予定情報 var mySchedules = getMySchedules(calendarId); var date = new Date(); var dateStr = Utilities.formatDate(date, 'JST', "yyyy/MM/dd (E)"); var message = '[info][title]Today\'s Your Schedules! ' + dateStr + ' [/title]'; if (mySchedules.length === 0) { message += '本日の予定は特にありません。'; } for (var i = 0; i < mySchedules.length; i++) { message += mySchedules[i]; message += '\n'; } message += '[/info]'; sendChat(message, roomId, client); } function main(){ var roomId = '/* roomId */'; var calendarId = '/* calendarId */'; var chatworkToken = '/* chatworkToken */'; sendMyTodaysSchedules(roomId, calendarId, chatworkToken); }あとはこれに、トリガーを設定して毎朝送られてくるようにすれば完成です。
他の人にも使ってもらえるようにする
ここからが本題です笑 この仕組みを使っているうちに、他の人にも使わせてあげたくなってきました。そこで、どうやったら使わせてあげられるか、もとい、使っていただけるかを考えました。
まず、利用者の登録を、GoogleSpreadSheetで行うようにしました。
このシートに、名前とcalendarId、予定を送ってほしいルームのroomIdを記入してもらいます。これを、朝の決まった時間に読み取って、カレンダーの情報をユーザーごとにTo付けをして送信します。これをやるにあたって、発生した問題がいくつかありました。
1つ目は、ChatworkでのTo送信の際のaccount_idの取得方法の変更が必要となった点です。To送信先のaccount_idの取得を、送信先のルームにいるユーザーのaccount_idを取得することで行なっています。マイチャットが送信先になっている場合は、1つのaccount_idしか存在しませんでしたが、他のroomIdも指定できるようにしたので、名前で、送信したいユーザーのaccount_idを検索してあげる必要性が発生しました。今回は新たに、この部分のロジックを実装しています。また、実運用中にルームから検索できないケースがあったので、contactからも検索できるようにしています。
// To:[account_id]形式のテキストを出力 function makeTo(name, room_id, client) { var account_id = getAccountId(name, client, room_id); var message = '[To:' + account_id + ']' + name + '\n'; return message; } // roomから取得できないときは、コンタクトから取得 function getAccountId(name_, client, room_id) { if (room_id === '') { var account_id = getUserId(name_,room_id,client); return account_id; } try { var account_id = getUserId(name_, room_id, client); } catch(error) { var account_id = getUserIdFromContact(name_, client); } return account_id; } // room_idが示すchatroomのメンバー一覧を取得し、ユーザー名に対するaccount_idを取得する function getUserId(name_, room_id, client) { var members = client.get('/rooms/' + room_id + '/members'); if (members.length == 1) { return members[0].account_id; } return searchUserId(members, name_); } // account_idの取得をcontactから行うように変更 function getUserIdFromContact(name_, client) { var members = client.get('/contacts/'); return searchUserId(members, name_); } // 検索部分の共通化 function searchUserId(members, name_){ var user = members.filter(function (member) { name_ = name_.replace(/\s+/g, ''); name = member['name'].replace(/\s+/g, ''); return (name.match(name_) != null); }); return user[0].account_id; }2つ目は、僕のGoogleCalendarに追加されていないと、CalendarAppの検索対象にならないことです。今は仕方なく、Calendarに手動で追加しています。あとで、根本対応?として、SpreadSheetの変更をトリガーにして、Calendarに追加する仕組みを実装します。(仮にユーザーが100名になったら、僕のカレンダーに100名登録されてしまうのですが...)
今回は、一時対応として、配信に失敗した時にエラーメッセージを表示する仕組みを以下のように実装しています。また、スプレッドシートのセルにtrueが入ったユーザーにだけ、手動で再送信する仕組みも実装しました。(直接scriptEditorから関数を実行します。実装はほとんど下記と同じなので割愛します)var chatworkToken = '/* chatworkToken */'; // スプレッドシートのid var id = '/* spreadsheetId */'; var spreadsheet = SpreadsheetApp.openById(id); var sheet = spreadsheet.getSheetByName('members'); var range = sheet.getRange(2, 1, 1000, 4); var values = range.getValues(); for (var i = 0; i < values.length; i++) { var userVal = values[i]; if (userVal[0] === '') break; var name = userVal[0]; var calendarId = userVal[1]; var roomId = userVal[2].toString(); try { sendMyTodaysSchedules(roomId, name, calendarId, chatworkToken); var date = new Date(); sheet.getRange('D'+(i+2).toString()).setValue(date); } catch (error) { // エラーをspreadSheetに表示し、再送フラグも追加 Logger.log(error); sheet.getRange('D'+(i+2).toString()).setValue(error); sheet.getRange('E'+(i+2).toString()).setValue('true'); } }3つ目は、土日にも送られてしまうことがあげられます。これは自分一人で使っている時にはあまり気にならなかったのですが、お休みの日にもChatworkにチャットが送られてしまうことに対する罪悪感から対策しています。
// 土日は送らない var day = new Date().getDay(); if (day === 0 | day === 6) { Logger.log("土日は送らない"); return; }おわりに
ひとりで使っている時には問題にならなかったことも、複数人で使うとなると大きな問題になることがあります。そのような問題を解決して、他の人にも使っていただけるようなものを作ることで、ユーザーの目線に立って考えるいい機会になった、と思います。なによりも自分が作ったものを他の人に使っていただける、というのが結構嬉しいです。会議室、今日どこだっけってなった時にスマホから確認できるとか、想定していない使い方も出てきて面白いです。
ただ、僕のChatworkTokenを使用しているので、毎朝、僕からToでユーザーに自分の予定が送られるという不思議な状況になっています。(あとでBotのアカウントに変えようかな...)ちなみに、現在、僕を含めた7名にこの仕組みは利用されています。ご利用ありがとうございます!
- 投稿日:2019-12-22T23:19:07+09:00
年末まで毎日webサイトを作り続ける大学生 〜65日目 文字を泳がせる〜
はじめに
こんにちは!@70days_jsです。
今日は文字を泳がせてみました。(gif)↓
64日目。(2019/12/21)
よろしくお願いします。サイトURL
https://sin2cos21.github.io/day65.html
やったこと
文字を泳がせてみました。
別の言い方をすると、文字がランダムなスピードでY方向の移動をくり返しています。html↓
<body> <canvas id="canvas"></canvas> </body>canvasを使っています。
css↓
body { margin: 0; } #canvas { background-color: black; }bodyのmarginを消して、背景を黒にしています。
JavaScript↓
let canvas = document.getElementById("canvas"), ctx = canvas.getContext("2d"), twidth = (canvas.width = window.innerWidth), theight = (canvas.height = window.innerHeight), chars = [], fontSize = Math.random() * 100, countNumber = 1, flag = true; chars2 = [ "あ", "い", "う", ...略... "を", "ん" ]; function Character(ctx) { this.ctx = ctx; this.x = Math.floor(Math.random() * twidth); this.y = Math.floor(Math.random() * theight); this.size = fontSize; this.char = chars2[Math.floor(Math.random() * chars2.length)]; this.v = Math.floor(Math.random() * 5); } Character.prototype.render = function() { this.draw(); this.positionChange(); }; Character.prototype.positionChange = function() { this.y -= this.v + 1; if (this.y < 0) this.y = theight; if (this.y > theight) this.y = 0; }; Character.prototype.draw = function() { let ctx = this.ctx; ctx.beginPath(); ctx.fillStyle = "rgba(31, 227, 13, .5)"; ctx.font = this.size + "px Arial"; ctx.fill(); ctx.fillText(this.char, this.x, this.y); ctx.closePath(); }; for (var i = 0; i < 100; i++) { fontSize = Math.random() * 15; let char = new Character(ctx); chars.push(char); } function render() { ctx.clearRect(0, 0, twidth, theight); for (var i = 0; chars.length > i; i++) { let char = chars[i]; char.render(); } requestAnimationFrame(render); } render();昨日作ったCharacterコンストラクターを再利用しています。
メソッドは新たにpositionChange()メソッドを加えました。Y方向の移動をthis.vを使って変更しています。
this.vはランダムで作っているので、あまり遅すぎるものを警戒して最後に+1しています。感想
とてもシンプルなコードですが、文字が動いているのはワクワクしますね。
ひらがなっていいな。最後まで読んでいただきありがとうございます。明日も投稿しますのでよろしくお願いします。
- 投稿日:2019-12-22T23:07:30+09:00
弊社マッチングアプリでよく使わているArray関数ランキング & 使われ方の紹介
概要
JavaScriptのArrayを使う上で、Arrayにはどんな機能があって、どういう場面でどう便利に使えるのか、ぜひ知っておきたいです。
そうしないと、Arrayのさまざまな関数でできることって、結局for文で手続き的に書いていけば実装できてしまうので、ソースがfor文だらけになってしまうかもしれません。
弊社のマッチングアプリ『CoupLink』は、JavaScriptで書かれているのですが、ソース内でArrayの機能がそれぞれどれくらい、そしてどのように使われているのかまとめてみました。
- 添付しているアプリのスクリーンショットはダミーのものになります。
- 例で出しているサンプルのデータは、説明しやすいよう新たに定義したもので、実際の構造とは関係ありません。
- 例で出しているJavaScriptのコードも同様に、説明しやすい内容で新たに書いたもので、実装のソースとは関係ありません。
1位 filter (70回)
特に特殊な使い方はしておらず、配列から特定のものに絞りたい場合に使っています。
↑全スタンプのうち、選択しているタブのスタンプだけを表示します
// チャットで使える複数の種類のスタンプを全てフラットな配列で受け取っている const stamps = [ { id: 1, name: 'stamp1-1', group: 1 }, { id: 2, name: 'stamp1-2', group: 1 }, // ... { id: 41, name: 'stamp2-1', group: 2 }, { id: 42, name: 'stamp2-2', group: 2 }, // ... { id: 239, name: 'stamp6-39', group: 6 }, { id: 240, name: 'stamp6-40', group: 6 } ] // 選択した種類のスタンプのみにフィルタリングする const selectedGroup = 1 const selectedStamps = stamps.filter(v => v.group === selectedGroup) // [{ id: 1, name: 'stamp1-1', group: 1 }, { id: 2, name: 'stamp1-2', group: 1 }, ...]また、配列からfalsyな値を取り除きたいときなどにも使っています。
const values = ['val1', null, 'val3', null] users.filter(v => v) // ['val1', 'val3'] // または users.filter(Boolean) // ['val1', 'val3']2位 map (65回)
mapは体感でもかなり使っている感じがします。
(都道府県idのみの配列を、都道府県文字列でマッピング)
// 都道府県マスタ const PREFECTURES = [ { id: 1, name: '北海道' }, // ... { id: 47, name: '沖縄県' } ] // 都道府県のidのみが格納された配列 const selectedPrefectureIds = [1, 2, 3] // 上記のidの配列をマスタの内容でマッピング const selectedPrefectures = selectedPrefectureIds.map(id => { return PREFECTURES.find(p => p.id === id) }) // [ // { id: 1, name: '北海道' }, // { id: 2, name: '青森県' }, // { id: 3, name: '岩手県' } // ]単純に深い階層にある値だけを取り出したり、
const prefectures = [ { id: 1, name: '北海道' }, { id: 47, name: '沖縄県' } ] const prefectureNames = prefectures.map(v => v.name) // ['北海道', '沖縄県']行ごとに何か関数を実行するような場合でも、返り値があるならmapを使うことが多いです。
const results = arr.map(v => execSmothing(v)) // [true, false]3位 find (66回)
前提として、連続したデータは基本的にArrayで扱っています。
例えば複数のユーザーを変数で扱うとき、以下のようにidなどをキーとしてObjectに格納する、といったことはしません。const users = { 1: { name: 'user1' }, 2: { name: 'user2' }, 3: { name: 'user3' } } const user2 = users[2] // { name: 'user2' }以下のようなデータを扱います。
const users = [ { id: 1, name: 'user1' }, { id: 2, name: 'user2' }, { id: 3, name: 'user3' } ]このとき、特定行の中にある値を元にその行を取得したい場合にfindを使います。
const user2 = users.find(u => u.id === 2) // { id: 2, name: 'user2' }使われ方としてはほとんどがこのケースでした。
4位 includes (62回)
includesはほぼif文でのORを短くする目的で使われていました。
if (response.statusCode === 401 || response.statusCode === 403) { // Do something }↑を↓のように書きたい。
if ([401, 403].includes(response.statusCode)) { // Do something }起点の配列側が定数となり、引数が検証される側の変数となります。
5位 forEach (34回)
主に行ごとに何か別の関数を実行したいときなどに使われています。
const params = new URLSearchParams() userIds.forEach(id => params.append('user_ids[]', id))もしforEachやfor文の利用回数が一番多い人は、例えば以下のように他のArray関数が使えるケースでもfor文を使ってしまっているかもしれません。
const users = [ { id: 1, gender: 'male' }, { id: 2, gender: 'female' }, { id: 3, gender: 'male' } ] // femaleだけの配列にしたい const femaleUsers = [] users.forEach(user => { if (user.gender === 'female') femaleUsers.push(user) })// filterが使えます const femaleUsers = users.filter(user => user.gender === 'female')5位 join (25回)
joinはいたって普通の使い方で、配列を文字列で列挙する際に使っています。
これ以外の使い方はあまり思いつきません。const user1 = { name: 'user1', hobbies: [{ id: 1, name: '映画鑑賞' }, { id: 2, name: '音楽' }, { id: 3, name: 'グルメ' }] } user1.hobbies.map(h => h.name).join(', ') // 映画鑑賞, 音楽, グルメ6位 some (14回)
一行でもcallbackでtrueを返せば、結果がtrueになる関数です。
そのままの使い方では、フォームのバリデーションなどで使っていました。
const arr = [ { question: '質問1', answer: '回答1' }, { question: '質問2', answer: '' }, { question: '質問3', answer: '回答3' }, ] if (arr.some(v => v.answer === '')) alert('未入力項目があります')他の使い方:
someはループ中にtrueが見つかれば、その時点でtrueを返し、それ以降の行のコールバックは実行されません。
この仕様を利用して、break文のような処理を書くことができます。
const arr = ['arg1', 'arg2', 'arg3'] arr.some(arg => { const result = execSomething(arg) return !result // 失敗したらbreak }) // execSomething('arg2') で失敗したら execSomething('arg3') は実行されない7位 reduce (5回)
一般的にはドキュメントにある例のように、各行を集計して1つの結果にまとめる、といった使いかと思われます。
const arr = [1, 5, 10, 20] const sum = arr.reduce((prev, current) => prev + current) // 36CoupLinkでは、第二引数の初期値を利用して新たなObjectやArrayを組み立てるためにたまに使われていました。
例えば、上のスクリーンショットの例では、フラットに格納されたチャットのメッセージの配列を、日付ごとにグルーピングした状態したいときに、reduceを以下のように使っています。
// メッセージの一覧がフラットに格納された配列を const massages = [ { date: '2019-12-01', message: 'm1' }, { date: '2019-12-01', message: 'm2' }, { date: '2019-12-02', message: 'm3' }, { date: '2019-12-03', message: 'm4' }, { date: '2019-12-03', message: 'm5' }, { date: '2019-12-03', message: 'm6' } ] // このように日付でグルーピングしたい [ { date: '2019-12-01', messages: ['m1', 'm2'] }, { date: '2019-12-02', messages: ['m3'] }, { date: '2019-12-03', messages: ['m4', 'm5', 'm6'] } ]// reduce const messageGroup = messages.reduce((arr, current) => { const foundRow = arr.find(v => v.date === current.date) if (foundRow) { foundRow.messages.push(current.message) } else { arr.push({ date: current.date, messages: [current.message] }) } return arr }, [])意地でも一回の代入で仕上げたい人向けです。
forEachで書くと以下のようになります。
reduceの仕様を覚えていない人も少なくないかもしれませんが、さきほどとやっていることは同じです。// forEach const messageGroup = [] messages.forEach(current => { const foundRow = arr.find(v => v.date === current.date) if (foundRow) { foundRow.messages.push(current.message) } else { arr.push({ date: current.date, messages: [current.message] }) } })おわり
おわりです!
- 投稿日:2019-12-22T23:07:30+09:00
自社のマッチングアプリでよく使わているArray関数ランキング & 使われ方の紹介
概要
JavaScriptのArrayを使う上で、Arrayにはどんな機能があって、どういう場面でどう便利に使えるのか、ぜひ知っておきたいです。
そうしないと、Arrayのさまざまな関数でできることって、結局for文で手続き的に書いていけば実装できてしまうので、ソースがfor文だらけになってしまうかもしれません。
弊社のマッチングアプリ『CoupLink』は、JavaScriptで書かれているのですが、ソース内でArrayの機能がそれぞれどれくらい、そしてどのように使われているのかまとめてみました。
- 添付しているアプリのスクリーンショットはダミーのものになります。
- 例で出しているサンプルのデータは、説明しやすいよう新たに定義したもので、実際の構造とは関係ありません。
- 例で出しているJavaScriptのコードも同様に、説明しやすい内容で新たに書いたもので、実装のソースとは関係ありません。
1位 filter (70回)
特に特殊な使い方はしておらず、配列から特定のものに絞りたい場合に使っています。
↑全スタンプのうち、選択しているタブのスタンプだけを表示します
// チャットで使える複数の種類のスタンプを全てフラットな配列で受け取っている const stamps = [ { id: 1, name: 'stamp1-1', group: 1 }, { id: 2, name: 'stamp1-2', group: 1 }, // ... { id: 41, name: 'stamp2-1', group: 2 }, { id: 42, name: 'stamp2-2', group: 2 }, // ... { id: 239, name: 'stamp6-39', group: 6 }, { id: 240, name: 'stamp6-40', group: 6 } ] // 選択した種類のスタンプのみにフィルタリングする const selectedGroup = 1 const selectedStamps = stamps.filter(v => v.group === selectedGroup) // [{ id: 1, name: 'stamp1-1', group: 1 }, { id: 2, name: 'stamp1-2', group: 1 }, ...]また、配列からfalsyな値を取り除きたいときなどにも使っています。
const values = ['val1', null, 'val3', null] users.filter(v => v) // ['val1', 'val3'] // または users.filter(Boolean) // ['val1', 'val3']2位 map (65回)
mapは体感でもかなり使っている感じがします。
(都道府県idのみの配列を、都道府県文字列でマッピング)
// 都道府県マスタ const PREFECTURES = [ { id: 1, name: '北海道' }, // ... { id: 47, name: '沖縄県' } ] // 都道府県のidのみが格納された配列 const selectedPrefectureIds = [1, 2, 3] // 上記のidの配列をマスタの内容でマッピング const selectedPrefectures = selectedPrefectureIds.map(id => { return PREFECTURES.find(p => p.id === id) }) // [ // { id: 1, name: '北海道' }, // { id: 2, name: '青森県' }, // { id: 3, name: '岩手県' } // ]単純に深い階層にある値だけを取り出したり、
const prefectures = [ { id: 1, name: '北海道' }, { id: 47, name: '沖縄県' } ] const prefectureNames = prefectures.map(v => v.name) // ['北海道', '沖縄県']行ごとに何か関数を実行するような場合でも、返り値があるならmapを使うことが多いです。
const results = arr.map(v => execSmothing(v)) // [true, false]3位 find (66回)
前提として、連続したデータは基本的にArrayで扱っています。
例えば複数のユーザーを変数で扱うとき、以下のようにidなどをキーとしてObjectに格納する、といったことはしません。const users = { 1: { name: 'user1' }, 2: { name: 'user2' }, 3: { name: 'user3' } } const user2 = users[2] // { name: 'user2' }以下のようなデータを扱います。
const users = [ { id: 1, name: 'user1' }, { id: 2, name: 'user2' }, { id: 3, name: 'user3' } ]このとき、特定行の中にある値を元にその行を取得したい場合にfindを使います。
const user2 = users.find(u => u.id === 2) // { id: 2, name: 'user2' }使われ方としてはほとんどがこのケースでした。
4位 includes (62回)
includesはほぼif文でのORを短くする目的で使われていました。
if (response.statusCode === 401 || response.statusCode === 403) { // Do something }↑を↓のように書きたい。
if ([401, 403].includes(response.statusCode)) { // Do something }起点の配列側が定数となり、引数が検証される側の変数となります。
5位 forEach (34回)
主に行ごとに何か別の関数を実行したいときなどに使われています。
const params = new URLSearchParams() userIds.forEach(id => params.append('user_ids[]', id))もしforEachやfor文の利用回数が一番多い人は、例えば以下のように他のArray関数が使えるケースでもfor文を使ってしまっているかもしれません。
const users = [ { id: 1, gender: 'male' }, { id: 2, gender: 'female' }, { id: 3, gender: 'male' } ] // femaleだけの配列にしたい const femaleUsers = [] users.forEach(user => { if (user.gender === 'female') femaleUsers.push(user) })// filterが使えます const femaleUsers = users.filter(user => user.gender === 'female')5位 join (25回)
joinはいたって普通の使い方で、配列を文字列で列挙する際に使っています。
これ以外の使い方はあまり思いつきません。const user1 = { name: 'user1', hobbies: [{ id: 1, name: '映画鑑賞' }, { id: 2, name: '音楽' }, { id: 3, name: 'グルメ' }] } user1.hobbies.map(h => h.name).join(', ') // 映画鑑賞, 音楽, グルメ6位 some (14回)
一行でもcallbackでtrueを返せば、結果がtrueになる関数です。
そのままの使い方では、フォームのバリデーションなどで使っていました。
const arr = [ { question: '質問1', answer: '回答1' }, { question: '質問2', answer: '' }, { question: '質問3', answer: '回答3' }, ] if (arr.some(v => v.answer === '')) alert('未入力項目があります')他の使い方:
someはループ中にtrueが見つかれば、その時点でtrueを返し、それ以降の行のコールバックは実行されません。
この仕様を利用して、break文のような処理を書くことができます。
const arr = ['arg1', 'arg2', 'arg3'] arr.some(arg => { const result = execSomething(arg) return !result // 失敗したらbreak }) // execSomething('arg2') で失敗したら execSomething('arg3') は実行されない7位 reduce (5回)
一般的にはドキュメントにある例のように、各行を集計して1つの結果にまとめる、といった使いかと思われます。
const arr = [1, 5, 10, 20] const sum = arr.reduce((prev, current) => prev + current) // 36CoupLinkでは、第二引数の初期値を利用して新たなObjectやArrayを組み立てるためにたまに使われていました。
例えば、上のスクリーンショットの例では、フラットに格納されたチャットのメッセージの配列を、日付ごとにグルーピングした状態したいときに、reduceを以下のように使っています。
// メッセージの一覧がフラットに格納された配列を const massages = [ { date: '2019-12-01', message: 'm1' }, { date: '2019-12-01', message: 'm2' }, { date: '2019-12-02', message: 'm3' }, { date: '2019-12-03', message: 'm4' }, { date: '2019-12-03', message: 'm5' }, { date: '2019-12-03', message: 'm6' } ] // このように日付でグルーピングしたい [ { date: '2019-12-01', messages: ['m1', 'm2'] }, { date: '2019-12-02', messages: ['m3'] }, { date: '2019-12-03', messages: ['m4', 'm5', 'm6'] } ]// reduce const messageGroup = messages.reduce((arr, current) => { const foundRow = arr.find(v => v.date === current.date) if (foundRow) { foundRow.messages.push(current.message) } else { arr.push({ date: current.date, messages: [current.message] }) } return arr }, [])意地でも一回の代入で仕上げたい人向けです。
forEachで書くと以下のようになります。
reduceの仕様を覚えていない人も少なくないかもしれませんが、さきほどとやっていることは同じです。// forEach const messageGroup = [] messages.forEach(current => { const foundRow = arr.find(v => v.date === current.date) if (foundRow) { foundRow.messages.push(current.message) } else { arr.push({ date: current.date, messages: [current.message] }) } })おわり
おわりです!
- 投稿日:2019-12-22T23:03:03+09:00
Webpackで出力したbundleのハッシュ値を抽出して使う
個人的に開発しているサイトではWordPressのPHP上にReactを載せており、そこではbundleのハッシュ値をphp側で参照できるようにしています。extract-hash-webpack-pluginを使うと任意のテキスト形式でハッシュ値を含んだファイルを出力できます。今回はPHPから参照したいので
.php
の形式で出力してみます。以下がwebpack.config.js
の例です。var path = require('path'); var ExtractHashWebpackPlugin = require('extract-hash-webpack-plugin').default; const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const DIST_PATH = "theme/dist"; module.exports = { entry: "./src/index.tsx", output: { filename: "index.[hash].js", path: path.resolve(__dirname, DIST_PATH) }, /* ... */ plugins: [ new ExtractHashWebpackPlugin({ dest: DIST_PATH, filename: 'version.php', fn: hash => `<? $JS_HASH = '${hash}'; ?>` }), /* ... */ ] };プラグインの
fn
オプションでハッシュ値を使った出力を定義できます。今回は変数$JS_HASH
にハッシュ値を保存するPHPのコードを出力するようにしています。
- 投稿日:2019-12-22T22:11:01+09:00
ブラウザ関連のJavaScript
ユーザー情報の取得については、こちらをご参照ください。
JavaScriptによるユーザー情報の取得ページ表示時に処理
イベント 内容 DOMContentLoaded HTMLドキュメント解析完了時(deferを指定しているなら不要) load 全リソースの読み込み完了時(キャッシュの場合は対象外) pageshow 全リソースの読み込み完了時(キャッシュの場合も対象) jswindow.addEventListener("load", () => { console.log("ページが読み込まれた"); });ページ非表示時に処理
イベント 内容 beforeunload ページ遷移直前 unload ウィンドウを閉じた時、他のページに切り替えた時、ページをリローした時に発生(キャッシュの場合は対象外) pagehide ページ遷移などで元のページが隠れた時 jswindow.addEventListener("beforeunload", () => { event.returnValue; });タブがバックグラウンドになったときに処理
イベント 内容 visibilitychange タブのコンテンツが表示されたとき、非表示(バックグラウンド)になったとき document要素に対して設定します。
document.visibilityStateと組み合わせて使います。jsdocument.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { console.log("コンテンツが表示された"); } if (document.visibilityState === 'hidden') { console.log("コンテンツがバックグラウンドになった"); } })ページのフォーカスのイベント
ページにフォーカスされた時
JavaScriptwindow.addEventListener("focus", () => { console.log("ページにフォーカスされた"); });ページからフォーカスが外れた時
JavaScriptwindow.addEventListener("blur", () => { console.log("ページからフォーカスが外れた"); });タイトル
タイトルの取得
html<button id="js-getTitleButton">タイトル取得</button>JavaScriptconst getTitleButton = document.getElementById("js-getTitleButton"); getTitleButton.addEventListener("click", () => { console.log(document.title); });タイトルの変更
html<button id="js-changeTitleButton">タイトル変更</button>JavaScriptconst changeTitleButton = document.getElementById("js-changeTitleButton"); changeTitleButton.addEventListener("click", () => { document.title = "new title" });ハッシュ
値の取得
htm<button id="js-getHashButton">ハッシュ取得</button>JavaScriptconst getHashButton = document.getElementById("js-getHashButton"); getHashButton.addEventListener("click", () => { console.log(location.hash); });変更イベント
html<button id="js-changeHashButton">ハッシュ変更</button>JavaScriptconst changeHashButton = document.getElementById("js-changeHashButton"); //ハッシュ変更 changeHashButton.addEventListener("click", () => { location.hash = "sample"; }); //ハッシュ変更監視 window.addEventListener("hashchange", () => { console.log("ハッシュが変更された"); });ダイアログ
アラート
html<button id="js-alertButton">アラート</button>JavaScriptconst alertButton = document.getElementById("js-alertButton"); alertButton.addEventListener("click", () => { alert("アラート"); });コンファーム
html<button id="js-confirmButton">コンファーム</button>JavaScriptconst confirmButton = document.getElementById("js-confirmButton"); confirmButton.addEventListener("click", () => { const confirmReaction = confirm("OK or Cancel"); if (confirmReaction === true) { console.log("OKを選択した"); } if (confirmReaction === false) { console.log("Cancelを選択した"); } });文字入力プロンプト
html<button id="js-promptButton">プロンプト</button>JavaScriptconst promptButton = document.getElementById("js-promptButton"); promptButton.addEventListener("click", () => { const promptReaction = prompt("文字入力してください", "デフォルト文字列") if (promptReaction === "") { console.log("入力されなかった"); } else if (promptReaction == null) { console.log("キャンセルされた"); } else { console.log(`${promptReaction}と入力された`); } });ウィンドウサイズ
プロパティ window.innerWidth ビューポートの幅 window.innerHeight ビューポートの高さ window.document.documentElement.clientWidth (スクロールバーを除く) window.document.documentElement.clientHeight (スクロールバーを除く) html<button id="js-windowSizeButton">ウィンドウサイズ</button>JavaScriptconst windowSizeButton = document.getElementById("js-windowSizeButton"); windowSizeButton.addEventListener("click", () => { console.log(`幅${window.innerWidth}px`); console.log(`高さ${window.innerHeight}px`); });スクロール
表示位置の取得
ドキュメント左上基準で取得されます。
プロパティ 内容 window.scrollX X軸方向のスクロール量 window.scrollY Y軸方向のスクロール量 html<button id="js-scrollPositionButton" style="margin-top:2000px;">スクロール位置取得</button>JavaScriptconst scrollPositionButton = document.getElementById("js-scrollPositionButton"); scrollPositionButton.addEventListener("click", () => { console.log(`X軸${window.scrollX}`); console.log(`Y軸${window.scrollY}`); });表示位置の変更
メソッド 内容 scrollTo(X,Y) スクロールする座標の指定(絶対) scrollBy(X,Y) スクロールする座標の指定(相対) html<button id="js-scrollButton" style="margin-top:2000px;">スクロールボタン</button>JavaScriptconst scrollButton = document.getElementById("js-scrollButton"); scrollButton.addEventListener("click", () => { window.scrollTo(0, 0); });スクロール時に処理
イベント 内容 scroll 対象の要素がスクロールしたとき jswindow.addEventListener('scroll',()=>{ console.log(window.scrollX,window.scrollY); });Intersection Observer
「基準の要素」と「監視される要素」を監視して、重なり具合をきっかけにして処理を行います。
IntersectionObserver オブジェクトの作成
jsconst observer = new IntersectionObserver(callback, options);IntersectionObserverの第一引数
交差割合が変わったときに実行されるコールバック関数です。
jsconst callback = (entries, observer) => { entries.forEach(entry => { //処理 }) }
引数 説明 entries 交差オブジェクト
observe メソッドを複数回呼び出せば複数の要素を監視できるので配列
このオブジェクトから交差の割合などの情報を引き出せるobserver IntersectionObserverオブジェクト IntersectionObserverの第二引数
オプションです。
jsconst options = { root: null, rootMargin: 0, threshold: [0.25, 0.5] }
項目 説明 root 基準にしたい要素を指定
デフォルトはビューポートrootMargin 対象要素との交差位置の調整
正の値を指定:画面に入る前に交差判定
デフォルト:0threshold コールバックを実行する交差の閾値
数値か、複数の場合は配列で指定可能
例:threshold: [0.2, 0.5]の場合は、20%と50%のときに判定が行われる
範囲:0~1の間(デフォルト0)observerのobserveメソッドで監視を開始
jsconst target = document.querySelector('#something'); observer.observe(target)
IntersectionObserverEntryのプロパティ 内容 intersectionRatio 交差している領域の割合 boundingClientRect 監視対象の要素のサイズと、そのビューポートに対する位置 intersectionRect 交差している領域のサイズと、そのビューポートに対する位置 isIntersecting trueは交差状態への移行、falseは非交差状態への移行 rootBounds rootの要素のサイズと、そのビューポートに対する位置 target 監視対象 time タイムスタンプ html<div class="greenSection"></div> <div class="redSection"></div> <div class="yellowSection"></div> <div class="greenSection"></div> <div class="redSection"></div> <div class=" yellowSection"></div>css.greenSection { background-color: green; height: 1000px; } .redSection { background-color: red; transition: background-color 0.4s ease-out; height: 2000px; } .redSection.active { background-color: blue; } .yellowSection { background-color: yellow; height: 1000px; }js//Intersection Observerの処理 const startIntersectionObserver = () => { //thresholdで指定した値の時に呼ばれるコールバック const callback = (entries, observer) => { entries.forEach(entry => { //交差判定の時点でintersectionRationが20%以上のとき if (entry.intersectionRatio >= 0.2) { //activeクラスを追加 entry.target.classList.add('active') } //交差判定の時点で見えている領域が20%を下回ったとき else if (!entry.isIntersecting) { //activeクラスを削除 entry.target.classList.remove('active') } }) } //Intersection Observerのオプションの指定 const option = { //基準となる要素の指定(ブラウザ) root: null, //対象要素との交差位置の調整 rootMargin: "0px", //20%と100%で交差判定を出す threshold: [0.2, 1] } //Intersection Observerのインスタンス生成 const observer = new IntersectionObserver(callback, option); //対象の要素取得(NodeList) const targetList = document.querySelectorAll('.redSection'); //NodeListの配列化 const targetArray = [...targetList]; //対象の配列の各要素を監視する targetArray.forEach((target) => { observer.observe(target); }) } //Intersection Observerの処理の実行 startIntersectionObserver()リサイズ
画面サイズ変更時に処理
イベント 内容 resize ブラウザのウインドウサイズが変わった時 jswindow.addEventListener('resize', () => { console.log('ブラウザがリサイズされた'); })負荷削減(リサイズ時の処理削減)
サイズが1ピクセルでも変わると発火するので負荷が高いです。
リサイズの1秒後に処理を行うタイマー+リサイズごとに解除することで負荷を抑えます。jslet resizeTimer; window.addEventListener('resize', () => { //リサイズタイマーに値があればタイマー解除 if (resizeTimer != null) { clearTimeout(resizeTimer); } const onResize = () => { console.log("リサイズした") } resizeTimer = setTimeout(() => { onResize(); }, 1000); })負荷削減(ブレイクポイントを超えた時だけ処理)
違い 内容 resizeイベント ウィンドウサイズ変更ごとに実行される matchMedia() 一度だけ実行される(負荷軽減)
メソッド 内容 matchMedia(メディアクエリ) メディアクエリの情報 matchMedia(メディアクエリ).addListener(処理) メディアクエリに一致したとき処理を実行
プロパティ 内容 matchMedia(メディアクエリ).match メディアクエリに一致するかどうか(真偽値) jsconst mediaQueryList = matchMedia('(min-width:500px)'); console.log(mediaQueryList); //幅が500以上のときtruejsmatchMedia('(max-width:300px)').matches; //真偽値 matchMedia('(min-width:100px) and (max-width:700px)').matches; //真偽値jsconst mediaQueryList=matchMedia('(orientation:portrait)'); mediaQueryList.addListener(()=>{ console.log('向きが変更された'); })ResizeObserver
指定した監視要素のリサイズを検知して処理を行う事ができます。
observerの作成
jsconst resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { ... } });対象の監視開始
jsresizeObserver.observe(target);対象の監視終了
jsresizeObserver.unobserve(target);サンプル
html<p id="text--red"> Lorem ipsum dolor sit amet consectetur adipisicing elit. Aliquam, autem optio qui molestias accusamus fugiat nam debitis sunt modi! Voluptatum ullam minus eius earum voluptate dicta dolore neque, voluptatibus itaque maiores hic necessitatibus debitis repudiandae! Quaerat voluptatibus velit magnam veritatis, adipisci perspiciatis reprehenderit consectetur magni laboriosam, dolore repellendus doloribus nesciunt, est dolorum dignissimos. Earum, aliquam nulla? Dolor quo ipsum omnis neque obcaecati corporis sint. Libero repellendus sed autem optio quia doloribus fugiat veritatis excepturi, molestias deserunt nam facere. Quibusdam amet distinctio officia suscipit labore magnam obcaecati aperiam dolorem beatae impedit vitae optio nostrum alias voluptas repellendus similique ad corrupti commodi libero temporibus, excepturi quam. Vero ducimus nisi eaque doloremque atque facilis, tempora accusantium rerum nesciunt, aliquid asperiores ad in pariatur saepe, doloribus deleniti culpa repellendus ratione quia quisquam deserunt iste enim? Nulla, omnis. Maxime cupiditate, delectus corporis praesentium aut commodi architecto eius eos, animi odio dolore fuga totam sint. Suscipit dolorem culpa voluptatem dolor sint. Dignissimos aliquid obcaecati nemo nesciunt eos sint accusamus quos, odit neque architecto aliquam natus magni a nisi libero, omnis quas impedit voluptate! Obcaecati ducimus atque reiciendis, mollitia sint eligendi labore impedit voluptates laboriosam dolor debitis veritatis saepe amet accusamus cupiditate eum dolorem magni nulla. Esse, voluptate, dolorem inventore mollitia delectus tenetur deleniti quibusdam ducimus perspiciatis consequuntur fuga consectetur rerum itaque laboriosam nisi a maxime rem reprehenderit, qui hic enim nihil accusamus soluta! Sapiente veritatis eos obcaecati, accusamus reiciendis delectus voluptates natus odit dolor excepturi officiis iure? Saepe in tenetur soluta libero? Nam, iusto quisquam. Reiciendis, blanditiis dolor? Unde mollitia perferendis aliquid expedita sapiente, ipsa harum possimus debitis numquam exercitationem ipsam suscipit nemo porro hic dolor dolorem adipisci laudantium voluptatem fugit aliquam, tenetur voluptatibus maxime inventore! Sequi magni ad, veritatis, voluptatem aliquid dolorum mollitia laudantium libero commodi ipsam ullam porro doloremque fugit in hic quasi ut. </p> <p id="text--blue"> Lorem ipsum dolor sit, amet consectetur adipisicing elit. Hic, officiis nobis dolorum sint asperiores a aperiam velit fugit aliquid nihil sunt ducimus nostrum! Quas assumenda, corporis rerum molestiae in culpa! Neque dolores at consectetur nisi inventore dolorem corrupti quibusdam maxime unde eos! Ipsam asperiores consectetur unde commodi tempora sit magni ea nesciunt modi explicabo quo voluptas, provident reiciendis. Eligendi deleniti at officia excepturi nemo quae doloribus itaque, reiciendis, explicabo velit rem et cum. Laudantium pariatur doloribus ab delectus qui perspiciatis alias blanditiis vero voluptate quam dicta culpa incidunt quae illo, cupiditate praesentium fugit quia tenetur fugiat iusto ipsa nisi consequuntur. Doloribus quisquam amet fuga? At ab magnam inventore architecto nostrum explicabo quasi labore sit illum delectus, mollitia voluptatum quibusdam temporibus commodi rerum placeat? Vel quidem recusandae ipsum asperiores ratione odit porro repudiandae reiciendis doloribus ad, corporis illo est animi iste natus! Odit similique reiciendis id temporibus molestiae libero est iste, quibusdam ea! Atque, dolores? Atque minima impedit dolores nobis dolorem exercitationem officia harum? Ullam voluptatum modi sint. Voluptas numquam saepe officiis hic dolore nisi doloremque consectetur quidem, voluptatem unde ad alias vitae modi labore aut quia sit ipsa fugit perferendis inventore praesentium repellendus! Atque culpa sapiente laboriosam labore tenetur molestias velit, explicabo minima temporibus ab ad assumenda repudiandae ipsum totam aspernatur iure iste modi, amet, sint ratione placeat sed dolorum dicta. Illum magnam veniam accusantium nulla unde non esse, provident perspiciatis commodi dolorem minima doloribus fugit nihil ut qui similique laboriosam culpa earum dicta at fuga voluptas quo porro. Odio laboriosam illum, obcaecati quo aliquam hic eligendi fugit repellat earum veniam saepe iste quidem cum facilis. Saepe qui obcaecati nisi sapiente porro dolore molestias tenetur expedita explicabo, modi recusandae eos necessitatibus ipsam deleniti odit ut distinctio doloremque numquam dicta reiciendis, debitis dolorum adipisci est odio. Incidunt fugiat illo porro asperiores. </p> <p id="text--yellow"> Lorem ipsum dolor sit, amet consectetur adipisicing elit. Modi corporis quisquam quidem, nobis laboriosam, quasi optio delectus autem tempora accusantium eligendi, placeat magni mollitia illum est tenetur accusamus dolorem nemo! Reprehenderit, doloremque. Quos, officia corrupti fugiat nulla illum iusto accusantium ex nam necessitatibus esse dolor veniam excepturi id accusamus error impedit sed sunt quidem inventore adipisci optio quaerat rerum! Mollitia perferendis quibusdam sed impedit quo aut facere nemo quaerat odit ut pariatur cumque nulla molestiae fugiat, sunt quasi suscipit omnis consequuntur quia eligendi eaque architecto fuga maiores? Facilis dolores libero magni nam sed assumenda minus accusantium, placeat, vero aliquam inventore consequatur eligendi totam blanditiis error natus quos excepturi necessitatibus porro repellat, aspernatur soluta. Illum a, nesciunt incidunt expedita excepturi cumque autem nobis placeat impedit deleniti fugit. Optio distinctio natus dolor voluptates quaerat atque reiciendis a sit, molestiae itaque quo, sequi assumenda, quis aliquam? Nobis velit beatae magni doloremque accusamus eos aperiam quaerat amet, suscipit, error quos, culpa quod eum sunt soluta. Aspernatur repellendus eum nam eligendi, maxime repudiandae similique accusamus amet earum facilis quod explicabo fugit totam praesentium pariatur suscipit, quibusdam aperiam omnis labore adipisci sequi dolore ducimus perspiciatis! Unde explicabo vel cumque impedit recusandae. Corrupti, repellendus exercitationem? Natus iure facilis, id delectus veniam autem enim reiciendis quod voluptas, illo minima repudiandae alias dolores voluptates accusantium? Veritatis, praesentium atque? Aliquid at atque dolores quaerat, consequuntur laboriosam quae maxime maiores consectetur, quibusdam inventore beatae porro corporis fuga in ipsam mollitia velit voluptatum nisi praesentium? Consequuntur error aliquam temporibus, fugit assumenda minima aspernatur, amet natus ipsa perspiciatis aperiam sapiente voluptate incidunt atque itaque alias voluptas et neque, sit consequatur autem! Culpa dolor facere architecto consectetur nulla unde ea obcaecati dolorem neque quia sapiente provident voluptate soluta minus, eaque aperiam nihil totam sunt quas inventore adipisci debitis! Quos expedita nulla fugit tempore optio! </p>css#text--red { background-color: red; } #text--blue { background-color: blue; max-width: 800px; } #text--yellow { background-color: yellow; max-width: 400px; }js//監視対象の取得 const red = document.querySelector("#text--red"); const blue = document.querySelector("#text--blue"); const yellow = document.querySelector("#text--yellow"); const resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { //リサイズされた検知した要素 console.log("リサイズされた要素:" + entry.target.id); //位置、サイズ console.log("top:" + entry.contentRect.top); console.log("left:" + entry.contentRect.left); console.log("width:" + entry.contentRect.width); console.log("height:" + entry.contentRect.height); } }); resizeObserver.observe(red); resizeObserver.observe(blue); resizeObserver.observe(yellow);フルスクリーン表示
html<button id="js-fullscreenButton">フルスクリーン</button> <button id="js-clearfullscreenButton">フルスクリーン解除</button>JavaScriptconst fullscreenButton = document.getElementById("js-fullscreenButton"); fullscreenButton.addEventListener("click", (element) => { //chrome.firefox if (document.body.requestFullscreen) { document.body.requestFullscreen(); } //edge if (document.body.webkitRequestFullscreen) { document.body.webkitRequestFullscreen(); } }); const clearfullscreenButton = document.getElementById("js-clearfullscreenButton"); clearfullscreenButton.addEventListener("click", () => { //chrome,firefox if (document.exitFullscreen) { document.exitFullscreen(); } //edge if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } });ページ遷移
ページを移動
html<button id="js-movePageButton">ページ移動</button>JavaScriptconst movePageButton = document.getElementById("js-movePageButton"); movePageButton.addEventListener("click", () => { location.href = "https://www.google.co.jp/"; });ページをリロード
html<button id="js-reloadPageButton">ページリロード</button>JavaScriptconst reloadPageButton = document.getElementById("js-reloadPageButton"); reloadPageButton.addEventListener("click", () => { //キャッシュを使ったリロード location.reload(); //キャッシュを使わないリロード location.reload(true); });別ウィンドウを開く
html<button id="js-openNewWindowButton">別ウィンドウを開く</button>JavaScriptconst openNewWindowButton = document.getElementById("js-openNewWindowButton"); openNewWindowButton.addEventListener("click", () => { window.open("https://www.google.co.jp"); });履歴
履歴の前後のページに移動
メソッド 内容 history.back() 履歴1つ戻る history.forward() 履歴1つ進む history.go(n) 履歴nつ進む html<button id="js-historygoMinus2Button">履歴2つ戻る</button> <button id="js-historyBackButton">履歴1つ戻る</button> <button id="js-historyforwardButton">履歴1つ進む</button> <button id="js-historygo2Button">履歴2つ進む</button>JavaScriptconst historygoMinus2Button = document.getElementById("js-historygoMinus2Button"); const historyBackButton = document.getElementById("js-historyBackButton"); const historyforwardButton = document.getElementById("js-historyforwardButton"); const historygo2Button = document.getElementById("js-historygo2Button"); historygoMinus2Button.addEventListener("click", () => { history.go(-2); }); historyBackButton.addEventListener("click", () => { history.back(); }); historyforwardButton.addEventListener("click", () => { history.forward(); }); historygo2Button.addEventListener("click", () => { history.go(2); });履歴の操作
URLが変わるだけで、ページの内容は変わりません。
実際にそのページに遷移する場合は読み込み処理を書く必要があります。htmlconst historyPushButton = document.getElementById("js-historyPushButton"); const historyReplaceButton = document.getElementById("js-historyReplaceButton"); const historyBackButton = document.getElementById("js-historyBackButton"); const historyforwardButton = document.getElementById("js-historyforwardButton");JavaScriptconst historyPushButton = document.getElementById("js-historyPushButton"); const historyReplaceButton = document.getElementById("js-historyReplaceButton"); const historyBackButton = document.getElementById("js-historyBackButton"); const historyforwardButton = document.getElementById("js-historyforwardButton"); historyPushButton.addEventListener("click", () => { history.pushState(null, null, "/nextpage.html"); console.log("履歴が追加された"); }); historyReplaceButton.addEventListener("click", () => { history.replaceState(null, null, "/other.html"); console.log("履歴が上書きされた"); }); historyBackButton.addEventListener("click", () => { history.back(); }); historyforwardButton.addEventListener("click", () => { history.forward(); }); window.addEventListener("popstate", () => { console.log("履歴が辿られた"); })
- 投稿日:2019-12-22T21:57:49+09:00
Javascriptで帳票印刷したい
Vue.jsを利用したWebシステムで帳票印刷する必要がありました。
方法がいくつかあり、検討した知見を紹介します。背景
- フロントはVue.jsを利用したSPA
- 指定条件を入力しビジネスの統計情報を画面表示する
- 集計、グラフを伴う
- 統計画面上の「印刷」ボタンを押下すると帳票印刷 or PDFダウンロードする
- ビジネスレポートは複数ページに渡る
- 数値の集計表、グラフなどを伴う
- ヘッダ/フッタなどの体裁はこだわらない
また、画面ページ上の統計情報と印刷ページ上の統計情報は同じ情報であり、
開発コスト低減のために可能であれば、集計表、グラフなどの画面表示用コンポーネントを流用して帳票をレイアウトしたい状況です。方式検討
実現方式は以下のように複数考えられますが、
最終的には「ブラウザの印刷機能でPDF保存」させる方法を採用しました。
- サーバサイドでPDFファイルを生成
- テンプレートPDFを元に差し込み
- PDF生成エンジンを利用
- 独自レイアウト方式型
- HTML+CSSでレイアウト型
- headlessブラウザのレンダリング/印刷機能を利用
- クライアントサイドでPDFファイルを生成
- PDFファイルを生成するJSライブラリ利用
- 独自レイアウト型
- HTML+CSSでレイアウト型
- Domを元にCanvas化し、Canvas画像をPDFに貼り付ける
- クライアントサイドでブラウザの印刷機能でPDF保存 ←採用
- レイアウトはHTML+CSS
- Mediaクエリで印刷用CSS定義
以下、検討の際に考慮した観点について補足します。
定形雛形に差し込み vs ページ記述
例えば、「領収書印刷」などの用途のような、以下の条件のようなものは
- 1枚ペラもの
- 定型フォーマット
- 所定の位置に 宛名、金額 が記載できればOK
定形のテンプレートPDFを作成しておき、宛名、金額など一部のテキストに必要な値を差し込む方式が適していると言えます。
本要件は比較的ボリュームの多いビジネスレポートであり、以下のような特性があるため、テンプレート形式を取りにくいと考えました。
- ページ数は不定
- 長文は改ページして複数ページをまたぐ
- グラフ、表が含まれる
レイアウト方式
独自方式
このとき、どのようにレイアウトをPDF化するか、という問題がありますが、
PDF生成エンジン・ライブラリによっては、独自のレイアウト指定が必要なものがあります。以下はPDF生成ライブラリpdfkit の例です。
テキストだけなら良いですが、表やグラフをこれで描画するのは骨が折れそうです。pdfkitの例const PDFDocument = require('pdfkit'); // Create a document const doc = new PDFDocument; // Pipe its output somewhere, like to a file or HTTP response // See below for browser usage doc.pipe(fs.createWriteStream('output.pdf')); // Embed a font, set the font size, and render some text doc.font('fonts/PalatinoBold.ttf') .fontSize(25) .text('Some text with an embedded font!', 100, 100); // Add an image, constrain it to a given size, and center it vertically and horizontally doc.image('path/to/image.png', { fit: [250, 300], align: 'center', valign: 'center' });HTMLでレイアウト
独自レイアウト方法が大変なので、
慣れたHTML+CSSでレイアウト指定が可能なものがありますが、
カスタムCSSが利用しづらいなどの注意点ががあるようです。ブラウザの印刷機能でPDFレンダリング
ChromeやFirefox, Safariなど人気のあるモダンブラウザでは
印刷機能でPDFとして保存する機能を備えており、
そのレンダリング性能も信頼できると言って良いでしょう。Mediaクエリを利用して印刷用CSSを定義し、
印刷する際のコンテンツサイズ指定、不要な画面サイドのナビやボタン類を非表示にするなどの制御が可能です。フォント
PDF生成エンジン/ライブラリによってはデフォルトで日本語フォントを扱えないものが多いようです。
pdfkit, pdfmake(内部でpdfkitを利用)などはフォント埋め込みのための事前処理や設定が必要なようです。特に、クライアントサイドでPDFを生成する場合は埋め込み用フォントを事前生成するという方法以外に、
Domとして日本語表示したイメージをあえて画像化してPDFに埋め込む、という方法もあります。日本語コンテンツのボリュームによっては、多くの画像が埋め込まれるため、(フォント埋め込みの場合よりも)PDFのサイズは大きくなるかもしれません。
改ページ
複数ページにまたがるPDFの場合は改ページ位置を制御したいかもしれません。
PDF生成エンジン、ライブラリを利用する場合は、そのエンジンの改ページ制御方法に倣う必要があります。ブラウザの印刷機能でPDFを生成する場合もMediaクエリで改ページの制御が可能です。
一方、フォントの問題のためにコンテンツを画像化してPDFに貼り付けている場合は改ページ制御が煩わしいかもしれません。
その他のデザイン・体裁のコントロール
特に、ブラウザの印刷機能を利用してPDFファイルを作成する場合、
クライアントユーザの設定によって生成されるPDFが異なるため、
デザイン・体裁的にコントロール、画一化することは難しいのが現状です。
- 用紙サイズ、向き、マージン
- 背景画像の有無
- ヘッダ・フッタの有無
- URL, ページタイトル, 作成日
主要なモダンブラウザのデフォルトで利用した際に
文章の中身が読めれば良く、ヘッダ・フッタなどにはこだわらなくて良い、
という今回の要件では許容できると判断しました。処理の負荷 (サーバサイド/クライアントサイド)
不特定多数のユーザが利用するシステムであり、
サーバ負荷を抑えたいため、可能であればクライアント側でPDFを生成したいと考えました。デバッグのしやすさ
ブラウザの印刷機能を利用する場合は、デバッグの際に、印刷用CSSと画面表示用CSSを合わせて置けばレイアウト確認は楽々ですね。
ブラウザのデベロッパーツールも活用できるので、ルーラを表示したり、画面を見ながら微調整を試すことも容易です。PDF生成エンジン/ライブラリの場合はデバッグは大変かも。
Vue.js での実装例
<template> <div class="sheets"> <div> <el-button type="primary" @click="handlePrint">印刷</el-button> ※PDFで保存したい場合は印刷ダイアログで「PDF保存」を指定してください </div> <div class="sheet"> <h2>帳票サンプル</h2> <h3>テーブルを印刷する</h3> <el-table :data="list" border fit> <el-table-column label="ID" prop="id" align="center" width="80px"> <template slot-scope="scope"> <span>{{ scope.row.id }}</span> </template> </el-table-column> <el-table-column label="Title" min-width="150px"> <template slot-scope="{row}"> <span>{{ row.title }}</span> </template> </el-table-column> <el-table-column label="Author" width="110px" align="center"> <template slot-scope="scope"> <span>{{ scope.row.author }}</span> </template> </el-table-column> </el-table> </div> <div class="sheet"> <h3>改ページのテスト</h3> 2ページ目 </div> </div> </template> <script> export default { data() { return { list: null } }, created() { this.getList() }, mounted() { this.fetchData() }, methods: { getList() { // APIからデータ取得する想定 this.list = [ { id: 1, author: 'John Due', title: 'Hello, world' }, { id: 2, author: '太郎', title: 'あいうえお かきくけこ' } ] }, fetchData() { document.title = 'タイトルをいい感じに設定する' setTimeout(() => { this.$nextTick(() => { this.handlePrint() }) }) }, handlePrint() { window.print() } } } </script> <style lang="scss" scoped> .sheet { page-break-after: always; } /* hide in print */ @media print { .sheets > :not(.sheet) { display: none; } } /* for preview */ @media screen { /* mm単位で指定しているけど、vueコンポ側はpx単位なので、無理にmmにしなくてもいいかも。解像度の違いでハマるかも */ .sheet { width: 200mm; min-height: 296mm; /* 設定しなくてもいいかも。あまり印刷画面に似せすぎると、些細な違いがバグに見えてしまう */ margin: 5mm; padding: 5mm; background: white; box-shadow: 0 .5mm 2mm rgba(0,0,0,.3); } } </style> <style lang="scss"> /* for preview */ @media screen { BODY { background: #eee; } } </style>Vueで実装した帳票プレビュー画面
Webシステムで帳票印刷機能を実行すると、プレビュー画面を表示するようにしています。
画面上部にある「印刷」ボタンは、メディアクエリにて画面表示の場合のみボタン表示して、印刷時には非表示にしています。あえて、印刷プレビューっぽく見えるようにグレー背景、縦ページ、ドロップシャドウなどを設定していますが、ブラウザの印刷ダイアログでもプレビュー表示されるので、無くて良いかも。
ブラウザの印刷ダイアログ
Chromeの印刷ダイアログの例。
デフォルトではWebページタイトルがPDFファイル名になるので、印刷直前にWebページタイトルを切り替えるように実装しておきます。
参考
- 投稿日:2019-12-22T21:38:54+09:00
【初心者向け】【PWA】コピペでできる!フロントエンド3言語でテトリス作ってスマホで遊ぼ!【githubで公開】
はじめに
初めまして、@kashiwagi_wataruと申します。
プログラミングを初めて間もない頃って成果物を誰かに使ってもらうことが少なくてモチベーションの意地が結構難しいと思うんですよね。
なんか初心者の人でも誰かに使ってもらえるようなアプリを開発できる記事をかけないかなぁと考えてみました!
そこで思いついたのがコピペでできる。テトリスを作って遊んでもらお!と言う開発から公開まで一連の流れを体験できる記事です。追記:OUTPUTの鬼となるため、初めてqiitaの記事を書かせていただくことになりました。
乱文、乱コードは寒い日の夜中に子供にプレゼントを届けるサンタのような目でみていただければ幸いですこの記事の対象とする人
- プログラミングを初めてまもない人
- サーバーサイドが苦手な人
- 自分が作ったものを誰かに使ってみてもらいたい人
- 今この記事を見ている人
やりたいことメモ
大人気ゲームテトリスを作る
スマホでも操作可能。
ホーム画面に追加をするとオフラインでも使えようにする
消した行をカウントする。
デプロイとか難しいことはしたくない。AWSで間違ってお金がかかったりするから(うっかり課金経験者ですw)(AWSでお金がうっかり課金されちゃいガチな人はこちらへ笑 https://qiita.com/Yuji-Ishibashi/items/bb1c0042fd16a9350c5a )
目次
大きく分けて3つに分けることができます。
- PWAの設定をする
- テトリスを作る
- githubで公開する
一応なぞってコピペしていくと完成するようになっていますが、
興味あるところまで飛ばしてくださっても全然大丈夫です!PWAとは?
みなさん、PWAってご存知ですか。
Googleが言い出した、今注目されつつある仕組みで、
これを使えばwebサイトのUIUXをよりよくできるだろうと言われているものになります。https://www.seohacks.net/basic/terms/pwa/
WAとは、「Progressive Web Apps」の略称で、モバイル向けWebサイトをスマートフォン向けアプリのように使えるようにする仕組みです。
PWAはそれ自体が何か特殊な一つの技術、というわけではありません。レスポンシブデザイン、HTTPS化など、Googleが定める要素を備えたWebサイトであり、オフラインやプッシュ通知に対応するためのブラウザAPI(Service Workerなど)を利用しているWebサイトをPWAと呼びます。
PWAを実装することでプッシュ通知やホーム画面へのアイコン追加など、アプリの特徴的な機能をWebサイトに持たせる事ができます。これにより、UX向上やユーザーエンゲージメントの改善にもつながるとして注目されています。つまり、webアプリケーションをあたかもスマホアプリのように使えるようになるとのことです!
「ホーム画面に追加」をするとキャッシュさえ残っていればオフラインでもそのwebページが利用可能になるとのこと。。PWAの導入
PWAの導入はすごく簡単です。
必要なファイルは下記の3つです。
- index.html
- service_worker.js
- manifest.json
圧倒的少なさ!
これだけでゲームが作れるなんてでは一つずつ紹介していきますね
index.html
このファイルは一番最初に表示されるページとなります。
スマホでもたくさん使って欲しいのでviewportの記述を忘れずに、
とりあえずは下記のような感じでいいでしょう。index.html<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="manifest" href="manifest.json"> <title>Tetris_app</title> </head> <body> <script> if ('serviceWorker' in navigator) { navigator.serviceWorker.register('service_worker.js').then(function(registration) { console.log('ServiceWorker registration successful with scope: ', registration.scope); }).catch(function(err) { console.log('ServiceWorker registration failed: ', err); }); } </script> </body> </html>service_worker.js
PWAにとってこのファイルはとても大事です。
push通知とかアプリチックなことを実行するときに重要なservice_workerの登録をしてくれます。service_worker.js// キャッシュファイルの指定 var CACHE_NAME = 'TetrisApp-caches'; var urlsToCache = [ '/kashiwagi-wataru.github.io/', ]; // service workerの記述 if ('serviceWorker' in navigator) { navigator.serviceWorker.register('service_worker.js').then(function(registration) { console.log('ServiceWorker registration successful with scope: ', registration.scope); }).catch(function(err) { console.log('ServiceWorker registration failed: ', err); }); } // インストール処理 self.addEventListener('install', function(event) { event.waitUntil( caches .open(CACHE_NAME) .then(function(cache) { return cache.addAll(urlsToCache); }) ); }); // キャッシュロードの処理 self.addEventListener('fetch', function(event) { event.respondWith( caches .match(event.request) .then(function(response) { return response ? response : fetch(event.request); }) ); });manifest.json
これは、スマホでアプリみたいに利用できるようにするためのファイルです。
下記のような感じでいいでしょう
ポイントは"display": "standalone"です。
standaloneにすることでスマホでアプリのように使用することができます。
もっと拘りたい方は、
https://developer.mozilla.org/ja/docs/Web/Manifest
をみて改造してみてください
アイコンとかを指定できたりもします。manifest.json{ "short_name": "Tetris", "name": "Tetris_App", "display": "standalone", "start_url": "index.html" }これで最低限必要なファイルは完成です!(簡単!)
テトリスを作る
今からテトリスを作っていきます。
テトリスといえば知らない人はいないあのゲームのことですよね。
ゲームをつくるといっても使うのは、HTML
CSS
Javascript
の3つしか使いません。
先ほど作った3つのファイル+cssファイル,jsファイルを作りましょう。
下記のファイルは全て同じ階層に格納されます。
- index.html(このファイルに上書きしていきます)
- service_worker.js(これは触らない)
- manifest.json(これも触らない)
--下記2つを追加--
- style.css
- app.js
こんなのを作れればいいなぁと思ってます。
テトリスのコードはすごく長くなるのでザーッとみてコピペでいいですw
追々細かい説明を書いていきます笑(2020年になってから笑)テトリスのコードは下記の通りです。
index.html
先ほど作ったindex.htmlに追記(上書き)していきましょう
index.html<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" type="text/css" href="style.css"> <link rel="manifest" href="manifest.json"> <title>PWA Sample</title> </head> <body> <div class="title-wrap"> <h1>テトリスで遊ぼう!</h1> </div> <div class="wrapper-container"> <div class="tetris-container"> <div class="tetris-main"> <canvas id="stage" width="250px" height="500px" style="background-color:black;"> </canvas> </div> <div class="tetris-option"> <span class="tetris-panel-container"> <p>Next</p> <canvas id="next" width="150px" height="150px" style="background-color:black;"> </canvas> <p>LINES:<span id="lines">0</span></p> <p><span id="message"></span></p> <div class="tetris-panel-container-padding"> <table class="tetris-button-panel"> <tr> <td></td> <td id="tetris-rotate-button" class="tetris-button">↻</td> <td></td> </tr> <tr> <td id="tetris-move-left-button"class="tetris-button">←</td> <td id="tetris-fall-button"class="tetris-button">↓</td> <td id="tetris-move-right-button"class="tetris-button">→</td> </tr> </table> </div> </span> </div> </div> </div> <script src="app.js"></script> <script> var tetris = new Tetris(); tetris.startGame(); </script> </body> </html>style.css
style.csshtml { touch-action: manipulation; } h1 { margin: 0; position: relative; font-size: 40px; text-align: center; margin: 20px 0; display: inline-block; } p { margin: 0; line-height: 2; } .title1-wrap { padding-bottom: 20px; text-align: center; } .wrapper-container { display: inline-block; } .tetris-container { height:530px; display: flex; flex-direction: row; margin: 10px; background-color: #333333; } .tetris-panel-contaizner { display: flex; padding-left: 10px; padding-right: 10px; flex-direction: column; color: white; background-color: #333333; } .tetris-panel-container-padding { flex-grow: 1; } .tetris-panel-container p { margin-left:20px; padding: 10px 0; font-family: sans-serif; font-size: 20px; color: #ffffff; } .tetris-button-panel { border-style: none; width: 100%; padding-top:100px; } .tetris-button { padding-top: 10px; padding-bottom: 10px; text-align: center; background: #444444; box-shadow: inset 0 2px 0 rgba(255,255,255,0.2), inset 0 -2px 0 rgba(0, 0, 0, 0.05), 0 2px 6px rgba(0, 0, 0, .15); border-radius: 4px; } .tetris-button:active { box-shadow: 0 0 2px rgba(0, 0, 0, 0.30); }app.js
app.jsclass Tetris { constructor() { this.stageWidth = 10; this.stageHeight = 20; this.stageCanvas = document.getElementById("stage"); this.nextCanvas = document.getElementById("next"); let cellWidth = this.stageCanvas.width / this.stageWidth; let cellHeight = this.stageCanvas.height / this.stageHeight; this.cellSize = cellWidth < cellHeight ? cellWidth : cellHeight; this.stageLeftPadding = (this.stageCanvas.width - this.cellSize * this.stageWidth) / 2; this.stageTopPadding = (this.stageCanvas.height - this.cellSize * this.stageHeight) ; this.blocks = this.createBlocks(); this.deletedLines = 0; window.onkeydown = (e) => { if (e.keyCode === 37) { this.moveLeft(); } else if (e.keyCode === 38) { this.rotate(); } else if (e.keyCode === 39) { this.moveRight(); } else if (e.keyCode === 40) { this.fall(); } } document.getElementById("tetris-move-left-button").onmousedown = (e) => { this.moveLeft(); } document.getElementById("tetris-rotate-button").onmousedown = (e) => { this.rotate(); } document.getElementById("tetris-move-right-button").onmousedown = (e) => { this.moveRight(); } document.getElementById("tetris-fall-button").onmousedown = (e) => { this.fall(); } } createBlocks() { let blocks = [ { shape: [[[-1, 0], [0, 0], [1, 0], [2, 0]], [[0, -1], [0, 0], [0, 1], [0, 2]], [[-1, 0], [0, 0], [1, 0], [2, 0]], [[0, -1], [0, 0], [0, 1], [0, 2]]], color: "rgb(0, 255, 255)", highlight: "rgb(255, 255, 255)", shadow: "rgb(0, 128, 128)" }, { shape: [[[0, 0], [1, 0], [0, 1], [1, 1]], [[0, 0], [1, 0], [0, 1], [1, 1]], [[0, 0], [1, 0], [0, 1], [1, 1]], [[0, 0], [1, 0], [0, 1], [1, 1]]], color: "rgb(255, 255, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(128, 128, 0)" }, { shape: [[[0, 0], [1, 0], [-1, 1], [0, 1]], [[-1, -1], [-1, 0], [0, 0], [0, 1]], [[0, 0], [1, 0], [-1, 1], [0, 1]], [[-1, -1], [-1, 0], [0, 0], [0, 1]]], color: "rgb(0, 255, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(0, 128, 0)" }, { shape: [[[-1, 0], [0, 0], [0, 1], [1, 1]], [[0, -1], [-1, 0], [0, 0], [-1, 1]], [[-1, 0], [0, 0], [0, 1], [1, 1]], [[0, -1], [-1, 0], [0, 0], [-1, 1]]], color: "rgb(255, 0, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(128, 0, 0)" }, { shape: [[[-1, -1], [-1, 0], [0, 0], [1, 0]], [[0, -1], [1, -1], [0, 0], [0, 1]], [[-1, 0], [0, 0], [1, 0], [1, 1]], [[0, -1], [0, 0], [-1, 1], [0, 1]]], color: "rgb(0, 0, 255)", highlight: "rgb(255, 255, 255)", shadow: "rgb(0, 0, 128)" }, { shape: [[[1, -1], [-1, 0], [0, 0], [1, 0]], [[0, -1], [0, 0], [0, 1], [1, 1]], [[-1, 0], [0, 0], [1, 0], [-1, 1]], [[-1, -1], [0, -1], [0, 0], [0, 1]]], color: "rgb(255, 165, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(128, 82, 0)" }, { shape: [[[0, -1], [-1, 0], [0, 0], [1, 0]], [[0, -1], [0, 0], [1, 0], [0, 1]], [[-1, 0], [0, 0], [1, 0], [0, 1]], [[0, -1], [-1, 0], [0, 0], [0, 1]]], color: "rgb(255, 0, 255)", highlight: "rgb(255, 255, 255)",111111111111 shadow: "rgb(128, 0, 128)" } ]; return blocks; } drawBlock(x, y, type, angle, canvas) { let context = canvas.getContext("2d"); let block = this.blocks[type]; for (let i = 0; i < block.shape[angle].length; i++) { this.drawCell(context, x + (block.shape[angle][i][0] * this.cellSize), y + (block.shape[angle][i][1] * this.cellSize), this.cellSize, type); } } drawCell(context, cellX, cellY, cellSize, type) { let block = this.blocks[type]; let adjustedX = cellX + 0.5; let adjustedY = cellY + 0.5; let adjustedSize = cellSize - 1; context.fillStyle = block.color; context.fillRect(adjustedX, adjustedY, adjustedSize, adjustedSize); context.strokeStyle = block.highlight; context.beginPath(); context.moveTo(adjustedX, adjustedY + adjustedSize); context.lineTo(adjustedX, adjustedY); context.lineTo(adjustedX + adjustedSize, adjustedY); context.stroke(); context.strokeStyle = block.shadow; context.beginPath(); context.moveTo(adjustedX, adjustedY + adjustedSize); context.lineTo(adjustedX + adjustedSize, adjustedY + adjustedSize); context.lineTo(adjustedX + adjustedSize, adjustedY); context.stroke(); } startGame() { let virtualStage = new Array(this.stageWidth); for (let i = 0; i < this.stageWidth; i++) { virtualStage[i] = new Array(this.stageHeight).fill(null); } this.virtualStage = virtualStage; this.currentBlock = null; this.nextBlock = this.getRandomBlock(); this.mainLoop(); } mainLoop() { if (this.currentBlock == null) { if (!this.createNewBlock()) { return; } } else { this.fallBlock(); } this.drawStage(); if (this.currentBlock != null) { this.drawBlock(this.stageLeftPadding + this.blockX * this.cellSize, this.stageTopPadding + this.blockY * this.cellSize, this.currentBlock, this.blockAngle, this.stageCanvas); } setTimeout(this.mainLoop.bind(this), 500); } createNewBlock() { this.currentBlock = this.nextBlock; this.nextBlock = this.getRandomBlock(); this.blockX = Math.floor(this.stageWidth / 2 - 2); this.blockY = 0; this.blockAngle = 0; this.drawNextBlock(); if (!this.checkBlockMove(this.blockX, this.blockY, this.currentBlock, this.blockAngle)) { let messageElem = document.getElementById("message"); messageElem.innerText = "GAME OVER"; return false; } return true; } drawNextBlock() { this.clear(this.nextCanvas); this.drawBlock(this.cellSize * 2, this.cellSize, this.nextBlock, 0, this.nextCanvas); } getRandomBlock() { return Math.floor(Math.random() * 7); } fallBlock() { if (this.checkBlockMove(this.blockX, this.blockY + 1, this.currentBlock, this.blockAngle)) { this.blockY++; } else { this.fixBlock(this.blockX, this.blockY, this.currentBlock, this.blockAngle); this.currentBlock = null; } } checkBlockMove(x, y, type, angle) { for (let i = 0; i < this.blocks[type].shape[angle].length; i++) { let cellX = x + this.blocks[type].shape[angle][i][0]; let cellY = y + this.blocks[type].shape[angle][i][1]; if (cellX < 0 || cellX > this.stageWidth - 1) { return false; } if (cellY > this.stageHeight - 1) { return false; } if (this.virtualStage[cellX][cellY] != null) { return false; } } return true; } fixBlock(x, y, type, angle) { for (let i = 0; i < this.blocks[type].shape[angle].length; i++) { let cellX = x + this.blocks[type].shape[angle][i][0]; let cellY = y + this.blocks[type].shape[angle][i][1]; if (cellY >= 0) { this.virtualStage[cellX][cellY] = type; } } for (let y = this.stageHeight - 1; y >= 0; ) { let filled = true; for (let x = 0; x < this.stageWidth; x++) { if (this.virtualStage[x][y] == null) { filled = false; break; } } if (filled) { for (let y2 = y; y2 > 0; y2--) { for (let x = 0; x < this.stageWidth; x++) { this.virtualStage[x][y2] = this.virtualStage[x][y2 - 1]; } } for (let x = 0; x < this.stageWidth; x++) { this.virtualStage[x][0] = null; } let linesElem = document.getElementById("lines"); this.deletedLines++; linesElem.innerText = "" + this.deletedLines; } else { y--; } } } drawStage() { this.clear(this.stageCanvas); let context = this.stageCanvas.getContext("2d"); for (let x = 0; x < this.virtualStage.length; x++) { for (let y = 0; y < this.virtualStage[x].length; y++) { if (this.virtualStage[x][y] != null) { this.drawCell(context, this.stageLeftPadding + (x * this.cellSize), this.stageTopPadding + (y * this.cellSize), this.cellSize, this.virtualStage[x][y]); } } } } moveLeft() { if (this.checkBlockMove(this.blockX - 1, this.blockY, this.currentBlock, this.blockAngle)) { this.blockX--; this.refreshStage(); } } moveRight() { if (this.checkBlockMove(this.blockX + 1, this.blockY, this.currentBlock, this.blockAngle)) { this.blockX++; this.refreshStage(); } } rotate() { let newAngle; if (this.blockAngle < 3) { newAngle = this.blockAngle + 1; } else { newAngle = 0; } if (this.checkBlockMove(this.blockX, this.blockY, this.currentBlock, newAngle)) { this.blockAngle = newAngle; this.refreshStage(); } } fall() { while (this.checkBlockMove(this.blockX, this.blockY + 1, this.currentBlock, this.blockAngle)) { this.blockY++; this.refreshStage(); } } refreshStage() { this.clear(this.stageCanvas); this.drawStage(); this.drawBlock(this.stageLeftPadding + this.blockX * this.cellSize, this.stageTopPadding + this.blockY * this.cellSize, this.currentBlock, this.blockAngle, this.stageCanvas); } clear(canvas) { let context = canvas.getContext("2d"); context.fillStyle = "rgb(0, 0, 0)"; context.fillRect(0, 0, canvas.width, canvas.height); } }githubで公開する!!
では作成したファイルをサーバーに公開して終了となります。
サーバーはhttpsが利用できるAWSやherokuなどで頑張って公開しようかと思っていたところ、
githubだけで簡単にwebページを公開する方法があるそうです。しかもgithubの公式さんが言うには、かなり簡単とのこと。
これはやるしかないですねgithub公式が割とわかりやすいのでおいておきますね。
https://pages.github.com/github.ioと言うものらしいです。
github.ioのメリットデメリット
メリット
- 簡単にwebページを公開できる
- 無料
- 更新したい場合はリポジトリにpushするだけ
デメリット
- ソースコードが公開される
- サーバー依存のjsとかは使えない
今回は...
- ただ自分で作ったwebページを公開するだけ(特にセキリュティを気にするものはない)
- jsはサーバーに依存しない
と言うことで最適な方法と言えるのではないのでしょうか。
方法
やり方は簡単で、
リポジトリ名を
username.github.io
にするだけでいいらしいです。すごい。ではやってみましょう。(本当にほぼ公式のやり方をなぞっていくだけですw)
① まずはリポジトリを作ります。
命名規則はusername.github.io
だそうです。
僕の場合は下記のようになります。② 次に下記のコマンドを実行します。デスクトップとかでね
クローンして
git clone https://github.com/username/username.github.io移動してサンプルテキストを入れて(index.htmlを作って)
cd username.github.io echo "Hello World" > index.htmlgit add --all git commit -m "Initial commit" git push -u origin masterそして
https://username.github.io
にアクセスすればHelloWorldと表示されるはず!
![]()
![]()
![]()
なぜか404と怒られてしまいました。。。原因はよくわかりませんw
ですが作ったリポジトリのsettings
にある
ここにリンクが載っていました。
リンクの構造がgithub公式の構造と少し違いますが、とりあえず踏んでみましょう。
表示されました!
(反映されるまで、少し時間が(30秒くらい)かかるかもしれないので焦らずに待ちましょう)これで作ったゲームを公開する準備はできました。
このリポジトリにテトリスのファイルをぶち込んで完成となります。
動作確認
最終的なファイルは
- index.html
- style.css
- app.js
- manifest.json
- service_worker.jsの5つになると思います。
githubのリポジトリに全てのファイルが揃ったら
https://username.github.io
にアクセスしてみましょう。
PC、スマホ両方でアクセスすると良いと思います。こんな感じでChromeとは別のタブで開けていたらOKです!
オフラインでの動作を確認したいので一度タブを閉じで機内モードにしてから開いてみてください
機内モードでもテトリスで遊べちゃうと思います!
恐るべしPWA。。。こんな感じで、テトリスをスマホアプリのように扱えるようになりました。
リンクを友達に教えると友達のスマホでも遊べるようになるのでぜひ友達に送りつけてみてください。参照記事
http://kmaebashi.com/programmer/tetris/index.html
https://www.webprofessional.jp/what-is-the-attention-project-pwa-by-google/
- 投稿日:2019-12-22T21:38:54+09:00
【初心者向け】コピペでできる!javascriptでスマホで遊べるテトリス作ってgithubで公開!【PWA】
はじめに
初めまして、@kashiwagi_wataruと申します。
プログラミングを初めて間もない頃って成果物を誰かに使ってもらうことが少なくてモチベーションの意地が結構難しいと思うんですよね。
なんか初心者の人でも誰かに使ってもらえるようなアプリを開発できる記事をかけないかなぁと考えてみました!
そこで思いついたのがコピペでできる。テトリスを作って遊んでもらお!と言う開発から公開まで一連の流れを体験できる記事です。追記:OUTPUTの鬼となるため、初めてqiitaの記事を書かせていただくことになりました。
乱文、乱コードは寒い日の夜中に子供にプレゼントを届けるサンタのような目でみていただければ幸いですこの記事の対象とする人
- プログラミングを初めてまもない人
- サーバーサイドが苦手な人
- 自分が作ったものを誰かに使ってみてもらいたい人
- 今この記事を見ている人
やりたいことメモ
大人気ゲームテトリスを作る
スマホでも操作可能。
ホーム画面に追加をするとオフラインでも使えようにする
消した行をカウントする。
デプロイとか難しいことはしたくない。AWSで間違ってお金がかかったりするから(経験者ですw)(AWSでお金がうっかり課金されちゃいガチな人はこちらへ笑 https://qiita.com/Yuji-Ishibashi/items/bb1c0042fd16a9350c5a )
目次
大きく分けて3つに分けることができます。
- PWAの設定をする
- テトリスを作る
- githubで公開する
一応なぞってコピペしていくと完成するようになっていますが、
興味あるところまで飛ばしてくださっても全然大丈夫です!PWAとは?
みなさん、PWAってご存知ですか。
Googleが言い出した、今注目されつつある仕組みで、
これを使えばwebサイトのUIUXをよりよくできるだろうと言われているものになります。https://www.seohacks.net/basic/terms/pwa/
WAとは、「Progressive Web Apps」の略称で、モバイル向けWebサイトをスマートフォン向けアプリのように使えるようにする仕組みです。
PWAはそれ自体が何か特殊な一つの技術、というわけではありません。レスポンシブデザイン、HTTPS化など、Googleが定める要素を備えたWebサイトであり、オフラインやプッシュ通知に対応するためのブラウザAPI(Service Workerなど)を利用しているWebサイトをPWAと呼びます。
PWAを実装することでプッシュ通知やホーム画面へのアイコン追加など、アプリの特徴的な機能をWebサイトに持たせる事ができます。これにより、UX向上やユーザーエンゲージメントの改善にもつながるとして注目されています。つまり、webアプリケーションをあたかもスマホアプリのように使えるようになるとのことです!
「ホーム画面に追加」をするとキャッシュさえ残っていればオフラインでもそのwebページが利用可能になるとのこと。。PWAの導入
PWAの導入はすごく簡単です。
必要なファイルは下記の3つです。
- index.html
- service_worker.js
- manifest.json
圧倒的少なさ!
これだけでゲームが作れるなんてでは一つずつ紹介していきますね
index.html
このファイルは一番最初に表示されるページとなります。
スマホでもたくさん使って欲しいのでviewportの記述を忘れずに、
とりあえずは下記のような感じでいいでしょう。index.html<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="manifest" href="manifest.json"> <title>Tetris_app</title> </head> <body> <script> if ('serviceWorker' in navigator) { navigator.serviceWorker.register('service_worker.js').then(function(registration) { console.log('ServiceWorker registration successful with scope: ', registration.scope); }).catch(function(err) { console.log('ServiceWorker registration failed: ', err); }); } </script> </body> </html>service_worker.js
PWAにとってこのファイルはとても大事です。
push通知とかアプリチックなことを実行するときに重要なservice_workerの登録をしてくれます。service_worker.js// キャッシュファイルの指定 var CACHE_NAME = 'TetrisApp-caches'; var urlsToCache = [ '/kashiwagi-wataru.github.io/', ]; // service workerの記述 if ('serviceWorker' in navigator) { navigator.serviceWorker.register('service_worker.js').then(function(registration) { console.log('ServiceWorker registration successful with scope: ', registration.scope); }).catch(function(err) { console.log('ServiceWorker registration failed: ', err); }); } // インストール処理 self.addEventListener('install', function(event) { event.waitUntil( caches .open(CACHE_NAME) .then(function(cache) { return cache.addAll(urlsToCache); }) ); }); // キャッシュロードの処理 self.addEventListener('fetch', function(event) { event.respondWith( caches .match(event.request) .then(function(response) { return response ? response : fetch(event.request); }) ); });manifest.json
これは、スマホでアプリみたいに利用できるようにするためのファイルです。
下記のような感じでいいでしょう
ポイントは"display": "standalone"です。
standaloneにすることでスマホでアプリのように使用することができます。
もっと拘りたい方は、
https://developer.mozilla.org/ja/docs/Web/Manifest
をみて改造してみてください
アイコンとかを指定できたりもします。manifest.json{ "short_name": "Tetris", "name": "Tetris_App", "display": "standalone", "start_url": "index.html" }これで最低限必要なファイルは完成です!(簡単!)
テトリスを作る
今からテトリスを作っていきます。
テトリスといえば知らない人はいないあのゲームのことですよね。
ゲームをつくるといっても使うのは、HTML
CSS
Javascript
の3つしか使いません。
先ほど作った3つのファイル+cssファイル,jsファイルを作りましょう。
下記のファイルは全て同じ階層に格納されます。
- index.html(このファイルに上書きしていきます)
- service_worker.js(これは触らない)
- manifest.json(これも触らない)
--下記2つを追加--
- style.css
- app.js
こんなのを作れればいいなぁと思ってます。
テトリスのコードはすごく長くなるのでザーッとみてコピペでいいですw
追々細かい説明を書いていきます笑(2020年になってから笑)テトリスのコードは下記の通りです。
index.html
先ほど作ったindex.htmlに追記(上書き)していきましょう
index.html<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" type="text/css" href="style.css"> <link rel="manifest" href="manifest.json"> <title>PWA Sample</title> </head> <body> <div class="title-wrap"> <h1>テトリスで遊ぼう!</h1> </div> <div class="wrapper-container"> <div class="tetris-container"> <div class="tetris-main"> <canvas id="stage" width="250px" height="500px" style="background-color:black;"> </canvas> </div> <div class="tetris-option"> <span class="tetris-panel-container"> <p>Next</p> <canvas id="next" width="150px" height="150px" style="background-color:black;"> </canvas> <p>LINES:<span id="lines">0</span></p> <p><span id="message"></span></p> <div class="tetris-panel-container-padding"> <table class="tetris-button-panel"> <tr> <td></td> <td id="tetris-rotate-button" class="tetris-button">↻</td> <td></td> </tr> <tr> <td id="tetris-move-left-button"class="tetris-button">←</td> <td id="tetris-fall-button"class="tetris-button">↓</td> <td id="tetris-move-right-button"class="tetris-button">→</td> </tr> </table> </div> </span> </div> </div> </div> <script src="app.js"></script> <script> var tetris = new Tetris(); tetris.startGame(); </script> </body> </html>style.css
style.csshtml { touch-action: manipulation; } h1 { margin: 0; position: relative; font-size: 40px; text-align: center; margin: 20px 0; display: inline-block; } p { margin: 0; line-height: 2; } .title1-wrap { padding-bottom: 20px; text-align: center; } .wrapper-container { display: inline-block; } .tetris-container { height:530px; display: flex; flex-direction: row; margin: 10px; background-color: #333333; } .tetris-panel-contaizner { display: flex; padding-left: 10px; padding-right: 10px; flex-direction: column; color: white; background-color: #333333; } .tetris-panel-container-padding { flex-grow: 1; } .tetris-panel-container p { margin-left:20px; padding: 10px 0; font-family: sans-serif; font-size: 20px; color: #ffffff; } .tetris-button-panel { border-style: none; width: 100%; padding-top:100px; } .tetris-button { padding-top: 10px; padding-bottom: 10px; text-align: center; background: #444444; box-shadow: inset 0 2px 0 rgba(255,255,255,0.2), inset 0 -2px 0 rgba(0, 0, 0, 0.05), 0 2px 6px rgba(0, 0, 0, .15); border-radius: 4px; } .tetris-button:active { box-shadow: 0 0 2px rgba(0, 0, 0, 0.30); }app.js
app.jsclass Tetris { constructor() { this.stageWidth = 10; this.stageHeight = 20; this.stageCanvas = document.getElementById("stage"); this.nextCanvas = document.getElementById("next"); let cellWidth = this.stageCanvas.width / this.stageWidth; let cellHeight = this.stageCanvas.height / this.stageHeight; this.cellSize = cellWidth < cellHeight ? cellWidth : cellHeight; this.stageLeftPadding = (this.stageCanvas.width - this.cellSize * this.stageWidth) / 2; this.stageTopPadding = (this.stageCanvas.height - this.cellSize * this.stageHeight) ; this.blocks = this.createBlocks(); this.deletedLines = 0; window.onkeydown = (e) => { if (e.keyCode === 37) { this.moveLeft(); } else if (e.keyCode === 38) { this.rotate(); } else if (e.keyCode === 39) { this.moveRight(); } else if (e.keyCode === 40) { this.fall(); } } document.getElementById("tetris-move-left-button").onmousedown = (e) => { this.moveLeft(); } document.getElementById("tetris-rotate-button").onmousedown = (e) => { this.rotate(); } document.getElementById("tetris-move-right-button").onmousedown = (e) => { this.moveRight(); } document.getElementById("tetris-fall-button").onmousedown = (e) => { this.fall(); } } createBlocks() { let blocks = [ { shape: [[[-1, 0], [0, 0], [1, 0], [2, 0]], [[0, -1], [0, 0], [0, 1], [0, 2]], [[-1, 0], [0, 0], [1, 0], [2, 0]], [[0, -1], [0, 0], [0, 1], [0, 2]]], color: "rgb(0, 255, 255)", highlight: "rgb(255, 255, 255)", shadow: "rgb(0, 128, 128)" }, { shape: [[[0, 0], [1, 0], [0, 1], [1, 1]], [[0, 0], [1, 0], [0, 1], [1, 1]], [[0, 0], [1, 0], [0, 1], [1, 1]], [[0, 0], [1, 0], [0, 1], [1, 1]]], color: "rgb(255, 255, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(128, 128, 0)" }, { shape: [[[0, 0], [1, 0], [-1, 1], [0, 1]], [[-1, -1], [-1, 0], [0, 0], [0, 1]], [[0, 0], [1, 0], [-1, 1], [0, 1]], [[-1, -1], [-1, 0], [0, 0], [0, 1]]], color: "rgb(0, 255, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(0, 128, 0)" }, { shape: [[[-1, 0], [0, 0], [0, 1], [1, 1]], [[0, -1], [-1, 0], [0, 0], [-1, 1]], [[-1, 0], [0, 0], [0, 1], [1, 1]], [[0, -1], [-1, 0], [0, 0], [-1, 1]]], color: "rgb(255, 0, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(128, 0, 0)" }, { shape: [[[-1, -1], [-1, 0], [0, 0], [1, 0]], [[0, -1], [1, -1], [0, 0], [0, 1]], [[-1, 0], [0, 0], [1, 0], [1, 1]], [[0, -1], [0, 0], [-1, 1], [0, 1]]], color: "rgb(0, 0, 255)", highlight: "rgb(255, 255, 255)", shadow: "rgb(0, 0, 128)" }, { shape: [[[1, -1], [-1, 0], [0, 0], [1, 0]], [[0, -1], [0, 0], [0, 1], [1, 1]], [[-1, 0], [0, 0], [1, 0], [-1, 1]], [[-1, -1], [0, -1], [0, 0], [0, 1]]], color: "rgb(255, 165, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(128, 82, 0)" }, { shape: [[[0, -1], [-1, 0], [0, 0], [1, 0]], [[0, -1], [0, 0], [1, 0], [0, 1]], [[-1, 0], [0, 0], [1, 0], [0, 1]], [[0, -1], [-1, 0], [0, 0], [0, 1]]], color: "rgb(255, 0, 255)", highlight: "rgb(255, 255, 255)",111111111111 shadow: "rgb(128, 0, 128)" } ]; return blocks; } drawBlock(x, y, type, angle, canvas) { let context = canvas.getContext("2d"); let block = this.blocks[type]; for (let i = 0; i < block.shape[angle].length; i++) { this.drawCell(context, x + (block.shape[angle][i][0] * this.cellSize), y + (block.shape[angle][i][1] * this.cellSize), this.cellSize, type); } } drawCell(context, cellX, cellY, cellSize, type) { let block = this.blocks[type]; let adjustedX = cellX + 0.5; let adjustedY = cellY + 0.5; let adjustedSize = cellSize - 1; context.fillStyle = block.color; context.fillRect(adjustedX, adjustedY, adjustedSize, adjustedSize); context.strokeStyle = block.highlight; context.beginPath(); context.moveTo(adjustedX, adjustedY + adjustedSize); context.lineTo(adjustedX, adjustedY); context.lineTo(adjustedX + adjustedSize, adjustedY); context.stroke(); context.strokeStyle = block.shadow; context.beginPath(); context.moveTo(adjustedX, adjustedY + adjustedSize); context.lineTo(adjustedX + adjustedSize, adjustedY + adjustedSize); context.lineTo(adjustedX + adjustedSize, adjustedY); context.stroke(); } startGame() { let virtualStage = new Array(this.stageWidth); for (let i = 0; i < this.stageWidth; i++) { virtualStage[i] = new Array(this.stageHeight).fill(null); } this.virtualStage = virtualStage; this.currentBlock = null; this.nextBlock = this.getRandomBlock(); this.mainLoop(); } mainLoop() { if (this.currentBlock == null) { if (!this.createNewBlock()) { return; } } else { this.fallBlock(); } this.drawStage(); if (this.currentBlock != null) { this.drawBlock(this.stageLeftPadding + this.blockX * this.cellSize, this.stageTopPadding + this.blockY * this.cellSize, this.currentBlock, this.blockAngle, this.stageCanvas); } setTimeout(this.mainLoop.bind(this), 500); } createNewBlock() { this.currentBlock = this.nextBlock; this.nextBlock = this.getRandomBlock(); this.blockX = Math.floor(this.stageWidth / 2 - 2); this.blockY = 0; this.blockAngle = 0; this.drawNextBlock(); if (!this.checkBlockMove(this.blockX, this.blockY, this.currentBlock, this.blockAngle)) { let messageElem = document.getElementById("message"); messageElem.innerText = "GAME OVER"; return false; } return true; } drawNextBlock() { this.clear(this.nextCanvas); this.drawBlock(this.cellSize * 2, this.cellSize, this.nextBlock, 0, this.nextCanvas); } getRandomBlock() { return Math.floor(Math.random() * 7); } fallBlock() { if (this.checkBlockMove(this.blockX, this.blockY + 1, this.currentBlock, this.blockAngle)) { this.blockY++; } else { this.fixBlock(this.blockX, this.blockY, this.currentBlock, this.blockAngle); this.currentBlock = null; } } checkBlockMove(x, y, type, angle) { for (let i = 0; i < this.blocks[type].shape[angle].length; i++) { let cellX = x + this.blocks[type].shape[angle][i][0]; let cellY = y + this.blocks[type].shape[angle][i][1]; if (cellX < 0 || cellX > this.stageWidth - 1) { return false; } if (cellY > this.stageHeight - 1) { return false; } if (this.virtualStage[cellX][cellY] != null) { return false; } } return true; } fixBlock(x, y, type, angle) { for (let i = 0; i < this.blocks[type].shape[angle].length; i++) { let cellX = x + this.blocks[type].shape[angle][i][0]; let cellY = y + this.blocks[type].shape[angle][i][1]; if (cellY >= 0) { this.virtualStage[cellX][cellY] = type; } } for (let y = this.stageHeight - 1; y >= 0; ) { let filled = true; for (let x = 0; x < this.stageWidth; x++) { if (this.virtualStage[x][y] == null) { filled = false; break; } } if (filled) { for (let y2 = y; y2 > 0; y2--) { for (let x = 0; x < this.stageWidth; x++) { this.virtualStage[x][y2] = this.virtualStage[x][y2 - 1]; } } for (let x = 0; x < this.stageWidth; x++) { this.virtualStage[x][0] = null; } let linesElem = document.getElementById("lines"); this.deletedLines++; linesElem.innerText = "" + this.deletedLines; } else { y--; } } } drawStage() { this.clear(this.stageCanvas); let context = this.stageCanvas.getContext("2d"); for (let x = 0; x < this.virtualStage.length; x++) { for (let y = 0; y < this.virtualStage[x].length; y++) { if (this.virtualStage[x][y] != null) { this.drawCell(context, this.stageLeftPadding + (x * this.cellSize), this.stageTopPadding + (y * this.cellSize), this.cellSize, this.virtualStage[x][y]); } } } } moveLeft() { if (this.checkBlockMove(this.blockX - 1, this.blockY, this.currentBlock, this.blockAngle)) { this.blockX--; this.refreshStage(); } } moveRight() { if (this.checkBlockMove(this.blockX + 1, this.blockY, this.currentBlock, this.blockAngle)) { this.blockX++; this.refreshStage(); } } rotate() { let newAngle; if (this.blockAngle < 3) { newAngle = this.blockAngle + 1; } else { newAngle = 0; } if (this.checkBlockMove(this.blockX, this.blockY, this.currentBlock, newAngle)) { this.blockAngle = newAngle; this.refreshStage(); } } fall() { while (this.checkBlockMove(this.blockX, this.blockY + 1, this.currentBlock, this.blockAngle)) { this.blockY++; this.refreshStage(); } } refreshStage() { this.clear(this.stageCanvas); this.drawStage(); this.drawBlock(this.stageLeftPadding + this.blockX * this.cellSize, this.stageTopPadding + this.blockY * this.cellSize, this.currentBlock, this.blockAngle, this.stageCanvas); } clear(canvas) { let context = canvas.getContext("2d"); context.fillStyle = "rgb(0, 0, 0)"; context.fillRect(0, 0, canvas.width, canvas.height); } }githubで公開する!!
では作成したファイルをサーバーに公開して終了となります。
サーバーはhttpsが利用できるAWSやherokuなどで頑張って公開しようかと思っていたところ、
githubだけで簡単にwebページを公開する方法があるそうです。しかもgithubの公式さんが言うには、かなり簡単とのこと。
これはやるしかないですねgithub公式が割とわかりやすいのでおいておきますね。
https://pages.github.com/github.ioと言うものらしいです。
github.ioのメリットデメリット
メリット
- 簡単にwebページを公開できる
- 無料
- 更新したい場合はリポジトリにpushするだけ
デメリット
- ソースコードが公開される
- サーバー依存のjsとかは使えない
今回は...
- ただ自分で作ったwebページを公開するだけ(特にセキリュティを気にするものはない)
- jsはサーバーに依存しない
と言うことで最適な方法と言えるのではないのでしょうか。
方法
やり方は簡単で、
リポジトリ名を
username.github.io
にするだけでいいらしいです。すごい。ではやってみましょう。(本当にほぼ公式のやり方をなぞっていくだけですw)
① まずはリポジトリを作ります。
命名規則はusername.github.io
だそうです。
僕の場合は下記のようになります。② 次に下記のコマンドを実行します。デスクトップとかでね
クローンして
git clone https://github.com/username/username.github.io移動してサンプルテキストを入れて(index.htmlを作って)
cd username.github.io echo "Hello World" > index.htmlgit add --all git commit -m "Initial commit" git push -u origin masterそして
https://username.github.io
にアクセスすればHelloWorldと表示されるはず!
![]()
![]()
![]()
なぜか404と怒られてしまいました。。。原因はよくわかりませんw
ですが作ったリポジトリのsettings
にある
ここにリンクが載っていました。
リンクの構造がgithub公式の構造と少し違いますが、とりあえず踏んでみましょう。
表示されました!
(反映されるまで、少し時間が(30秒くらい)かかるかもしれないので焦らずに待ちましょう)これで作ったゲームを公開する準備はできました。
このリポジトリにテトリスのファイルをぶち込んで完成となります。
動作確認
最終的なファイルは
- index.html
- style.css
- app.js
- manifest.json
- service_worker.jsの5つになると思います。
githubのリポジトリに全てのファイルが揃ったら
https://username.github.io
にアクセスしてみましょう。
PC、スマホ両方でアクセスすると良いと思います。こんな感じでChromeとは別のタブで開けていたらOKです!
オフラインでの動作を確認したいので一度タブを閉じで機内モードにしてから開いてみてください
機内モードでもテトリスで遊べちゃうと思います!
恐るべしPWA。。。こんな感じで、テトリスをスマホアプリのように扱えるようになりました。
リンクを友達に教えると友達のスマホでも遊べるようになるのでぜひ友達に送りつけてみてください。参照記事(結構コピペしてますw)
![]()
http://kmaebashi.com/programmer/tetris/index.html
https://www.webprofessional.jp/what-is-the-attention-project-pwa-by-google/
- 投稿日:2019-12-22T21:21:47+09:00
4種類のHeadlessCMSを試してみた感想(wordpress/strapi/contentful/microCMS)
この記事はJAMstack Advent Calendar 24日目の記事です。
前提
本投稿にはJAMStackとヘッドレスCMS自体の説明は省きます。
JAMStackとは何か、ヘッドレスCMSとは何かといった所については、JAMstack Advent Calendarの主催者であるshibe97さんが大変分かりやすい日本語記事を幾つか投稿してくださっているので、是非見てみてください。
- JAMstackとは何を指すのか今一度考えてみる
- Jamstack公式ページの和訳 + 考察記事
- ヘッドレスCMSとは何か?従来CMSとの違いやメリデメを解説!
- GitベースのCMSとAPIベースのCMSの比較
はじめに
フリーランスエンジニアのyanagiと申します。
自分用にHeadlessCMSについての知見をまとめようとしていたところ
JAMstack Advent Calendarの存在を知り、どうせならと参加してみました。headlesscms.orgを見ると、数多くのヘッドレスCMSが存在する事がわかります。
ぱっと見で人気がありそうなのはStrpi, Contentful, Ghost, NetlifyCMSあたりでしょうか。これらにどんな違いがあるのか、何を基準に選んだらいいのか
中々考えるのが大変な部分だと思うので、そのあたりの参考になる記事をかけたらと思っています。探していたもの
そもそもなぜ4種類ものHeadlessCMSを触ったのかという話になるのですが
現在、株式会社Xemono様にて
企業のコーポレートサイトやWebメディアなどを簡単に作れるパッケージ製品の開発を行っており(近日公開予定)
パッケージのベースとなる良いCMSを探していました。大雑把な前提は以下通りです。
- JAMStackの思想で作りたい
- viewはnuxtとTypeScriptで作る
- デプロイ & ホスティングはgithub actionsとgithub pagesで実現する
CMSに求めていた機能は以下の通り
- 最低限、ステージング環境と本番環境が欲しい
- 管理画面が日本語対応であると嬉しい
- 維持・管理コストは低い方が良い
- コンテントタイプの定義等を簡単に再利用したい
- コンテンツ側の自動テストもしたい
- 情報が多い方が良い
これらの要求を満たしてくれそうなものと、既に開発実績のあったWordpressを使う方法を試してみました。
- WordpressのHeadless化
- Strapi
- ContentFul
- microCMS
使い方等には言及しません。
どの製品もドキュメントがしっかりしているので、良いと思う製品があれば公式の方から色々読んでみてください。簡単な比較
2019/12/24現在の状況
主観を含む部分が多くあります。
自分の調べが足りない所もあるかもしれません。
もし間違いがあったらコメント等で指摘頂けたら幸いです。
製品 コスト・手間 定義・データの再利用 プレビュー 権限管理 バリデーション graphQL化 日本語化 headless wordpress × ○ × ○ ○ ○ ○ Strapi × ○ × ○ △(必須のみ) ○ ○ Contentful ○ ○ ○ △(有料) ○ ○ × microCMS ○ × ○ ○ △(必須のみ) × ○ 各製品の特徴と使ってみた感想
1. WordpressのHeadless化
特徴
- wp-jamstack-deploymentsなどのプラグインを用いてWordpressをHeadlessCMS化する
- wordpressの管理画面をそのまま用いる事が出来るのでwordpressを使い慣れたユーザーにとってはメリットが大きい
- できる事は多いが、プラグインを使ったりして自分でなんとかすることになる(手間)
感想
今年10月より運営を開始している東方我楽多叢誌はこの方法で実現しています。
開発者側としては案件ごとのカスタマイズの手間が多かったり、wordpress特有の作業(プラグインを入れたり、functions.phpをこねくり回したり)が残っていたり、折角JAMStackでやろうとしているのに開発体験が中々高まりません。
Wordpressのホスティングの方法にもよるかとは思いますが、セキュリティ的にも気を使わなければいけない部分が多く
純正のHeadlssCMSに比べるとコストも大きくなりがちかと思います。一方で、wordpressの管理画面をそのまま用いる事が出来るというのは、コンテンツを管理するユーザーにとって大きなメリットです。
そのあたりについては、JAMstack Advent Calendar 8日目の記事が大変参考になります。ユーザーの性質や開発する製品によってはこの選択肢もあるという事は頭の片隅においておきたい所です。
2. Strapi
特徴
- オープンソース / サーバーインストール形のAPIベースHeadlessCMS
- CMS側も自分でカスタマイズ可能
- CMS側のホスティングを自前でやる必要がある
- dockerイメージが既にある
- DBをmysql, mariadb, mongodb, postgreSQL, SQLiteから選べる
- 拡張性が高い - ドキュメント
- 日本語化も可能
感想
構築自動化、マイグレーションなど開発者がやりたいと思うような事は大概できそうです。
拡張性の高さ故に、覚える事も多い印象です。
CMS自体のホスティングが自前というところもあり、小さめの案件にはあまり向かないかもしれません。wordpressをサーバーインストールしていじり倒しているような案件や、CMSを内製しているような案件はこちらに置き換える事が出来るのではないかと思います。
3. ContentFul
特徴
- クローズドソースでAPIベースのクラウドサービス
- HeadlessCMSの中では上位の利用者の多さ、情報も多い
- CMS側を自分でホスティングする必要がない
- プライシング
- microプランでも環境を二つまで作れる(staging/productionなど)
- cliツールの機能やapiが充実している - ドキュメント
- Webhookが自分で定義できる。github actionsとの連携も容易
- 日本語への対応は今のところない
感想
日本語対応以外は今回探していた要件と一番合致したのがContentFulでした。
クラウドサービスであるため、自前でホスティングする必要ないにもかかわらず、CLI経由でマイグレーションができたり、環境を複数用意できたり、痒い所に手が届いている印象です。
おそらく、開発者が欲しいと思う機能は大体あるし、殆どの操作がCLI経由で出来るので、コマンド一発で構築終わり!が実現できます。
利用者が多く、情報や有志のモジュールが多い点も良い。microプラン($39/月)はユーザー登録時に一つもらえるため、大抵の案件はそちらでまかなえると思います。
管理画面が非エンジニアには扱いにくそうなところと、microプランだと権限が1つまでしか設定できず、管理者と入稿者で権限を分けられないところがネックにはなってくるでしょうか
この辺りは後述のmicroCMSに軍配が上がります。4. microCMS
特徴
- クローズドソースでAPIベースのクラウドサービス
- 純日本製、日本語のHeadlessCMS
- 公式や作者が頻繁に情報更新している
- freeプランがかなり充実してる(制限がデータ転送量とメンバー数のみ)
- 管理画面のUIが良い、権限も分ける事ができ、ユーザーライク
- APIリファレンスの自動作成機能がある
- Webhookがslackとnetlify向けしかない
- 必須設定以外のバリデーションがまだ無い
- ドキュメントにあたるものがブログ形式で見辛い
感想
個人的に今一番気になっているHeadlessCMSです。
サービス開始が2019年8月とかなり新しい製品です。純日本製サービスである上、UIもわかりやすく、非エンジニアのコンテンツ管理者であっても管理画面が扱い易いと思います。
自動作成されるAPIリファレンスも親切で、開発者にとっても易しいと感じました。特徴にも幾つか挙げた通り、現時点では、機能にせよドキュメントにせよまだ足りないと思う部分は多いものの
更新頻度が高い上、ユーザーの要望を積極的に取り入れており、
今後、ドンドン良いサービスになっていくのではないかと思っています。
何かあった時の問い合せが日本語で良いのも日本人にとってはありがたいポイントだったりします。現時点でも、とにかくコストや工数を抑えて小規模なコーポレートサイトやポートフォリオサイトを作りたいといった場合には向いているサービスだと思います。
まとめ
HeadlessCMS界隈は群雄割拠状態で、どのような案件でも「これ!」とオススメできる製品はまだ無さそうです。
自分のやりたい事や仕事相手の性質、作る製品の事を総合的に考えて技術選定をする必要があります。
(それはHeadlessCMSに限った話では無いですが)本投稿が少しでも技術選定の助けになれれば幸いです。
- 投稿日:2019-12-22T21:05:45+09:00
ReactNativeで基本的なフォームを作成する(Formik/Yupを使用)
React Nativeで基本的なフォーム画面を作成する手法を模索したのでまとめてみます。ラジオボタンやドロップダウン選択など、アプリにあまり無いようなものも使ってフォームを作成する際、悩ましいのがこういう点だと思います。
- UIコンポーネント類はいくつかあるが、色々な種類のフォームフィールドを扱うためのスタンダードなライブラリが無い
- それらをつぎはぎするとデザインが統一しにくくなる
- デザインのカスタマイズをある程度できるようにしたい
- そもそもチェックボックスやラジオボタンなどにライブラリを使う必要を感じない(でもなるべく煩雑にならないようにしたい)
- Formik等にstate管理を任せてYupでバリデーションしたい、でもFormikとReact Nativeの相性があまりよくない
これらをなるべく解決したいと思います。
デザインのカスタマイズ性と既成のUIコンポーネント類を使う手軽さとどちらを取るかは人によって考え方が異なると思いますが、使いたい全ての機能をサポートするような既成のものがなかったため、この記事では自作している部分が多くなりました。対応するフォーム用コンポーネント
- テキスト入力
- ラジオボタン
- チェックボックス
- トグルスイッチ
- ドロップダウン
- ファイル入力
- 日付選択
Formik / Yup を導入
最終的なソースを最後に載せますが、順を追って要素を追加していきます。
App.jsimport React, { Component } from "react"; import { StyleSheet, Text, View, ScrollView, TextInput } from "react-native"; import { Formik } from "formik"; import * as Yup from "yup"; const schema = Yup.object().shape({ name: Yup.string() .min(3, "3文字以上で入力してください") .max(20, "20文字以内で入力してください") .required("氏名を入力してください") }); const styles = StyleSheet.create({ container: { width: "100%", padding: 24, backgroundColor: "#fff", alignItems: "center", justifyContent: "center" }, form: { width: "100%" } }); export default class App extends Component { onSubmit = async (values, actions) => { // データ送信 }; render() { return ( <ScrollView contentContainerStyle={styles.container}> <View style={styles.form}> <Formik initialValues={{ name: "" }} validateOnMount validationSchema={schema} onSubmit={this.onSubmit} > {({ handleSubmit, handleChange, handleBlur, isValid, isSubmitting, values, errors, touched }) => ( <> <View> {errors.name && touched.name ? <Text>{errors.name}</Text> : null} <TextInput value={values.name} onChangeText={handleChange('name')} onBlur={handleBlur('name')} placeholder="氏名を入力してください" /> </View> <Button title="Submit" onPress={handleSubmit} disabled={!isValid || isSubmitting} /> </> )} </Formik> </View> </ScrollView> ); } }大まかな流れとしてはHTMLと一緒ですが、HTMLでFormikを使用する場合は
<Form />
や<Field />
などのコンポーネントをそのまま使って簡潔に書けるのに対して、React NativeでFormikを使う際は一手間必要です。
エラーの表示や入力イベントのハンドリングなどを<Formik />
の子要素として渡すfunctionの引数(errors
,handleChange
等)から自分で処理します。また、Formikはテキスト入力以外のフィールドからは通常HTMLのイベントを利用して値などを取得しているため、React Nativeで文字列以外の値を持つ入力フィールドを普通に作ろうとすると以下のようなエラーが出ると思います。
TypeError: undefined is not an object (evaluating 'target.type')そこで、これらを解決するためというのと、複数種類の入力フィールドのデザインを統一するために、各フィールドをラップするコンポーネントを作ってみます。
FormFieldコンポーネントを作成
まずはHTMLに依存している部分を避けてFormikをReact Nativeで使うために、
react-native-formik
というヘルパーをインストールし、withFormikControl
関数を使ってみます。これは引数に与えられたコンポーネントのpropsにFormikの状態やセッター関数を渡してくれる高階functional componentだと認識しておけば大丈夫です。ちなみに実装自体はシンプルなのですが、npmで
Unpacked Size
が2.4 MBとあったので何事かと思ったら、サンプル画像の容量が大きいだけでした。さて、この
withFormikControl
を使ってコンポーネントをラップしますが、せっかくなので下記のように、フィールドのタイトルやエラーの処理、デザインなどを統一するためのさらに高階のコンポーネント(FormField
)を作っておきます。FormField.jsimport React from "react"; import * as PropTypes from "prop-types"; import { View, Text, StyleSheet } from "react-native"; import { withFormikControl } from "react-native-formik"; import colors from "../constants/colors"; const styles = StyleSheet.create({ container: { width: "100%", marginVertical: 12, paddingBottom: 24 }, label: { fontSize: 16, marginBottom: 10 }, error: { position: "absolute", color: colors.red, fontSize: 12, bottom: 0 } }); function FormField(WrappedComponent) { return withFormikControl(function(props) { const { label, error, touched } = props; return ( <View style={styles.container}> <Text style={styles.label}>{label}</Text> <WrappedComponent {...props} /> {error && touched ? <Text style={styles.error}>{error}</Text> : null} </View> ); }); } FormField.propTypes = { label: PropTypes.string.isRequired, name: PropTypes.string.isRequired }; export default FormField;
FormField > withFormikControl > 子要素(WrappedComponent)
という構造になっているのがわかるでしょうか。
バリデーションエラーはフィールドを触っていなければ表示しないようにするために、touched
も見て表示を切り替えています。
また、スタイリングの話ですが、エラー表示によって下の要素がガタッとずれないようにposition: "absolute"
やpadding
によって調整しています。ちょっと分かりにくいかもしれないので、一番シンプルなテキスト入力フィールドの例を先に載せます。
テキスト入力コンポーネントを作成
Input.jsimport React from "react"; import * as PropTypes from "prop-types"; import { TextInput } from "react-native"; import colors from "../constants/colors"; import FormField from "./FormField"; const style = { width: "100%", height: 50, paddingHorizontal: 10, borderWidth: 1, borderColor: colors.border, borderRadius: 4, fontSize: 16 }; function Input(props) { const { placeholder, value, setFieldValue, setFieldTouched } = props; return ( <TextInput style={style} placeholder={placeholder} autoCapitalize="none" onChangeText={setFieldValue} onBlur={() => setFieldTouched(true)} value={value} /> ); } Input.defaultProps = { placeholder: "", value: "" }; Input.propTypes = { placeholder: PropTypes.string, value: PropTypes.string, setFieldValue: PropTypes.func.isRequired, setFieldTouched: PropTypes.func.isRequired }; export default FormField(Input);フォーム内に追加するときはこのような感じになります。
App.js... {({ handleSubmit, isValid, isSubmitting }) => ( <> <Input label="氏名" name="name" placeholder="氏名を入力してください" /> ...
Input.js
自体は普通っぽいですが、最後の行でFormField
関数によってコンポーネントをラップしています。
App.js
の例を見て分かる通り、このInput
コンポーネント自体に明示的に渡すprops
はlabel
とname
、placeholder
のみ(もちろん、style
やdisabled
など適宜追加してOKですし、propsをそのまま全てdeconstructionして渡すのでもOK)です。
label
はFormField
関数内でタイトルとして表示されるために利用され、name
はwithFormikControl
を通してFormikにおいてどのフィールドの要素なのかを特定するために使われます。そして、placeholder
は最終的にInput.js
で定義した子要素に伝わります。
Input.js
でJSXを返している部分を見てみると、props
にvalue
,setFieldValue
,setFieldTouched
といった値が入っていて、それを使ってvalue
やtouched
の変更していることが分かるかと思います。これらの値はwithFormikControl
を通したことで追加されたものです。さて、これでFormikをReact Nativeで扱うときの問題はスッキリ解決しそうです。
タイトルやエラー表示を共通化しつつ、スタイリング自由なテキスト入力フィールドができました。ここで一旦ちょっと脇道にそれる気がしますが、入力中にキーボードが表示されている時に、ビューの他の部分をタップしたらキーボードを閉じる処理を追加します。これはHTMLと違って自分で処理しなければいけません。
ページ全体を
TouchableWithoutFeedback
で囲み、onPress
(あるいはonPressIn
でも)でKeyboard.dismiss
メソッドを呼ぶようにしておきます。App.jsimport { ... TouchableWithoutFeedback, Keyboard, ... } from "react-native"; ... <ScrollView contentContainerStyle={styles.container}> <TouchableWithoutFeedback onPress={Keyboard.dismiss}> // ここ <View style={styles.form}> // 以下Formik ...ラジオボタンを作成
さて、ここからは単純にそれぞれのコンポーネントを作っていきます。
あとあとデザインの変更やアニメーションの調整など容易にするために、(使えるライブラリは使いつつも)あまり大したことをしていないUIコンポーネント類はなるべく使わないという方向でいきます。RadioButton.jsimport React from "react"; import * as PropTypes from "prop-types"; import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; import colors from "../constants/colors"; import FormField from "./FormField"; const styles = StyleSheet.create({ container: { flexDirection: "row" }, option: { padding: 10, marginLeft: -10, marginRight: 30, flexDirection: "row", alignItems: "center" }, label: { fontSize: 16 }, labelActive: { color: colors.accent }, icon: { width: 20, height: 20, marginLeft: 10, borderWidth: 1, borderRadius: 10, borderColor: colors.border, justifyContent: "center", alignItems: "center" }, iconActive: { borderColor: colors.accent }, iconInner: { width: 10, height: 10, backgroundColor: colors.accent, borderRadius: 5 } }); function RadioButton(props) { const { options, value, setFieldValue } = props; return ( <View style={styles.container}> {options.map(option => { const active = option.value === value; return ( <TouchableOpacity disabled={active} key={option.value} onPress={() => { setFieldTouched(true); setFieldValue(option.value); }} > <View style={[styles.option, active && styles.active]}> <Text style={[styles.label, active && styles.labelActive]}> {option.label} </Text> <View style={[styles.icon, active && styles.iconActive]}> {active && <View style={styles.iconInner} />} </View> </View> </TouchableOpacity> ); })} </View> ); } RadioButton.defaultProps = { value: null }; RadioButton.propTypes = { value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), options: PropTypes.arrayOf( PropTypes.shape({ label: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) .isRequired }) ).isRequired, setFieldValue: PropTypes.func.isRequired, setFieldTouched: PropTypes.func.isRequired }; export default FormField(RadioButton);ラジオボタンは、表示名と値を持つ選択肢の配列を受け取り現在の値によって表示を切り替える単純な要素です。
Formikと連携するため、何かしら選択したときにsetFieldTouched
にtrue
を渡してFormikのtouched
を変更します。これらの処理は既成のUIコンポーネントを使用しても構造的に同じになります。App.js// 選択肢の定義 const genderOptions = [ { label: "男性", value: 0 }, { label: "女性", value: 1 } ]; // Yupのスキーマを定義(値は数値にしてみます。他の値を取らないよう制限も追加。) const schema = Yup.object().shape({ ... gender: Yup.number() .oneOf( genderOptions.map(option => option.value), "性別を選択して下さい" ) .required("性別を選択して下さい"), ... }); // フォーム内に追加 <RadioButton label="性別" name="gender" options={genderOptions} />これでシンプルなラジオボタンができました。
チェックボタンを作成
CheckBox.jsimport React from "react"; import * as PropTypes from "prop-types"; import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; import Icon from "@expo/vector-icons/Ionicons"; import colors from "../constants/colors"; import FormField from "./FormField"; const styles = StyleSheet.create({ container: { padding: 10, marginLeft: -10, marginTop: -10, marginBottom: -10, marginRight: -10, flexDirection: "row", alignItems: "center" }, box: { width: 30, height: 30, marginRight: 10, borderWidth: 1, borderRadius: 4, borderColor: colors.border, justifyContent: "center", alignItems: "center" }, checked: { backgroundColor: colors.accent, borderColor: colors.accent }, checkedInner: { width: 30, height: 30, textAlign: "center" } }); function CheckBox(props) { const { title, value, setFieldValue, setFieldTouched } = props; return ( <TouchableOpacity onPress={() => { setFieldTouched(true); setFieldValue(!value); }} > <View style={styles.container}> <View style={[styles.box, value && styles.checked]}> {value && ( <Icon name="ios-checkmark" size={30} color={colors.white} style={styles.checkedInner} /> )} </View> <Text style={styles.title}>{title}</Text> </View> </TouchableOpacity> ); } CheckBox.propTypes = { title: PropTypes.string.isRequired }; export default FormField(CheckBox);こちらも非常にシンプルに、真偽値に応じて見た目を変えればOKです。
上記のラジオボタンの場合は一回タップしたら何かしら選択せざるをえないためrequiredエラーが発生することはありませんが、こちらのチェックボックスの場合はYupでチェックを必要とするようにしてみます。
App.jsconst schema = Yup.object().shape({ ... terms: Yup.bool() .oneOf([true], "同意が必要です"), ... }); <CheckBox label="同意事項" title="同意する" name="terms" />チェックを外すと、
touched
がtrueになっているのでエラー文言が表示されます。(FormField.js
で実装した部分)
トグルスイッチ(Switch)を作成
トグルスイッチは各OS標準のものをそのまま使ってみます。
Switch.jsimport React from "react"; import { Switch as RNSwitch, View, StyleSheet } from "react-native"; import FormField from "./FormField"; import colors from "../constants/colors"; const styles = StyleSheet.create({ container: { alignItems: "flex-start" } }); function Switch(props) { const { value, setFieldValue, setFieldTouched } = props; return ( <View style={styles.container}> <RNSwitch value={value} ios_backgroundColor={colors.lightGray} thumbColor={colors.white} trackColor={{ true: colors.accent, false: colors.lightGray }} onValueChange={newValue => { setFieldTouched(true); setFieldValue(newValue); }} /> </View> ); } export default FormField(Switch);処理の構造はチェックボックスと同様になります。
カスタマイズできるのは色のみですが、OS標準とあっておかしなところが無いので使いやすそうです。
各OS見た目が違い、このような感じになります。色を指定しない場合の標準デザインはこちら
選択UIを作成
通常iOSではドラムロールで表示されるPickerを使用しますが、ここではドロップダウン型のものを作ってみます。
少しだけ複雑なコンポーネントになるため、必要とあれば既成のUIコンポーネント類を使用したいのですが、ことドロップダウンに関してはあまり需要がないのか、良いものが見つかりませんでした。(参考:https://qiita.com/zaburo/items/7e2d2f0f6b9317a7789e)車輪の再発明かもしれませんが、それほど難しいものではないので自作してみます。
まず全体のソースはこちらSelect.jsimport React, { PureComponent } from "react"; import * as PropTypes from "prop-types"; import { Dimensions, FlatList, Modal, Text, View, StyleSheet, TouchableWithoutFeedback, TouchableOpacity } from "react-native"; import Icon from "@expo/vector-icons/Ionicons"; import colors from "../constants/colors"; import FormField from "./FormField"; const styles = StyleSheet.create({ container: { position: "relative", width: "100%" }, current: { width: "100%", height: 50, lineHeight: 50, justifyContent: "center", paddingHorizontal: 10, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.white, borderRadius: 4, fontSize: 16 }, arrow: { position: "absolute", top: 13, right: 16 }, option: { width: "100%", height: 49, lineHeight: 49, justifyContent: "center", paddingHorizontal: 10, backgroundColor: colors.white, fontSize: 16 }, separator: { width: "100%", height: 1, backgroundColor: colors.border }, modal: { width: "100%", height: "100%" }, modalBg: { position: "absolute", width: "100%", height: "100%", top: 0, left: 0 }, modalInner: { position: "absolute", height: "100%", zIndex: 1, borderRadius: 4, borderWidth: 1, borderColor: colors.border, shadowColor: colors.black, shadowOpacity: 0.3, shadowRadius: 3, shadowOffset: { width: 2, height: 2 }, elevation: 4 }, list: { backgroundColor: colors.white, borderRadius: 4 } }); class Select extends PureComponent { state = { active: false }; /** * ドロップダウンを表示 */ open = () => { // 絶対座標で表示するために要素の位置・サイズを取得 this.currentComponent.measureInWindow((x, y, width, height) => { const { maxHeight, minHeight, options } = this.props; const windowHeight = Dimensions.get("window").height; let modalY = y; const modalMinHeight = minHeight ? Math.min(options.length * height, minHeight) : null; let modalMaxHeight = Math.min(windowHeight - y, maxHeight); if (modalMinHeight > modalMaxHeight) { // 選択肢が下に見切れる場合は上向きに表示する modalMaxHeight = Math.min(y + height, maxHeight); modalY = y + height - modalMaxHeight; } this.setState({ active: true, x, y: modalY, width, height, minHeight: modalMinHeight, maxHeight: modalMaxHeight }); }); }; /** * ドロップダウンを非表示 */ dismiss = () => { const { setFieldTouched } = this.props; setFieldTouched(true); this.setState({ active: false }); }; render() { const { active, x, y, width, height, minHeight, maxHeight } = this.state; const { value, options, placeholder, setFieldValue } = this.props; const selectedOption = options[value]; return ( <View style={styles.container}> <TouchableOpacity onPress={this.open}> <View> <Text ref={component => { this.currentComponent = component; }} style={styles.current} suppressHighlighting > {selectedOption ? selectedOption.label : placeholder} </Text> <Icon name="ios-arrow-down" color={colors.border} size={24} style={styles.arrow} /> </View> </TouchableOpacity> <Modal visible={active} transparent={true}> <View style={styles.modal}> <TouchableWithoutFeedback onPressIn={this.dismiss}> <View style={styles.modalBg} /> </TouchableWithoutFeedback> <View style={[ styles.modalInner, { left: x, top: y, width: width, minHeight, maxHeight } ]} > <FlatList data={options} ItemSeparatorComponent={() => <View style={styles.separator} />} keyExtractor={item => item.label} initialScrollIndex={value} getItemLayout={(data, index) => ({ length: height, offset: height * index, index })} style={styles.list} renderItem={({ item, index }) => ( <TouchableOpacity onPress={() => { this.dismiss(); setFieldValue(index); }} > <Text style={styles.option} suppressHighlighting> {item.label} </Text> </TouchableOpacity> )} /> </View> </View> </Modal> </View> ); } } Select.defaultProps = { selectedIndex: -1, maxHeight: 225, minHeight: 125, placeholder: "選択してください" }; Select.propTypes = { value: PropTypes.number, options: PropTypes.arrayOf( PropTypes.shape({ label: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) .isRequired }) ).isRequired, placeholder: PropTypes.string, maxHeight: PropTypes.number }; export default FormField(Select);現在の値、あるいはプレースホルダーを表示しておき、タップしたら選択肢を表示すればよいわけです。
ここで考慮するのは、
- React NativeのModalを使用する(FlatListをそのまま使うと、ページ自体のスクロールと二重になってしまうため)
- 選択フィールドの位置・サイズを取得して同じ絶対位置に選択肢を表示する
- 選択肢が画面の下に見切れてしまわないように高さを調整する
- propsでセットした最小の高さより小さくなりそうな場合は上向きに表示させる
といったところです。
propsの形式などはラジオボタンの時と合わせて、都道府県の選択フィールドを作ってみます。
prefectures.jsexport default [ { value: 1, label: "北海道" }, ... ];選択肢のデータを用意しておき、
App.jsconst schema = Yup.object().shape({ ... prefecture: Yup.number() .oneOf( prefectures.map(option => option.value), "地域を選択してください" ) .nullable() .required("地域を選択してください"), ... });ラジオボタンと同じく念の為選択肢の値以外のものが入らないようにしておきます。
また、ラジオボタンと違い一度選択肢を表示してから何も選ばず閉じる場合もあるため、選択していない場合の値null
が入っても型が違うというエラーが出ないようスキーマを定義(nullable
)しておきます。App.js<Select label="お住いの地域" name="prefecture" options={prefectures} />コンポーネントを使うところはラジオボタンと同じです。
ファイル入力(画像)を作成
画像をカメラロールから選択するフィールドを作成します。
コンポーネント側ではボタンと、expo-image-picker
の処理、サムネイル表示を用意し、expo-image-picker
で取得した一時ファイルのURLを値としてFormikに渡すようにします。
サムネイルはローディング時の表示などよしなにやってくれるreact-native-elements
のAvatar
を流用してみます。ImageSelect.jsimport React from "react"; import * as PropTypes from "prop-types"; import { View, StyleSheet } from "react-native"; import * as ImagePicker from "expo-image-picker"; import { Avatar } from "react-native-elements"; import Button from "./Button"; import FormField from "./FormField"; const styles = StyleSheet.create({ container: { width: "100%" }, thumbnail: { marginBottom: 8 } }); function ImageSelect(props) { const { title, value, setFieldValue, setFieldTouched } = props; async function select() { const { status } = await ImagePicker.requestCameraRollPermissionsAsync(); if (status === "granted") { const { cancelled, uri } = await ImagePicker.launchImageLibraryAsync(); setFieldTouched(true); if (!cancelled && uri) { setFieldValue(uri); } } } return ( <View style={styles.container}> <Avatar containerStyle={styles.thumbnail} source={value ? { uri: value } : null} size="large" /> <Button title={title} onPress={select} /> </View> ); } ImageSelect.defaultProps = { title: "画像を選択" }; ImageSelect.propTypes = { title: PropTypes.string }; export default FormField(ImageSelect);App.jsconst schema = Yup.object().shape({ ... image: Yup.string() .nullable() .required("画像が必要です"), ... }); ... <ImageSelect label="アカウント画像" name="image" /> ...と、ここまでは簡単なのですが、
expo-image-picker
で取得した画像URLはアプリの起動中に一時的にキャッシュされている画像ファイルのURLなので、後ほど送信する際はこれをダウンロードしてから保存する必要があります。その部分は後述するとして、最後に日付選択フィールドを作成します。
日付選択を作成
React Nativeではまず標準のものが (https://github.com/react-native-community/react-native-datetimepicker) 用意されていますが、iOSではページ内にそのまま表示されてしまい使い勝手が悪いため、これをモーダル内に表示するreact-native-modal-datetime-pickerを使用します。
Githubの説明にしたがって最新のパッケージをインストールしてください。
また、Date型を扱いやすくするためmoment
もインストールします。$ npm i react-native-modal-datetime-picker@8.x.x @react-native-community/datetimepicker moment
コンポーネントではプレースホルダー、あるいは現在選択されている日付をタイトルとしたボタンを表示し、適宜
isVisible
の切り替えを行います。
今回は時間はなく日付のみ選択させる想定なので、Formikで管理する値は日付の最初の時刻(0時00分0秒0)を取るようにします。(moment
のstartOfDay
を利用)DatePicker.jsimport React, { useState } from "react"; import * as PropTypes from "prop-types"; import moment from "moment"; import Button from "./Button"; import DateTimePickerModal from "react-native-modal-datetime-picker"; import FormField from "./FormField"; function DatePicker(props) { const { value, title, placeholder, setFieldValue, setFieldTouched } = props; const [active, setActive] = useState(false); const open = () => { setActive(true); }; const dismiss = () => { setActive(false); setFieldTouched(true); }; return ( <> <DateTimePickerModal isVisible={active} date={ value || moment() .startOf("day") .toDate() } onConfirm={date => { dismiss(); setFieldValue(moment(date).startOf('day').toDate()); }} headerTextIOS={title} cancelTextIOS="キャンセル" confirmTextIOS="OK" onCancel={dismiss} locale="ja" /> <Button title={value ? moment(value).format("YYYY年MM月DD日") : placeholder} onPress={open} /> </> ); } DatePicker.defaultProps = { title: "日付を選択してください", placeholder: "日付を選択してください" }; DatePicker.propTypes = { title: PropTypes.string, placeholder: PropTypes.string }; export default FormField(DatePicker);App.jsconst schema = Yup.object().shape({ ... date: Yup.date() .nullable() .required("日付を選択してください") ... }); <DatePicker label="日付" title="日付を選択" placeholder="日付を選択" name="date" />YupではDate型も使用できます。
Androidはカレンダー式の表示です。タイトルは表示されません。
Firestore / Firebase Storageに保存
最後に、入力した値をFirebaseに送信して保存する例を実装してみたいと思います。
最初に載せたソースの時点で送信ボタンをタップしたらonSubmit
メソッドが呼ばれるようにしてあるので、そちらに書いていきます。App.jsimport firebase from 'firebase'; import 'firebase/firestore'; const db = firebase.firestore(); ... onSubmit = async (values, actions) => { try { await db.collection("/members").add(values); actions.resetForm(); Alert.alert("送信できました"); } catch (error) { Alert.alert("送信に失敗しました", error.message); } }; ...単純な例ですが、これでFormikから渡されたvaluesをシンプルに
members
コレクションのドキュメントとして登録することができます。
Formikから第二引数にFormikBagオブジェクトが入り、setSubmitting
で送信状態を操作したり、値をリセットしたりすることができます。
isSubmitting
を見て送信中はボタンがdisabledになるようにしていますが、FormikのonSubmit
に渡した関数がPromiseを返す(この例ではasync function)場合には自動的にisSubmitting
がセットされるので、明示的にsetSubmitting
する必要はありません。この例では、送信が完了したらフォームをリセットするようにのみ処理しています。先ほど作成したようなファイル入力フィールドを使用している場合は、もう一手間必要です。
Firebaseの設定の方でStorageを有効にしてから、画像をfetch→Blob化してStorageに保存→Storage上のURLを取得という流れで画像を保存しFirestoreのドキュメントに紐付けます。App.jsonSubmit = async (values, actions) => { try { const localImageUrl = values.image; // 画像URL(キャッシュ)を保持 delete values.image; // Firestoreに最初に保存する際は画像URL(キャッシュ)を削除 const docRef = await db.collection("/members").add(values); const { imageUrl } = await this.submitImage(localImageUrl, docRef.id); await docRef.update({ imageUrl }); // 画像URLを登録 actions.resetForm(); Alert.alert("送信できました"); } catch (error) { Alert.alert("送信に失敗しました", error.message); } }; // 画像を保存する submitImage = async (localImageUrl, memberId) => { const imagePath = `members/${memberId}.jpg`; // 画像の位置を決める(とりあえずmemberのidを使う) const imageRef = storage.ref().child(imagePath); // refを作成 const imageResponse = await fetch(localImageUrl); // キャッシュから画像ファイルをダウンロード const blob = await imageResponse.blob(); // Blob化 const snapshot = await imageRef.put(blob); // Storageに保存 const imageUrl = await imageRef.getDownloadURL(); // URLを取得 return { imageUrl, snapshot }; };作成された
members
のドキュメントのimageUrl
フィールドにStorage上のURLが保存されていれば成功です。未掲載のソース
最終的にApp.jsはこんな感じになりました。
App.jsimport React, { Component } from "react"; import { StyleSheet, TouchableWithoutFeedback, Keyboard, View, ScrollView, Alert } from "react-native"; import { Formik } from "formik"; import * as Yup from "yup"; import { db, storage } from "./app/utils/firebase"; import Select from "./app/components/Select"; import Input from "./app/components/Input"; import ImageSelect from "./app/components/ImageSelect"; import RadioButton from "./app/components/RadioButton"; import Button from "./app/components/Button"; import CheckBox from "./app/components/CheckBox"; import Switch from "./app/components/Switch"; import DatePicker from "./app/components/DatePicker"; import prefectures from "./app/constants/prefectures"; const genderOptions = [ { label: "男性", value: 0 }, { label: "女性", value: 1 } ]; const schema = Yup.object().shape({ name: Yup.string() .min(3, "3文字以上で入力してください") .max(20, "20文字以内で入力してください") .required("氏名を入力してください"), gender: Yup.number() .oneOf( genderOptions.map(option => option.value), "性別を選択して下さい" ) .required("性別を選択して下さい"), prefecture: Yup.number() .oneOf( prefectures.map(option => option.value), "地域を選択してください" ) .nullable() .required("地域を選択してください"), terms: Yup.bool().oneOf([true], "同意が必要です"), notification: Yup.bool(), image: Yup.string() .nullable() .required("画像が必要です"), date: Yup.date() .nullable() .required("日付を選択してください") }); const styles = StyleSheet.create({ container: { width: "100%", padding: 24, backgroundColor: "#fff", alignItems: "center", justifyContent: "center" }, form: { width: "100%" } }); export default class App extends Component { onSubmit = async (values, actions) => { try { const localImageUrl = values.image; delete values.image; const docRef = await db.collection("/members").add(values); const { imageUrl } = await this.submitImage(localImageUrl, docRef.id); await docRef.update({ imageUrl }); actions.resetForm(); Alert.alert("送信できました"); } catch (error) { Alert.alert("送信に失敗しました", error.message); } }; submitImage = async (localImageUrl, memberId) => { const imagePath = `members/${memberId}.jpg`; const imageRef = storage.ref().child(imagePath); const imageResponse = await fetch(localImageUrl); const blob = await imageResponse.blob(); const snapshot = await imageRef.put(blob); const imageUrl = await imageRef.getDownloadURL(); return { imageUrl, snapshot }; }; render() { return ( <ScrollView contentContainerStyle={styles.container}> <TouchableWithoutFeedback onPress={Keyboard.dismiss}> <View style={styles.form}> <Formik initialValues={{ name: "", gender: null, prefecture: null, terms: false, notification: false, date: null, image: null }} validateOnMount validationSchema={schema} onSubmit={this.onSubmit} > {({ handleSubmit, isValid, isSubmitting }) => ( <> <Input label="氏名" name="name" placeholder="氏名を入力してください" /> <RadioButton label="性別" name="gender" options={genderOptions} /> <Select label="お住いの地域" name="prefecture" options={prefectures} /> <CheckBox label="同意事項" title="同意する" name="terms" /> <Switch label="通知" name="notification" /> <ImageSelect label="アカウント画像" name="image" /> <DatePicker label="日付" title="日付を選択" placeholder="日付を選択" name="date" /> <Button title="Submit" onPress={handleSubmit} disabled={!isValid || isSubmitting} /> </> )} </Formik> </View> </TouchableWithoutFeedback> </ScrollView> ); } }app/utils/firebase.jsimport firebase from "firebase"; import "firebase/firestore"; import "firebase/storage"; firebase.initializeApp(...); // ここは各自のプロジェクトで export const db = firebase.firestore(); export const storage = firebase.storage(); export default firebase;ボタンもデザインを共通化できるよう
react-native-elements
のボタンをラップしています。Button.jsimport React from "react"; import { StyleSheet } from "react-native"; import { Button as RNButton } from "react-native-elements"; import colors from "../constants/colors"; const styles = StyleSheet.create({ container: { width: "100%" }, button: { height: 50, borderColor: 4, backgroundColor: colors.accent } }); function Button(props) { const { title, disabled, loading, onPress } = props; return ( <RNButton title={title} disabled={disabled} loading={loading} containerStyle={styles.container} buttonStyle={styles.button} onPress={onPress} /> ); } export default Button;app/constants/colors.jsexport default { white: "#FFFFFF", black: "#000000", text: "#212121", border: "#BBBBBB", accent: "#3D5AFE", lightGray: "#DDDDDD", red: "#F50057" };
- 投稿日:2019-12-22T21:05:45+09:00
ReactNativeで基本的なフォームを作成する
React Nativeで基本的なフォーム画面を作成する手法を模索したのでまとめてみます。ラジオボタンやドロップダウン選択など、アプリにあまり無いようなものも使ってフォームを作成する際、悩ましいのがこういう点だと思います。
- UIコンポーネント類はいくつかあるが、色々な種類のフォームフィールドを扱うためのスタンダードなライブラリが無い
- それらをつぎはぎするとデザインが統一しにくくなる
- デザインのカスタマイズをある程度できるようにしたい
- そもそもチェックボックスやラジオボタンなどにライブラリを使う必要を感じない(でもなるべく煩雑にならないようにしたい)
- Formik等にstate管理を任せてYupでバリデーションしたい、でもFormikとReact Nativeの相性があまりよくない
これらをなるべく解決したいと思います。
デザインのカスタマイズ性と既成のUIコンポーネント類を使う手軽さとどちらを取るかは人によって考え方が異なると思いますが、使いたい全ての機能をサポートするような既成のものがなかったため、この記事では自作している部分が多くなりました。対応するフォーム用コンポーネント
- テキスト入力
- ラジオボタン
- チェックボックス
- トグルスイッチ
- ドロップダウン
- ファイル入力
- 日付選択
Formik / Yup を導入
最終的なソースを最後に載せますが、順を追って要素を追加していきます。
App.jsimport React, { Component } from "react"; import { StyleSheet, Text, View, ScrollView, TextInput } from "react-native"; import { Formik } from "formik"; import * as Yup from "yup"; const schema = Yup.object().shape({ name: Yup.string() .min(3, "3文字以上で入力してください") .max(20, "20文字以内で入力してください") .required("氏名を入力してください") }); const styles = StyleSheet.create({ container: { width: "100%", padding: 24, backgroundColor: "#fff", alignItems: "center", justifyContent: "center" }, form: { width: "100%" } }); export default class App extends Component { onSubmit = async (values, actions) => { // データ送信 }; render() { return ( <ScrollView contentContainerStyle={styles.container}> <View style={styles.form}> <Formik initialValues={{ name: "" }} validateOnMount validationSchema={schema} onSubmit={this.onSubmit} > {({ handleSubmit, handleChange, handleBlur, isValid, isSubmitting, values, errors, touched }) => ( <> <View> {errors.name && touched.name ? <Text>{errors.name}</Text> : null} <TextInput value={values.name} onChangeText={handleChange('name')} onBlur={handleBlur('name')} placeholder="氏名を入力してください" /> </View> <Button title="Submit" onPress={handleSubmit} disabled={!isValid || isSubmitting} /> </> )} </Formik> </View> </ScrollView> ); } }大まかな流れとしてはHTMLと一緒ですが、HTMLでFormikを使用する場合は
<Form />
や<Field />
などのコンポーネントをそのまま使って簡潔に書けるのに対して、React NativeでFormikを使う際は一手間必要です。
エラーの表示や入力イベントのハンドリングなどを<Formik />
の子要素として渡すfunctionの引数(errors
,handleChange
等)から自分で処理します。また、Formikはテキスト入力以外のフィールドからは通常HTMLのイベントを利用して値などを取得しているため、React Nativeで文字列以外の値を持つ入力フィールドを普通に作ろうとすると以下のようなエラーが出ると思います。
TypeError: undefined is not an object (evaluating 'target.type')そこで、これらを解決するためというのと、複数種類の入力フィールドのデザインを統一するために、各フィールドをラップするコンポーネントを作ってみます。
FormFieldコンポーネントを作成
まずはHTMLに依存している部分を避けてFormikをReact Nativeで使うために、
react-native-formik
というヘルパーをインストールし、withFormikControl
関数を使ってみます。これは引数に与えられたコンポーネントのpropsにFormikの状態やセッター関数を渡してくれる高階functional componentだと認識しておけば大丈夫です。ちなみに実装自体はシンプルなのですが、npmで
Unpacked Size
が2.4 MBとあったので何事かと思ったら、サンプル画像の容量が大きいだけでした。さて、この
withFormikControl
を使ってコンポーネントをラップしますが、せっかくなので下記のように、フィールドのタイトルやエラーの処理、デザインなどを統一するためのさらに高階のコンポーネント(FormField
)を作っておきます。FormField.jsimport React from "react"; import * as PropTypes from "prop-types"; import { View, Text, StyleSheet } from "react-native"; import { withFormikControl } from "react-native-formik"; import colors from "../constants/colors"; const styles = StyleSheet.create({ container: { width: "100%", marginVertical: 12, paddingBottom: 24 }, label: { fontSize: 16, marginBottom: 10 }, error: { position: "absolute", color: colors.red, fontSize: 12, bottom: 0 } }); function FormField(WrappedComponent) { return withFormikControl(function(props) { const { label, error, touched } = props; return ( <View style={styles.container}> <Text style={styles.label}>{label}</Text> <WrappedComponent {...props} /> {error && touched ? <Text style={styles.error}>{error}</Text> : null} </View> ); }); } FormField.propTypes = { label: PropTypes.string.isRequired, name: PropTypes.string.isRequired }; export default FormField;
FormField > withFormikControl > 子要素(WrappedComponent)
という構造になっているのがわかるでしょうか。
バリデーションエラーはフィールドを触っていなければ表示しないようにするために、touched
も見て表示を切り替えています。
また、スタイリングの話ですが、エラー表示によって下の要素がガタッとずれないようにposition: "absolute"
やpadding
によって調整しています。ちょっと分かりにくいかもしれないので、一番シンプルなテキスト入力フィールドの例を先に載せます。
テキスト入力コンポーネントを作成
Input.jsimport React from "react"; import * as PropTypes from "prop-types"; import { TextInput } from "react-native"; import colors from "../constants/colors"; import FormField from "./FormField"; const style = { width: "100%", height: 50, paddingHorizontal: 10, borderWidth: 1, borderColor: colors.border, borderRadius: 4, fontSize: 16 }; function Input(props) { const { placeholder, value, setFieldValue, setFieldTouched } = props; return ( <TextInput style={style} placeholder={placeholder} autoCapitalize="none" onChangeText={setFieldValue} onBlur={() => setFieldTouched(true)} value={value} /> ); } Input.defaultProps = { placeholder: "", value: "" }; Input.propTypes = { placeholder: PropTypes.string, value: PropTypes.string, setFieldValue: PropTypes.func.isRequired, setFieldTouched: PropTypes.func.isRequired }; export default FormField(Input);フォーム内に追加するときはこのような感じになります。
App.js... {({ handleSubmit, isValid, isSubmitting }) => ( <> <Input label="氏名" name="name" placeholder="氏名を入力してください" /> ...
Input.js
自体は普通っぽいですが、最後の行でFormField
関数によってコンポーネントをラップしています。
App.js
の例を見て分かる通り、このInput
コンポーネント自体に明示的に渡すprops
はlabel
とname
、placeholder
のみ(もちろん、style
やdisabled
など適宜追加してOKですし、propsをそのまま全てdeconstructionして渡すのでもOK)です。
label
はFormField
関数内でタイトルとして表示されるために利用され、name
はwithFormikControl
を通してFormikにおいてどのフィールドの要素なのかを特定するために使われます。そして、placeholder
は最終的にInput.js
で定義した子要素に伝わります。
Input.js
でJSXを返している部分を見てみると、props
にvalue
,setFieldValue
,setFieldTouched
といった値が入っていて、それを使ってvalue
やtouched
の変更していることが分かるかと思います。これらの値はwithFormikControl
を通したことで追加されたものです。さて、これでFormikをReact Nativeで扱うときの問題はスッキリ解決しそうです。
タイトルやエラー表示を共通化しつつ、スタイリング自由なテキスト入力フィールドができました。ここで一旦ちょっと脇道にそれる気がしますが、入力中にキーボードが表示されている時に、ビューの他の部分をタップしたらキーボードを閉じる処理を追加します。これはHTMLと違って自分で処理しなければいけません。
ページ全体を
TouchableWithoutFeedback
で囲み、onPress
(あるいはonPressIn
でも)でKeyboard.dismiss
メソッドを呼ぶようにしておきます。App.jsimport { ... TouchableWithoutFeedback, Keyboard, ... } from "react-native"; ... <ScrollView contentContainerStyle={styles.container}> <TouchableWithoutFeedback onPress={Keyboard.dismiss}> // ここ <View style={styles.form}> // 以下Formik ...ラジオボタンを作成
さて、ここからは単純にそれぞれのコンポーネントを作っていきます。
あとあとデザインの変更やアニメーションの調整など容易にするために、(使えるライブラリは使いつつも)あまり大したことをしていないUIコンポーネント類はなるべく使わないという方向でいきます。RadioButton.jsimport React from "react"; import * as PropTypes from "prop-types"; import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; import colors from "../constants/colors"; import FormField from "./FormField"; const styles = StyleSheet.create({ container: { flexDirection: "row" }, option: { padding: 10, marginLeft: -10, marginRight: 30, flexDirection: "row", alignItems: "center" }, label: { fontSize: 16 }, labelActive: { color: colors.accent }, icon: { width: 20, height: 20, marginLeft: 10, borderWidth: 1, borderRadius: 10, borderColor: colors.border, justifyContent: "center", alignItems: "center" }, iconActive: { borderColor: colors.accent }, iconInner: { width: 10, height: 10, backgroundColor: colors.accent, borderRadius: 5 } }); function RadioButton(props) { const { options, value, setFieldValue } = props; return ( <View style={styles.container}> {options.map(option => { const active = option.value === value; return ( <TouchableOpacity disabled={active} key={option.value} onPress={() => { setFieldTouched(true); setFieldValue(option.value); }} > <View style={[styles.option, active && styles.active]}> <Text style={[styles.label, active && styles.labelActive]}> {option.label} </Text> <View style={[styles.icon, active && styles.iconActive]}> {active && <View style={styles.iconInner} />} </View> </View> </TouchableOpacity> ); })} </View> ); } RadioButton.defaultProps = { value: null }; RadioButton.propTypes = { value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), options: PropTypes.arrayOf( PropTypes.shape({ label: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) .isRequired }) ).isRequired, setFieldValue: PropTypes.func.isRequired, setFieldTouched: PropTypes.func.isRequired }; export default FormField(RadioButton);ラジオボタンは、表示名と値を持つ選択肢の配列を受け取り現在の値によって表示を切り替える単純な要素です。
Formikと連携するため、何かしら選択したときにsetFieldTouched
にtrue
を渡してFormikのtouched
を変更します。これらの処理は既成のUIコンポーネントを使用しても構造的に同じになります。App.js// 選択肢の定義 const genderOptions = [ { label: "男性", value: 0 }, { label: "女性", value: 1 } ]; // Yupのスキーマを定義(値は数値にしてみます。他の値を取らないよう制限も追加。) const schema = Yup.object().shape({ ... gender: Yup.number() .oneOf( genderOptions.map(option => option.value), "性別を選択して下さい" ) .required("性別を選択して下さい"), ... }); // フォーム内に追加 <RadioButton label="性別" name="gender" options={genderOptions} />これでシンプルなラジオボタンができました。
チェックボタンを作成
CheckBox.jsimport React from "react"; import * as PropTypes from "prop-types"; import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; import Icon from "@expo/vector-icons/Ionicons"; import colors from "../constants/colors"; import FormField from "./FormField"; const styles = StyleSheet.create({ container: { padding: 10, marginLeft: -10, marginTop: -10, marginBottom: -10, marginRight: -10, flexDirection: "row", alignItems: "center" }, box: { width: 30, height: 30, marginRight: 10, borderWidth: 1, borderRadius: 4, borderColor: colors.border, justifyContent: "center", alignItems: "center" }, checked: { backgroundColor: colors.accent, borderColor: colors.accent }, checkedInner: { width: 30, height: 30, textAlign: "center" } }); function CheckBox(props) { const { title, value, setFieldValue, setFieldTouched } = props; return ( <TouchableOpacity onPress={() => { setFieldTouched(true); setFieldValue(!value); }} > <View style={styles.container}> <View style={[styles.box, value && styles.checked]}> {value && ( <Icon name="ios-checkmark" size={30} color={colors.white} style={styles.checkedInner} /> )} </View> <Text style={styles.title}>{title}</Text> </View> </TouchableOpacity> ); } CheckBox.propTypes = { title: PropTypes.string.isRequired }; export default FormField(CheckBox);こちらも非常にシンプルに、真偽値に応じて見た目を変えればOKです。
上記のラジオボタンの場合は一回タップしたら何かしら選択せざるをえないためrequiredエラーが発生することはありませんが、こちらのチェックボックスの場合はYupでチェックを必要とするようにしてみます。
App.jsconst schema = Yup.object().shape({ ... terms: Yup.bool() .oneOf([true], "同意が必要です"), ... }); <CheckBox label="同意事項" title="同意する" name="terms" />チェックを外すと、
touched
がtrueになっているのでエラー文言が表示されます。(FormField.js
で実装した部分)
トグルスイッチ(Switch)を作成
トグルスイッチは各OS標準のものをそのまま使ってみます。
Switch.jsimport React from "react"; import { Switch as RNSwitch, View, StyleSheet } from "react-native"; import FormField from "./FormField"; import colors from "../constants/colors"; const styles = StyleSheet.create({ container: { alignItems: "flex-start" } }); function Switch(props) { const { value, setFieldValue, setFieldTouched } = props; return ( <View style={styles.container}> <RNSwitch value={value} ios_backgroundColor={colors.lightGray} thumbColor={colors.white} trackColor={{ true: colors.accent, false: colors.lightGray }} onValueChange={newValue => { setFieldTouched(true); setFieldValue(newValue); }} /> </View> ); } export default FormField(Switch);処理の構造はチェックボックスと同様になります。
カスタマイズできるのは色のみですが、OS標準とあっておかしなところが無いので使いやすそうです。
各OS見た目が違い、このような感じになります。色を指定しない場合の標準デザインはこちら
選択UIを作成
通常iOSではドラムロールで表示されるPickerを使用しますが、ここではドロップダウン型のものを作ってみます。
少しだけ複雑なコンポーネントになるため、必要とあれば既成のUIコンポーネント類を使用したいのですが、ことドロップダウンに関してはあまり需要がないのか、良いものが見つかりませんでした。(参考:https://qiita.com/zaburo/items/7e2d2f0f6b9317a7789e)車輪の再発明かもしれませんが、それほど難しいものではないので自作してみます。
まず全体のソースはこちらSelect.jsimport React, { PureComponent } from "react"; import * as PropTypes from "prop-types"; import { Dimensions, FlatList, Modal, Text, View, StyleSheet, TouchableWithoutFeedback, TouchableOpacity } from "react-native"; import Icon from "@expo/vector-icons/Ionicons"; import colors from "../constants/colors"; import FormField from "./FormField"; const styles = StyleSheet.create({ container: { position: "relative", width: "100%" }, current: { width: "100%", height: 50, lineHeight: 50, justifyContent: "center", paddingHorizontal: 10, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.white, borderRadius: 4, fontSize: 16 }, arrow: { position: "absolute", top: 13, right: 16 }, option: { width: "100%", height: 49, lineHeight: 49, justifyContent: "center", paddingHorizontal: 10, backgroundColor: colors.white, fontSize: 16 }, separator: { width: "100%", height: 1, backgroundColor: colors.border }, modal: { width: "100%", height: "100%" }, modalBg: { position: "absolute", width: "100%", height: "100%", top: 0, left: 0 }, modalInner: { position: "absolute", height: "100%", zIndex: 1, borderRadius: 4, borderWidth: 1, borderColor: colors.border, shadowColor: colors.black, shadowOpacity: 0.3, shadowRadius: 3, shadowOffset: { width: 2, height: 2 }, elevation: 4 }, list: { backgroundColor: colors.white, borderRadius: 4 } }); class Select extends PureComponent { state = { active: false }; /** * ドロップダウンを表示 */ open = () => { // 絶対座標で表示するために要素の位置・サイズを取得 this.currentComponent.measureInWindow((x, y, width, height) => { const { maxHeight, minHeight, options } = this.props; const windowHeight = Dimensions.get("window").height; let modalY = y; const modalMinHeight = minHeight ? Math.min(options.length * height, minHeight) : null; let modalMaxHeight = Math.min(windowHeight - y, maxHeight); if (modalMinHeight > modalMaxHeight) { // 選択肢が下に見切れる場合は上向きに表示する modalMaxHeight = Math.min(y + height, maxHeight); modalY = y + height - modalMaxHeight; } this.setState({ active: true, x, y: modalY, width, height, minHeight: modalMinHeight, maxHeight: modalMaxHeight }); }); }; /** * ドロップダウンを非表示 */ dismiss = () => { const { setFieldTouched } = this.props; setFieldTouched(true); this.setState({ active: false }); }; render() { const { active, x, y, width, height, minHeight, maxHeight } = this.state; const { value, options, placeholder, setFieldValue } = this.props; const selectedOption = options[value]; return ( <View style={styles.container}> <TouchableOpacity onPress={this.open}> <View> <Text ref={component => { this.currentComponent = component; }} style={styles.current} suppressHighlighting > {selectedOption ? selectedOption.label : placeholder} </Text> <Icon name="ios-arrow-down" color={colors.border} size={24} style={styles.arrow} /> </View> </TouchableOpacity> <Modal visible={active} transparent={true}> <View style={styles.modal}> <TouchableWithoutFeedback onPressIn={this.dismiss}> <View style={styles.modalBg} /> </TouchableWithoutFeedback> <View style={[ styles.modalInner, { left: x, top: y, width: width, minHeight, maxHeight } ]} > <FlatList data={options} ItemSeparatorComponent={() => <View style={styles.separator} />} keyExtractor={item => item.label} initialScrollIndex={value} getItemLayout={(data, index) => ({ length: height, offset: height * index, index })} style={styles.list} renderItem={({ item, index }) => ( <TouchableOpacity onPress={() => { this.dismiss(); setFieldValue(index); }} > <Text style={styles.option} suppressHighlighting> {item.label} </Text> </TouchableOpacity> )} /> </View> </View> </Modal> </View> ); } } Select.defaultProps = { selectedIndex: -1, maxHeight: 225, minHeight: 125, placeholder: "選択してください" }; Select.propTypes = { value: PropTypes.number, options: PropTypes.arrayOf( PropTypes.shape({ label: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) .isRequired }) ).isRequired, placeholder: PropTypes.string, maxHeight: PropTypes.number }; export default FormField(Select);現在の値、あるいはプレースホルダーを表示しておき、タップしたら選択肢を表示すればよいわけです。
ここで考慮するのは、
- React NativeのModalを使用する(FlatListをそのまま使うと、ページ自体のスクロールと二重になってしまうため)
- 選択フィールドの位置・サイズを取得して同じ絶対位置に選択肢を表示する
- 選択肢が画面の下に見切れてしまわないように高さを調整する
- propsでセットした最小の高さより小さくなりそうな場合は上向きに表示させる
といったところです。
propsの形式などはラジオボタンの時と合わせて、都道府県の選択フィールドを作ってみます。
prefectures.jsexport default [ { value: 1, label: "北海道" }, ... ];選択肢のデータを用意しておき、
App.jsconst schema = Yup.object().shape({ ... prefecture: Yup.number() .oneOf( prefectures.map(option => option.value), "地域を選択してください" ) .nullable() .required("地域を選択してください"), ... });ラジオボタンと同じく念の為選択肢の値以外のものが入らないようにしておきます。
また、ラジオボタンと違い一度選択肢を表示してから何も選ばず閉じる場合もあるため、選択していない場合の値null
が入っても型が違うというエラーが出ないようスキーマを定義(nullable
)しておきます。App.js<Select label="お住いの地域" name="prefecture" options={prefectures} />コンポーネントを使うところはラジオボタンと同じです。
ファイル入力(画像)を作成
画像をカメラロールから選択するフィールドを作成します。
コンポーネント側ではボタンと、expo-image-picker
の処理、サムネイル表示を用意し、expo-image-picker
で取得した一時ファイルのURLを値としてFormikに渡すようにします。
サムネイルはローディング時の表示などよしなにやってくれるreact-native-elements
のAvatar
を流用してみます。ImageSelect.jsimport React from "react"; import * as PropTypes from "prop-types"; import { View, StyleSheet } from "react-native"; import * as ImagePicker from "expo-image-picker"; import { Avatar } from "react-native-elements"; import Button from "./Button"; import FormField from "./FormField"; const styles = StyleSheet.create({ container: { width: "100%" }, thumbnail: { marginBottom: 8 } }); function ImageSelect(props) { const { title, value, setFieldValue, setFieldTouched } = props; async function select() { const { status } = await ImagePicker.requestCameraRollPermissionsAsync(); if (status === "granted") { const { cancelled, uri } = await ImagePicker.launchImageLibraryAsync(); setFieldTouched(true); if (!cancelled && uri) { setFieldValue(uri); } } } return ( <View style={styles.container}> <Avatar containerStyle={styles.thumbnail} source={value ? { uri: value } : null} size="large" /> <Button title={title} onPress={select} /> </View> ); } ImageSelect.defaultProps = { title: "画像を選択" }; ImageSelect.propTypes = { title: PropTypes.string }; export default FormField(ImageSelect);App.jsconst schema = Yup.object().shape({ ... image: Yup.string() .nullable() .required("画像が必要です"), ... }); ... <ImageSelect label="アカウント画像" name="image" /> ...と、ここまでは簡単なのですが、
expo-image-picker
で取得した画像URLはアプリの起動中に一時的にキャッシュされている画像ファイルのURLなので、後ほど送信する際はこれをダウンロードしてから保存する必要があります。その部分は後述するとして、最後に日付選択フィールドを作成します。
日付選択を作成
React Nativeではまず標準のものが (https://github.com/react-native-community/react-native-datetimepicker) 用意されていますが、iOSではページ内にそのまま表示されてしまい使い勝手が悪いため、これをモーダル内に表示するreact-native-modal-datetime-pickerを使用します。
Githubの説明にしたがって最新のパッケージをインストールしてください。
また、Date型を扱いやすくするためmoment
もインストールします。$ npm i react-native-modal-datetime-picker@8.x.x @react-native-community/datetimepicker moment
コンポーネントではプレースホルダー、あるいは現在選択されている日付をタイトルとしたボタンを表示し、適宜
isVisible
の切り替えを行います。
今回は時間はなく日付のみ選択させる想定なので、Formikで管理する値は日付の最初の時刻(0時00分0秒0)を取るようにします。(moment
のstartOfDay
を利用)DatePicker.jsimport React, { useState } from "react"; import * as PropTypes from "prop-types"; import moment from "moment"; import Button from "./Button"; import DateTimePickerModal from "react-native-modal-datetime-picker"; import FormField from "./FormField"; function DatePicker(props) { const { value, title, placeholder, setFieldValue, setFieldTouched } = props; const [active, setActive] = useState(false); const open = () => { setActive(true); }; const dismiss = () => { setActive(false); setFieldTouched(true); }; return ( <> <DateTimePickerModal isVisible={active} date={ value || moment() .startOf("day") .toDate() } onConfirm={date => { dismiss(); setFieldValue(moment(date).startOf('day').toDate()); }} headerTextIOS={title} cancelTextIOS="キャンセル" confirmTextIOS="OK" onCancel={dismiss} locale="ja" /> <Button title={value ? moment(value).format("YYYY年MM月DD日") : placeholder} onPress={open} /> </> ); } DatePicker.defaultProps = { title: "日付を選択してください", placeholder: "日付を選択してください" }; DatePicker.propTypes = { title: PropTypes.string, placeholder: PropTypes.string }; export default FormField(DatePicker);App.jsconst schema = Yup.object().shape({ ... date: Yup.date() .nullable() .required("日付を選択してください") ... }); <DatePicker label="日付" title="日付を選択" placeholder="日付を選択" name="date" />YupではDate型も使用できます。
Androidはカレンダー式の表示です。タイトルは表示されません。
Firestore / Firebase Storageに保存
最後に、入力した値をFirebaseに送信して保存する例を実装してみたいと思います。
最初に載せたソースの時点で送信ボタンをタップしたらonSubmit
メソッドが呼ばれるようにしてあるので、そちらに書いていきます。App.jsimport firebase from 'firebase'; import 'firebase/firestore'; const db = firebase.firestore(); ... onSubmit = async (values, actions) => { try { await db.collection("/members").add(values); actions.resetForm(); Alert.alert("送信できました"); } catch (error) { Alert.alert("送信に失敗しました", error.message); } }; ...単純な例ですが、これでFormikから渡されたvaluesをシンプルに
members
コレクションのドキュメントとして登録することができます。
Formikから第二引数にFormikBagオブジェクトが入り、setSubmitting
で送信状態を操作したり、値をリセットしたりすることができます。
isSubmitting
を見て送信中はボタンがdisabledになるようにしていますが、FormikのonSubmit
に渡した関数がPromiseを返す(この例ではasync function)場合には自動的にisSubmitting
がセットされるので、明示的にsetSubmitting
する必要はありません。この例では、送信が完了したらフォームをリセットするようにのみ処理しています。先ほど作成したようなファイル入力フィールドを使用している場合は、もう一手間必要です。
Firebaseの設定の方でStorageを有効にしてから、画像をfetch→Blob化してStorageに保存→Storage上のURLを取得という流れで画像を保存しFirestoreのドキュメントに紐付けます。App.jsonSubmit = async (values, actions) => { try { const localImageUrl = values.image; // 画像URL(キャッシュ)を保持 delete values.image; // Firestoreに最初に保存する際は画像URL(キャッシュ)を削除 const docRef = await db.collection("/members").add(values); const { imageUrl } = await this.submitImage(localImageUrl, docRef.id); await docRef.update({ imageUrl }); // 画像URLを登録 actions.resetForm(); Alert.alert("送信できました"); } catch (error) { Alert.alert("送信に失敗しました", error.message); } }; // 画像を保存する submitImage = async (localImageUrl, memberId) => { const imagePath = `members/${memberId}.jpg`; // 画像の位置を決める(とりあえずmemberのidを使う) const imageRef = storage.ref().child(imagePath); // refを作成 const imageResponse = await fetch(localImageUrl); // キャッシュから画像ファイルをダウンロード const blob = await imageResponse.blob(); // Blob化 const snapshot = await imageRef.put(blob); // Storageに保存 const imageUrl = await imageRef.getDownloadURL(); // URLを取得 return { imageUrl, snapshot }; };作成された
members
のドキュメントのimageUrl
フィールドにStorage上のURLが保存されていれば成功です。未掲載のソース
最終的にApp.jsはこんな感じになりました。
App.jsimport React, { Component } from "react"; import { StyleSheet, TouchableWithoutFeedback, Keyboard, View, ScrollView, Alert } from "react-native"; import { Formik } from "formik"; import * as Yup from "yup"; import { db, storage } from "./app/utils/firebase"; import Select from "./app/components/Select"; import Input from "./app/components/Input"; import ImageSelect from "./app/components/ImageSelect"; import RadioButton from "./app/components/RadioButton"; import Button from "./app/components/Button"; import CheckBox from "./app/components/CheckBox"; import Switch from "./app/components/Switch"; import DatePicker from "./app/components/DatePicker"; import prefectures from "./app/constants/prefectures"; const genderOptions = [ { label: "男性", value: 0 }, { label: "女性", value: 1 } ]; const schema = Yup.object().shape({ name: Yup.string() .min(3, "3文字以上で入力してください") .max(20, "20文字以内で入力してください") .required("氏名を入力してください"), gender: Yup.number() .oneOf( genderOptions.map(option => option.value), "性別を選択して下さい" ) .required("性別を選択して下さい"), prefecture: Yup.number() .oneOf( prefectures.map(option => option.value), "地域を選択してください" ) .nullable() .required("地域を選択してください"), terms: Yup.bool().oneOf([true], "同意が必要です"), notification: Yup.bool(), image: Yup.string() .nullable() .required("画像が必要です"), date: Yup.date() .nullable() .required("日付を選択してください") }); const styles = StyleSheet.create({ container: { width: "100%", padding: 24, backgroundColor: "#fff", alignItems: "center", justifyContent: "center" }, form: { width: "100%" } }); export default class App extends Component { onSubmit = async (values, actions) => { try { const localImageUrl = values.image; delete values.image; const docRef = await db.collection("/members").add(values); const { imageUrl } = await this.submitImage(localImageUrl, docRef.id); await docRef.update({ imageUrl }); actions.resetForm(); Alert.alert("送信できました"); } catch (error) { Alert.alert("送信に失敗しました", error.message); } }; submitImage = async (localImageUrl, memberId) => { const imagePath = `members/${memberId}.jpg`; const imageRef = storage.ref().child(imagePath); const imageResponse = await fetch(localImageUrl); const blob = await imageResponse.blob(); const snapshot = await imageRef.put(blob); const imageUrl = await imageRef.getDownloadURL(); return { imageUrl, snapshot }; }; render() { return ( <ScrollView contentContainerStyle={styles.container}> <TouchableWithoutFeedback onPress={Keyboard.dismiss}> <View style={styles.form}> <Formik initialValues={{ name: "", gender: null, prefecture: null, terms: false, notification: false, date: null, image: null }} validateOnMount validationSchema={schema} onSubmit={this.onSubmit} > {({ handleSubmit, isValid, isSubmitting }) => ( <> <Input label="氏名" name="name" placeholder="氏名を入力してください" /> <RadioButton label="性別" name="gender" options={genderOptions} /> <Select label="お住いの地域" name="prefecture" options={prefectures} /> <CheckBox label="同意事項" title="同意する" name="terms" /> <Switch label="通知" name="notification" /> <ImageSelect label="アカウント画像" name="image" /> <DatePicker label="日付" title="日付を選択" placeholder="日付を選択" name="date" /> <Button title="Submit" onPress={handleSubmit} disabled={!isValid || isSubmitting} /> </> )} </Formik> </View> </TouchableWithoutFeedback> </ScrollView> ); } }app/utils/firebase.jsimport firebase from "firebase"; import "firebase/firestore"; import "firebase/storage"; firebase.initializeApp(...); // ここは各自のプロジェクトで export const db = firebase.firestore(); export const storage = firebase.storage(); export default firebase;ボタンもデザインを共通化できるよう
react-native-elements
のボタンをラップしています。Button.jsimport React from "react"; import { StyleSheet } from "react-native"; import { Button as RNButton } from "react-native-elements"; import colors from "../constants/colors"; const styles = StyleSheet.create({ container: { width: "100%" }, button: { height: 50, borderColor: 4, backgroundColor: colors.accent } }); function Button(props) { const { title, disabled, loading, onPress } = props; return ( <RNButton title={title} disabled={disabled} loading={loading} containerStyle={styles.container} buttonStyle={styles.button} onPress={onPress} /> ); } export default Button;app/constants/colors.jsexport default { white: "#FFFFFF", black: "#000000", text: "#212121", border: "#BBBBBB", accent: "#3D5AFE", lightGray: "#DDDDDD", red: "#F50057" };
- 投稿日:2019-12-22T20:56:00+09:00
React-hook-formで簡単にバリデーションフォーム作る
Reactでフォームの実装をしたことのある、もしくはこれから実装する皆さん。
React-hook-formをご存知ですか?
フォームの実装がとても楽になる便利なライブラリです。この記事ではReact-hook-formの基本的な簡単な使い方と
実装例をソースコードとともに解説しています。React-hook-formとは?
高性能で柔軟かつ拡張可能な使いやすいフォームバリデーションライブラリ。(引用)
従来のformライブラリに比べて、以下の特徴があります。1
・記述量が少ない
・レンダリングが少ない
・マウントが高速
・hooksで記述がシンプルそして何より。。
バリデーションの実装が楽になります。使い方
それではReact-hook-formの簡単な使い方を見てみましょう。
以下は公式デモのソースコードです。import React from 'react' import useForm from 'react-hook-form' export default function App() { const { register, handleSubmit, watch, errors } = useForm() const onSubmit = data => { console.log(data) } console.log(watch('example')) return ( <form onSubmit={handleSubmit(onSubmit)}> <input name="example" defaultValue="test" ref={register} /> <input name="exampleRequired" ref={register({ required: true })} /> {errors.exampleRequired && <span>This field is required</span>} <input type="submit" /> </form> ) }React-hook-formでは必要なメソッドやオブジェクトをuseFormから受け取って使用します。
以下の手順で実装します。1. フィールドを登録する。
非制御コンポーネント (Uncontrolled Components) をフックに登録(register) し、フォームフィールドの値を検証と収集できるようにする(引用)登録したいフィールドに
name="uniqueName"
とref={register}
を加えます。<input name="example" defaultValue="test" ref={register} />2. バリデーションとエラー文言を設定する。
registerメソッドにバリデーションを渡し、
バリデーション時にエラーが発生するとerrorsオブジェクトに
先ほど加えたnameをkeyとしたエラーメッセージを割り当てられます。2<input name="exampleRequired" ref={register({ required: true })} /> {errors.exampleRequired && <span>This field is required</span>上記の例の
required
はバリデーションの際に必須入力を求めます。
バリデーションは上記の他に最大文字数、最小文字数なども設定でき、
さらに正規表現やバリデーション関数を渡すこともできます!実装例
では実際に以下のようなフォームを実装してみます。
・各フォームごとに入力後バリデーションする
・バリデーションエラーの場合はエラーメッセージを表示する
・全てのフォームが正しく入力されている場合のみsubmitボタンを押せるようにするhooksのみで実装してみると。。。(長いので読む必要なし)
import * as React from 'react'; interface FormData { title: string; author: string; } interface FormValidationResults { title: boolean; author: boolean; } interface ErrorMessage { title: string; author: string; } const SomeForms: React.FC = () => { const [values, setValues] = React.useState<FormData>({ title: '', author: '' }); const [validationResults, setValidationResults] = React.useState< FormValidationResults >({ title: false, author: false }); const [errorMessages, setErrorMessages] = React.useState<ErrorMessage>({ title: '', author: '' }); const handleChange = (name: keyof FormData) => ( event: React.ChangeEvent<HTMLTextAreaElement> ) => { const newValues = { ...values, [name]: event.target.value }; setValues(newValues); validate(newValues, name); }; const validate = (values: FormData, name: keyof FormValidationResults) => { switch (name) { case 'title': titleValidation(values[name]); break; case 'author': authorValidation(values[name]); break; } }; const titleValidation = (value: string): void => { if (value.length < 1 || value.length > 20) { setValidationResults({ ...validationResults, title: false }); setErrorMessages({ ...errorMessages, title: 'タイトル名は1文字以上、20文字以下でなければなりません。' }); } else { setValidationResults({ ...validationResults, title: true }); setErrorMessages({ ...errorMessages, title: '' }); } }; const authorValidation = (value: string): void => { if (value.length < 1 || value.length > 20) { setValidationResults({ ...validationResults, author: false }); setErrorMessages({ ...errorMessages, author: '作者名は1文字以上、20文字以下でなければなりません。' }); } else { setValidationResults({ ...validationResults, author: true }); setErrorMessages({ ...errorMessages, author: '' }); } }; return ( <div> <h2>タイトル名</h2> <textarea name='title' value={values.title} onChange={handleChange('title')} /> {errorMessages.title && <span>{errorMessages.title}</span>} <h2>作者名</h2> <textarea name='author' value={values.author} onChange={handleChange('author')} /> {errorMessages.author && <span>{errorMessages.author}</span>} <button disabled={ validationResults.title && validationResults.author ? false : true } > 送信する </button> </div> ); }; export default SomeForms;。。。長い。。改行があるとはいえ100行強あります。
useStateで以下を管理しています。。。長い。
・フィールドの値
・エラーメッセージ
・バリデーションがvalidかどうかReact-hook-formで実装
import * as React from 'react'; import useForm from 'react-hook-form'; interface FormData { title: string; author: string; } const OtherForms: React.FC<{}> = () => { const { register, handleSubmit, errors, formState } = useForm<FormData>({ mode: 'onChange' }); const onSubmit = (data: FormData): void => console.log(data); return ( <div> <h2>タイトル名</h2> <form onSubmit={handleSubmit(onSubmit)}> <textarea name='title' ref={register({ required: true, maxLength: 20 })} /> {errors.title && '作者名は1文字以上、20文字以下でなければなりません。'} <h2>作者名</h2> <textarea name='author' ref={register({ required: true, maxLength: 20 })} /> {errors.author && '作者名は1文字以上、20文字以下でなければなりません。'} <button disabled={!formState.isValid}>送信する</button> </form> </div> ); }; export default OtherForms;なんと37行!(しかもフォームの結果をconsole.logで出力している)
デモの実装例にはなかった2つのメソッドorオブジェクトを追加して実装しています。・formState
フォームの状態に関する情報が含まれているオブジェクト。
formState.isValidはフィールドにエラーがない状態かどうかをbooleanで表しています。・handleSubmit
バリデーションに成功するとフォームのデータを渡してくれるメソッド。補足
バリデーションのタイミングはオプションで指定することができます。
今回は各フォームの入力ごとにバリデーションしたいので、
useFormに{mode: 'onChange'}
を渡しています。
パフォーマンスの観点ではレンダリングが増えるので推奨されてはいないようです。
バリデーションのみの登録とバリデーションとエラーメッセージをセットで登録することもできます。https://react-hook-form.com/jp/api/#register ↩
- 投稿日:2019-12-22T19:14:15+09:00
Vue x Bootstrap Vue x Netlifyでさくっと作るTrello風アプリ
はじめに
こんにちは!
Mikatus株式会社でインフラエンジニアっぽいようなことをしている福田です。
最近フロントエンド領域に興味があり、デザインやJavaScriptの勉強をしています。
インフラネタにしようか迷ったのですが、今回はjavaScriptだけで動く簡単なアプリを作ってみました。開発経緯
今や知らない人は少ないかもしれませんが、Trelloというのはタスク管理ウェブアプリケーションです。
https://trello.com/home私は業務タスクだけでなく、家事タスクもTrelloを使って管理しています。
私はTrelloにおいて、タスクがドラックアンドドロップで移動できる部分が好きなので、この部分を自分で実装してみたいと思ったのが今回Trello風アプリを作ろうと思ったきっかけです。
こんなの作った
Trello風アプリ
はい、名前の通りTrelloっぽいアプリです。
機能としては以下。
- リストを作成できる
- リストの中にタスクを作成できる
- タスクをドラッグアンドドロップで移動できる
コード
https://github.com/pistachiyoda/practice-trello-like-app使用技術
- Vue.js
- Vue.Draggable
- Bootstrap Vue
- Netlify
致命的なバグ(実装間に合わんかった)
- リストの移動ができない
- リストの削除ができない
- タスクの削除もできない
- ブラウザをリロードしたら作ったリスト・タスクは消える
- 背景をサンタクロースから変更できない
- 空欄でもリスト・タスクが作成できちゃう(フォームバリデーション実装してない)
などなど…
目標だったドラッグアンドドロップでのタスク移動の部分は実装できた(といってもライブラリを使ってしまったので、実装できたと言っていいのかという葛藤…)のですが、細かいと部分はもっと詰めていかないといけません。
ちょっとずつ修正したり機能追加してみようと思います。振り返り
実際に動くものを作るのは楽しいし勉強になりますね。
flexboxやgrid systemについて復習するいい機会になりました。アプリのデプロイが終わった後の満足感にやられて記事の執筆に全く身が入らなかったため、アウトプット前提のアプリを開発する場合は開発と執筆を並行して実施したほうがいいなと思いました。
- 投稿日:2019-12-22T18:35:41+09:00
node/js向け husky使ってpush前にテストするを共有する
テストや静的コード解析実行前にうっかりpushしてしまう事はないでしょうか?
有意義レビューの為にも事前にテストを実行し、リモートにはテスト通ったコードしか置かないようにしたいですね。
githooksのpre-pushを利用したpush前に必ずテストし、失敗したコードはpushできない状態を作る事ができます。
githooksのpre-pushを利用したpush前テスト
.git/hooks/pre-push
に実行したいコマンドを書きます。pre-push
はgit push
の前に実行されるフックです。ほとんどのテストコマンドは、すべて通った場合に終了コード
0
を、失敗した場合に1
などの0以外を返すようになっています。
0
の場合にのみgit push
が実行できるようになります。$ cp .git/hooks/pre-push.sample .git/hooks/pre-push $ chmod +x .git/hooks/pre-push[.git/hooks/pre-push]#!/bin/sh yarn testただ、この方法ではプロジェクトメンバーへの共有が難しくなります。テンプレ作って
pre-push
書き換えてと案内するのは面倒ですね。husky使ったpush前テスト
husky をinstallして
package.json
にpush前に実行したいコマンドを書くだけです。(.huskyrc
を用意しても良いです)$ yarn add --dev husky
pre-commit
などのhookも用意されています。コミット単位でテストするのは非効率なのでpre-push
を使います。package.json{ ... "husky": { "hooks": { "pre-push": "yarn test" } }, "devDependencies": { "husky": "^3.1.0" } }push時に自動でテストが走り、失敗するとpushできないようになりました。
$ git push husky > pre-push (node v10.14.1) yarn run v1.17.3 $ yarn test ... error Command failed with exit code 1. info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command. husky > pre-push hook failed (add --no-verify to bypass)プロジェクトメンバーはパッケージインストール時にpre-pushが書き換わる
clone時のhooksは何も登録されていないsample状態ですが、
$ git clone xxxxx $ ls .git/hooks applypatch-msg.sample fsmonitor-watchman.sample pre-applypatch.sample pre-push.sample pre-receive.sample update.sample commit-msg.sample post-update.sample pre-commit.sample pre-rebase.sample prepare-commit-msg.sampleパッケージインストール後に書き換えてくれます。
$ yarn yarn install v1.21.1 [1/4] ? Resolving packages... [2/4] ? Fetching packages... [3/4] ? Linking dependencies... [4/4] ? Building fresh packages... ✨ Done in 2.29s. $ ls .git/hooks applypatch-msg fsmonitor-watchman.sample post-merge post-update.sample pre-commit pre-push.sample pre-receive.sample sendemail-validate applypatch-msg.sample post-applypatch post-receive pre-applypatch pre-commit.sample pre-rebase prepare-commit-msg update commit-msg post-checkout post-rewrite pre-applypatch.sample pre-merge-commit pre-rebase.sample prepare-commit-msg.sample update.sample commit-msg.sample post-commit post-update pre-auto-gc pre-push pre-receive push-to-checkoutまとめ
必ず実行するであろうパッケージインストール後に書き換えてくれるので勝手に共有できるのは良いですね。
githooks管理ツールは他にもpre-commitやovercommitなどがありますが、
husky
はシンプルで良い感じです。
- 投稿日:2019-12-22T18:22:41+09:00
Reactで投稿のIDを取得する
はじめに
いなたつアドカレの二十二日目の記事です。
もうすぐ終わりですね。。。
今回はReactでURLから投稿などのIDを取得する方法の備忘録です。
じっそー
App.jsimport React from 'react'; import { BrowserRouter as Router, Route } from 'react-router-dom' function App() { return ( <Router> <Route exact path='/:id' component={Hoge} /> </Router> ); } export default App;
/:id
のURLでHogeのコンポーネントというRouteの定義ができました。const id = props.match.params.idHogeのコンポーネントで上のようにすることで、idを使用できます
このidを使ってAPiを叩くなりしましょ
- 投稿日:2019-12-22T18:18:10+09:00
v-modelの使い方と双方向データバインディング
フォーム入力バインディング
フォーム(
input要素
,select要素
,textarea要素
など)の入力値 / 選択値をvueインスタンス
のdataを同期させる 「双方向データバインディング」を行うにはv-model
ディレクティブを使用する。index.html<div id="app"> <input v-model="message"> <p>{{ message }}</p> </div>vue.jsnew Vue ({ el: '#app', data: { message: '' } })
v-modelディレクティブ
は次の2つの処理を一つにまとめています。
(1) データバインディングで、要素のvalue属性(値)を更新するv-bind:value="message"(2) イベントハンドリング(イベントが発生したときに呼び出される処理)で、受け取った値をデータに代入
入力フォームにユーザが入力
↓ inputイベントが発生
this.message = event.target.valueで値を得られる。
messageプロパティの値が変更されます。DOM要素へのデータバインディングと、要素から取得したデータをリアクティブデータに反映させるこの2つの処理を自動化した構文が
v-model
ラジオボタン
ラジオボタンのデフォルトの値の方は文字列
index.html<div> <span>性別:</span> <label> 男性 <input type="radio" value="male" v-model="form.sex"> </label> <label> 女性 <input type="radio" value="female" v-model="form.sex"> </label> <p>性別: {{ getRadioValue }}</p> </div>vue.jsdata: { return { //省略 form: { sex: '', }, } },
value属性
の値がバインディングされるので female / male のどちらかが
data
の中のsex
プロパティへ代入されます。セレクトボックス
index.html<div> <select v-model="form.selected"> <option disabled value="">--選択してください--</option> <option>A</option> <option>B</option> <option>C</option> <option>D</option> </select> </div>【 単数選択 】
vue.jsdata: { return { //省略 form: { selected: '' }, } },【 複数選択 】
vue.jsdata: { return { //省略 form: { selected: [] }, } },チェックボックス
チェックボックスのデフォルト値の値の型は
Boolean
index.html<div> <label> <input type="checkbox" v-model="form.checked"> 20際以上です </label> </div>vue.jsdata: { return { //省略 form: { checked: true }, } },
- 投稿日:2019-12-22T17:59:07+09:00
非同期処理の例外処理の書き方
promiseとasync awaitの例外処理の書き方を紹介します。
promiseの場合
与えられた引数を2倍にして、コンソールに描画するメソッドを例にして説明します。
2倍にするメソッドとして、doubleメソッドを作ります。
doubleメソッドの引数に10を与えて、console.logを実行すると20が表示されます。const double = (number) => { return new Promise(resolve=>{ resolve(number * 2) }) } double(10) .then(num => console.log(num)) // 実行結果: 20続けて、doubleメソッドの中で例外を発生させます。
const double = (number) => { return new Promise(resolve=>{ // 意図的に例外を発生 throw new Error('エラーが発生しました') resolve(number * 2) }) } double(10) .then(num => console.log(num)) // 何も表示されない例外が発生した時に、受け取る処理を書かないと何も表示されません。
これでは、システム内でエラーが発生しても何も通知されないことになります。では、例外処理を書いていきます。
promiseオブジェクトはcatchメソッドが使えるので、thenに続けてcatchを記述します。const double = (number) => { return new Promise(resolve=>{ // 意図的に例外を発生 throw new Error('エラーが発生しました') resolve(number * 2) }) } double(10) .then(num => console.log(num)) .catch(error => { // 例外が発生するとcatchに入る console.log(error.message) }) // 実行結果: エラーが発生しましたdoubleメソッド内でエラーが発生した時にthenメソッドを飛ばしてcatchメソッドに処理が移行します。
catchメソッド内でerror.messageと記述すると、new Errorで設定した「エラーが発生しました」というメッセージを表示するとことができます。ちなみに、thenとcatchがチェーンになっている場合、以下のような動きになります。
const printA = () => { console.log('A') } const printB = () => { console.log('B') } const printC = () => { console.log('C') } const rejected(){ console.log('reject') } // パターンA Promise.resolve() .then(printA) .then(printB) .catch(rejected) .then(printC) // 実行結果 // A // B // C // パターンB Promise.reject() .then(printA) .then(printB) .catch(rejected) .then(printC) // 実行結果 // reject // CパターンA:
例外が発生しなかった場合、catchメソッドは実行されずに次のthenメソッドまで処理が移行します。パターンB:
例外が発生した場合、catchメソッドまで処理が移行した後、thenメソッドが実行されます。async awaitの場合
promiseの時と同じく、与えられた引数を2倍にして、コンソールに描画するメソッドを例にして説明します。
const double = async (number) => { return (number * 2) } const exec = async () => { const result = await double(10) console.log(result) } exec() // 実行結果: 20正常に結果が返り、20と表示されました。
続いて、例外処理を書いていきます。
async awaitの場合はtry-catch文を使います。const double = async (number) => { throw new Error('エラーが発生しました') return (number * 2) } const exec = async () => { try { const result = await double(10) console.log(result) } catch (e) { console.log(e.message) } } exec() // 実行結果: エラーが発生しました例外が発生して、catch文に処理が移行したことを確認できました。
まとめ
システムにエラーは付きもので、エラーが起きた時にcatchする機能がないとユーザには何も通知されずに利便性の悪いものになってしまいます。
なので、適切な場所に例外処理を記述することをお勧めします。おまけ
try-catch文の中で、promiseのcatchを書くとどうなるでしょう?
やってみましょう。const double = number => { return new Promise(resolve=>{ // 意図的に例外を発生 throw new Error('エラーが発生しました') resolve(number * 2) }) } try { double(10) .then(num => console.log(num)) .catch(error => { // promiseのcatch処理 console.log('promiseのcatch処理') }) } catch { // try-catchのcatch処理 console.log('try-catch文のcatch処理') } // 実行結果: promiseのcatch処理結果より、try-catch文のcatchには入らずに、promiseのcatchの中の処理が実行されました。
参考
- 投稿日:2019-12-22T17:49:43+09:00
小さくて早い?Svelteとは
この記事はFIXER Advent Calendar 2019 22日目の記事でcloud.config Tech Blogにマルチポストされています。
Svelte
昨今よく使われているVirtualDOMを使用したReactやVueのようなJaveScriptライブラリを見かけますが、Svelteはそれらと違い、.svelteという拡張子のファイルからhtml、css、JavaScriptを吐き出してくれるコンパイラとなっています。
VirtualDOMを使用するフレームワークと比べて何がいいの?
VirtualDOMは変更を比較する際に、VirtualDOMの再構成をし前回との差分を見るという手順を踏む必要があります。それではどうしても無駄な比較が多くなってしまいます。しかし、Svelteはビルド時にアプリケーション内の変更される場所を認識するコンパイラなためVirtualDOMに比べて比較処理は少なくなります。
記法について
ReactやVueとの比較を書こうと思ったのですが公式にちょうど良い例があったので引用させていただきます。
React
Reactimport React, { useState } from 'react'; export default () => { const [a, setA] = useState(1); const [b, setB] = useState(2); function handleChangeA(event) { setA(+event.target.value); } function handleChangeB(event) { setB(+event.target.value); } return ( <div> <input type="number" value={a} onChange={handleChangeA}/> <input type="number" value={b} onChange={handleChangeB}/> <p>{a} + {b} = {a + b}</p> </div> ); };
VueVue<template> <div> <input type="number" v-model.number="a"> <input type="number" v-model.number="b"> <p>{{a}} + {{b}} = {{a + b}}</p> </div> </template> <script> export default { data: function() { return { a: 1, b: 2 }; } }; </script>
SvelteSvelte<script> let a = 1; let b = 2; </script> <input type="number" bind:value={a}> <input type="number" bind:value={b}> <p>{a} + {b} = {a + b}</p>これらのコードは同じ動きをしますが、コード量を比較すると差が一目瞭然ですね。
個人的に好きなところ
- 上のコードでもわかるのですが、複数のコンポーネントを返すことができる点です。Reactのように
React.Flagment
で囲ったり、Vueのようにtemplate
で加工必要もありません。毎回インデントの階層を増やして囲むのを面倒に思っていた自分からしたらとても嬉しい仕様です。- もう一つは、ReduxやVuexのような状態管理が機能として実装されている点です。 新しくパッケージを追加する必要がなく
store.jsimport { writable } from 'svelte/store'; export const text = writable("hello world");これだけの記述で宣言する事ができます。
storeの中身を操作したいときも
ChangeText.svelte<script> import { text } from './store.js'; function changeText() { text.update(() => "CLICK BUTTON!"); } </script> <button on:click={changeText}> Click me </button>このようにupdateを呼び出して値を更新するだけです。
あとはstoreの値を使いたいところで以下のように記述するのみです。App.sveltelet store_text text.subscribe(value => { store_text = value; });公式のページに親切に各機能の書き方がわかりやすく見れるようになっていて、サンプルを元にしてその場で書き換えることができるためとても便利です。
https://svelte.dev/examples#hello-world
- 上の例の種類を見ても分かる通り公式ページが便利でわかりやすくとっつきやすい点も好きでおすすめできる点です。REPLが実装されているため、わざわざ環境を整えずにコードの動作を確かめることができます。おわり
読んだことや書いたことがある人ならわかると思うのですが、個人的にはRiot.jsやVue.jsに似た文法だと感じました。
個人的にどう発展していくか注目していきたいライブラリです。今回、Svelteの紹介のみの記事になってしまったのですが、次はSvelteで何かを作って記事を書くことを自分への課題にして終わりとします。
- 投稿日:2019-12-22T17:21:42+09:00
終盤問題ジェネレーター(仮)の開発
概要
私が開発を進めている終盤問題ジェネレーター(仮、以下ジェネレーターと略)の開発に関する記事です。
後述の通り、特にジェネレーターのPoCに焦点を当てています。
リバーシ検討図メーカーの話も入っているかもしれませんが、便宜上ジェネレーターの話として紹介いたします。
ITを本職とされている方々からすると気になる部分などあるかもしれませんが、素人の戯言でございますのでご容赦ください。開発の目的
リバーシの終盤問題は既に複数の方が提供されています。
そういった問題集は良問が多い印象ですが、問題数に限りがあります。
無限に遊べる終盤問題集があればいいなと思い、開発を始めました。環境選択
開発するときにまず、どの環境を対象にするかを考えました。
候補として主に下の3つがありました(表は私の知っている限りの話です)。
環境 iOS Android Web 言語 Swift or
Objective-CJava or
KotlinHTML5+CSS
+JavaScriptiOS対応 O X O Android対応 X O O PC対応 X X O バイナリ実行 O O X これに通信機能を付けると、バックエンドのコードも書く必要があります。
私は「PCでも遊べるようにしたい」「バックエンドでNode.jsを使いたい」といったことを考え、Webで開発することにしました。PoC
準備段階
ジェネレーターで最も重要なのは生成エンジンの部分です。
本稿では「本当にそういったエンジンが作れるのか?」の検証(PoC)部分を中心に説明いたします。
開発初期は棋譜を出力し、作成された問題をWZebraでチェックしていました。
(参考図)
その後簡単な盤面表示関数を実装して、こちらでチェックするようにしました。
(X=黒石、O=白石)
PoC:Gen.1
Gen.1(第一世代、以下同様)として、ランダムに着手するだけのエンジンを作りました。
(ただし全体を通して、ジェネレーター関数はdeterministicとなるようケアしました。)生成例1f5f4d3f6g7c4g6f7g3h6b4h7h8f3e7e6h5c5f2c3b2e2d6d7d2g2h2e8f8a4b3g4h4c6d8a2b5c8g5b7c7f1a8c2e1a7a1g8e3d1c1b1a3a5g1h3a6b6b8h1
当然これでは、問題として通用する盤面は生成されません。
(下記解析例参照)
ただしこの、ランダムに着手するエンジンが全く使えないわけではありません。
様々試す中で、「ランダムな着手の一部を変更することで、互角に近い局面を生成すること」が可能であることが分かりました。
例えば棋譜例1ならば、52.h1 53.b8とすることでドロー局面が生じます。
PoC:Gen.2
Gen.1における試行の結果から、以下のような条件でエンジンを動作させることが有効と予想されます。
- ランダムな着手を途中で打ち切る
- 打ち切った局面から完全解析を実行
- 特定の評価値範囲内(例えば、黒+1~+10など)にある局面を抽出する
実際にこの条件で実行すると、以下の課題が見つかりました。
- 既に終局している局面が生成される。
- 最善進行の途中の局面が生成される。
- 不要なノードの探索に計算リソースが消費される
PoC:Gen.3
Gen.2の欠点を解消するため、追加で以下の制約を入れました。
- 連続して評価値Xの局面が生じる場合、評価値Xとなる空きマス数最多の局面のみを抽出する(課題1, 2対策)。
- 最低空きマス数を設定(課題3対策)
1について、例えば以下の3局面が検出された場合、1枚目の局面のみを抽出し、残りの局面は破棄することを意味します。
以上の対策の結果、以下のような棋譜を生成するエンジンが出来上がりました。
生成例2c4c5f6d3e2b3b4b5e6f1d2f7a4g6b2b1a2e3f3c3a5g2g3g4c6b6d6f2f4d7h4g5d8e8f8c7g8c1a1f5d1c2h6h5h2e1b8b7a6e7g1h1a7 c4c5f6d3e2b3b4b5e6f1d2f7a4g6b2b1a2e3f3c3a5g2g3g4c6b6d6f2f4d7h4g5d8e8f8c7g8c1a1f5d1c2h6h5h2e1b8b7a6e7g1g7c8 c4c5f6d3e2b3b4b5e6f1d2f7a4g6b2b1a2e3f3c3a5g2g3g4c6b6d6f2f4d7h4g5d8e8f8c7g8c1a1f5d1c2h6h5h2e1b8b7a6e7a3h3a7ただし、生成された局面が「不自然」という課題がありました。
PoC:Gen.4
ジェネレーターをより実戦向きにするため、抽出される局面がより「自然」となるよう改良を実施しました。
解決に当たり、まず以下のような仮説を立てました。
- 人間から見て「不自然」な局面は、局面の見た目で判断されている。
- 局面の見た目は石の配置に依存する。
- 石の配置の「自然さ」を評価する静的評価関数を導入し、より評価値の高い局面を抽出することで、より「自然」な局面のみを生成するエンジンとなる
これを確認するため、まず以下の対策を行いました。
- 「自然さ」の評価関数導入
- 「自然さ」評価値上位の問題の抽出
評価方法の一例として、「空きマス同士が隣り合っているか?」を紹介いたします。
Evaluator.js/*e0 and e1 : empty squares of upper/lower half.*/ function LFCountConnectedEmptySquares(e0,e1) { var a = e0 << 8 & e0; var b = e1 << 8 & e1 | e0 >>> 24 & e1; var c = LFCountNumberOfDisks(a, b); a = e0 << 7 & (e0 & 0x7f7f7f7f); b = e1 << 7 & (e1 & 0x7f7f7f7f) | e0 >>> 25 & e1; c += LFCountNumberOfDisks(a, b); a = e0 << 9 & (e0 & 0xfefefefe); b = e1 << 9 & (e1 & 0xfefefefe) | e0 >>> 23 & 0x000000fe & e1 c += LFCountNumberOfDisks(a, b); a = e0 << 1 & (e0 & 0xfefefefe); b = e1 << 1 & (e1 & 0xfefefefe); return c + LFCountNumberOfDisks(a, b); }これはマスクしたbitboardをビットシフトしてAND演算することで、縦横斜めのいずれかの方向に空きマスがつながっているかを評価するものです。
あるマスから見て隣接マスは8か所ありますが、そのうち4か所と隣接しているかどうかを評価しています。
例えばa1マスから見てa2マスは下方向で隣接していますが、a2マスから見るとa1マスは上方向に隣接しています。
これらを重複してカウントする必要はありませんので、このうち「a1から見たa2の位置関係」のみをカウントしています。
このアルゴリズムは、bitboardでの着手アルゴリズムに着想を得て実装しています。
なおJavaScriptでは64bit整数が利用できないため、bitboardは32bit整数 x 2で表しています。
そのため空きマスビットはe0とe1の2つで表現しています。
この点についてはこちらの記事( https://qiita.com/rimol/items/9ed84a4fd4cbfdb83d71 )を参考にしました。ある程度評価関数の実装とパラメーター設定ができたところで、一度アンケートを実施しました。
あなたはこの盤面が...
— AL4TH/あるふぉーす (@al4_th) December 15, 2019あなたはこの盤面が...
— AL4TH/あるふぉーす (@al4_th) December 15, 2019この時、同じ評価関数から得られた2つの局面について同時にアンケートをとりました。
結果、片方は「不自然」が多数、もう片方は「自然」が多数といったアンケート結果になりました。
このアンケート結果から、以下のように考えました。
- 同じアルゴリズムから「不自然」「自然」両方の局面を生成しうる。
- 評価関数の改良で「不自然」な局面を排除できるようになるのではないか?
PoC:Gen.5
Gen.4の考察から、色々と評価関数の調整を実施しました。
詳細は割愛しますが、以下のような局面(黒番)をコンスタントに生成するエンジンができました。
参考動画 : https://youtu.be/kfDp3Uc4LRI
まとめ
Gen.5である程度「自然」な局面を選択的に生成できるようになりましたので、PoCの目的を達しました。
エンジンは32bit整数 x 2をseedとして入力可能ですので、最大で64bit unsigned intの最大値=4294967295パターンの問題を生成可能です。
(必要な時はさらに追加可能。)
これからは、作成したエンジンを使って終盤問題アプリを作っていきたいと考えています。最後に、オセロ Advent Calendarにお誘い頂いた@sensuikan1973さんに感謝申し上げます。
長らく駄文にお付き合いいただきありがとうございました。
- 投稿日:2019-12-22T17:13:32+09:00
JSでブラウザがダークモードかを判定する
はじめに
ブラウザにダークモードが搭載されるようになった!
CSSで、ダークモード時の Media Query が記述できる!
Can I use... Support tables for HTML5, CSS3, etc→JSでは取得できないの...??探してもあまり見つからないし....
いろいろ試行錯誤した結果、取得する方法を発見(?)したので、紹介しようと思います。
条件
- CSSで、Media Query "
prefers-color-scheme
" が記述できる
2019.12.22現在、最新版Firefox, Chrome, Safari, Operaは使用できます。Edgeは残念ながら...
Can I use... Support tables for HTML5, CSS3, etc- JSで、
getComputedStyle
とgetPropertyValue
を使用できる
モダンブラウザすべてに対応しています。
(ユーザーがJSを「実行しない」にしていなければ)取得できます。考え方
- JSでは現在、「直接の」判定はできない。
- しかし、CSS変数は取得できる。
- Media Query で、CSS変数の値を変えることが可能。
...ということは...??
- Media Query で CSS変数を定義
- JSでCSS変数を取得し、値によって判定が可能
実装
今回記述したコードは、GitHubにあげておりますので、併せてごらんください。
a01sa01to/DarkmodeGetter_js=====
1. CSS変数を定義
/* ライトモードの時、CSS変数 isDarkmode は存在しない */ html{ ... } @media (prefers-color-scheme: dark){ /* ダークモードの時 */ html{ --isDarkmode: True; .... } }prefers-color-scheme - CSS: カスケーディングスタイルシート | MDN
変数名や値などは、競合しないように適宜変えても構いません。
=====
2. CSS変数を取得
/* 1. <html> に変数を設定したので、まずは <html> を取得する */ const htmlTag = document.querySelector("html"); // -> <html lang=...> ..... </html> /* 2. CSSプロパティの取得 */ const comStyle = getComputedStyle(htmlTag); // -> CSSStyleDeclaration {0: ... } /* 3. 変数の取得 */ const cssVariable = comStyle.getPropertyValue('--isDarkmode'); // -> "True"(ダークモード)・""(ライトモード) // ライトモードは、変数を指定していないため、空の文字列が返されるもちろん、わざわざ上記のように書かずとも、1行で記述可能です。
getComputedStyle(document.querySelector("html")).getPropertyValue('--isDarkmode');1.でCSS変数を変更した場合は、こちらも変更する必要があります。
=====
3. 判定
/* 2 のコードの続き。"cssVariable" が定義されている前提 */ const isDarkmode = !!cssVariable; // これでも同じ const isDarkmode = Boolean(cssVariable);ライトモードでは、
cssVariable == ""
なので、Falsy です。
そのため、isDarkmode
はfalse
を返します。ダークモードでは、
cssVariable == "True"
→ Truthy なので、true
を返します。Truthy - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN
Falsy - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN=====
以上により、ブラウザがダークモードかを取得することができました!
- 投稿日:2019-12-22T17:07:13+09:00
v-forによる繰り返しの描画
v-for
による繰り返し配列やオブジェクトをループ処理して、
要素を繰り返しを描画するには、v-for
ディレクティブを使用する。index.html// v-for 構文 <li v-for="各要素を代入する変数名 in 繰り返したい配列やオブジェクト">index.html<div id="app"> <ul> <li v-for="item in items" v-bind:key="item.id"> </ul> </div>app.jsnew Vue ({ el: '#app', data: { items: [ { id: 1, title: '1番目のリスト', }, { id: 2, title: '2番目のリスト', }, { id: 3, title: '3番目のリスト', }, ] } })
data
オプションに登録されているitems
の配列から、v-for
ディレクティブを使って1つずつ要素を取り出し描画します。Key の役割
v-bind:key="item.id"
v-forディレクティブ
でループしている要素に対しては、v-bind:key
ディレクティブにその要素として識別情報となる値(一意の値)を指定することが推奨されています。Vueが各要素を効率よく追跡できるようにするため、ということのようです。
:key
はv-bind:key
の省略構文。不変でユニークなキーを設定しよう
要素の削除や並び替えも考慮して「不変でユニークなキー」を設定する必要があります。
ユニークな乱数を生成するuuid
や、vue-uuid
を使用して:keyの値を乱数で指定することができます。(例)「
vue-uuid
」を使用して:key
を指定する際は乱数を値に指定できるようにする場合index.jsimport Vue from 'vue'; import uuid from 'vue-uuid'; // 追加 Vue.use(uuid); // 追加 new Vue({ el: '#app' });
uuidモジュール
をimportして、Vue.use()
の引数に指定することによって
アプリケーションでuuid
を使用できる様にします。app.vue.jsdata() { return { //省略 items: [ { id: this.$uuid.v4(), // バージョン4のUUIDは、乱数により生成される。 title: '1番目のリスト', }, { id: this.$uuid.v4(), title: '2番目のリスト' }, { id: this.$uuid.v4(), title: '3番目のリスト' } ], } }これで
id
には乱数が指定されるようになりました。
- 投稿日:2019-12-22T16:16:20+09:00
Recorder.jsを利用してブラウザ上で音声を録音する
ブラウザ上で音声を録音、音声ファイルのダウンロードする
GitHub Pagesで公開しているページで実施できます。
ライブラリとしてRecorder.jsを利用しています。Recorder.jsとは
Recorder.jsとはWeb Audio APIというJavaScript APIをラップし、簡単に録音とその音声ファイルの出力を可能にするJavaScriptライブラリです。
このライブラリをHTML内で読み込むことによって、ブラウザ上からPCのマイクを使って録音して音声ファイルをダウンロードできるようになります。GitHub上に公開されていた元のリポジトリを修正しています。
修正したリポジトリはこちらです。また、利用例としてGitHub Pagesを使って、ブラウザ上で録音、音声ファイルダウンロードできるようにしています。本当はライブラリの修正がしたかったのではなく、ブラウザで録音してそのファイルをAmazon Transcribeに処理させてテキスト化するというWebサービスを作りたかったのですが、時間がとれませんでした...
フォーク元Recorder.jsの問題点
フォーク元のRecorder.jsは2019年12月時点では開発が停止しており、残念ながらそのままで使うことはできませんでした。
AudioContext.resume()
が呼び出されていないNavigator.getUserMedia()
が利用されている
AudioContext.resume()
が呼び出されていない
AudioContext.resume()
は...あまりよくわかっていないです。
説明では途中停止した音声を再生するために使うらしいですが、AudioContextの初期化に必要らしい?
このメソッドを追加しないと録音ができなかったため、とりあえず追加しました。
Navigator.getUserMedia()
が利用されている
Navigator.getUserMedia()
は非推奨となっています。そのため、一部のブラウザ(Safariなど)ではこのAPIによる操作をおこなうことができないようです。
代替としてMediaDevices.getUserMedia()
を利用することが推奨されています。今回は
MediaDevices.getUserMedia()
に実装を変更したことで、Safariでも録音をおこなうことができるようになっています。ブラウザ上での利用例
GitHub Pagesで公開しているページで実施できます。
マイクの利用を許可し、recordボタンで録音を開始します。
stopを押すことで録音を停止し、音声ファイルが表示されるため、ブラウザ上で再生したり、ファイルダウンロードしたりできるようになります。
再度record、stopをすることで、音声ファイルがさらに追加されていきます。
(ボタンの見た目などはかなりダサいので改善余地あり)おわりに
最初にも書きましたが、Amazon Transcribeが日本語対応して音声からテキストを書き起こすことができるようになったので、
簡単に録音してテキスト化するというサービスにするつもりでした。
AWSではJavaScript SDKが公開されていて、多くの実装例が存在するため難しくないはずです。
時間がとれたときに挑戦してみようと思います。
- 投稿日:2019-12-22T15:48:54+09:00
Indexed Database APIの基礎
記事の趣旨
ローカル環境での永続化手法の1つである「Indexed Database API」について,ザックリと勉強したので,そのメモ書き.
1. Indexed Database APIとは
Webブラウザに標準搭載されている,Key-Value型のAPIとのこと.API仕様はW3Cで規定されている.主な登場人物としては「Database」「Object Store」「Record」で,関係性としては以下のとおり.
- Databaseは,0以上のObject Storeを有している
- Object Storeは,Key-Value型のRecordのリストを有している
- Object Storeはnameを持ち,nameはDatabase内で一意であることが保証されている
- Recordのリスト内でkeyは一意であることが保証されている
2. IDBRequest
先述の登場人物に対するCRUDは「Request」を通じて実行される.その仕様は
IDBRequest
というインタフェースで定義されている.大雑把に触れると以下のとおり.
IDBRequest
の実装オブジェストは,生成された瞬間にリクエストを発出する.- このリクエストは非同期に処理される.
- 結果はオブジェクト内の
result
変数に格納される.- リクエストが成功/失敗したときのイベントハンドラとして
onsucess
およびonerror
が用意されている.- 拡張インタフェースである
IDBOpenDBRequest
のみ,追加でonblock
およびonupgradeneeded
というイベントハンドラが用意されている.後者は初回起動時やDBのバージョン更新時のみ実行される.たとえばこんな感じ.
var connection; window.onload = function () { var openRequest = indexedDB.open('testDB'); openRequest.onsuccess = function () { connection = openRequest.result; } }
indexedDB
はブラウザ側で用意されているオブジェクトなので特に宣言なしに使用できる.こいつのopen('testDB')
メソッドで返却しているのが,まさに先述のIDBOpenRequest
の実装オブジェクト.このメソッド実行後,「testDBという名称のDatabaseに接続する,なければ作る」というリクエストが非同期に実行される.処理が完了するとresult
に結果が格納されているので,それをconnection
変数に渡すよう,onsuccess
のイベントハンドラに設定している.3. connectionとtransaction
先述のconnectionを起点としてRequestを発出する.connectionは,以下の2つのメソッドを有している.
createObjectStore(name)
:新たなObject Storeを作成する.transaction(name, mode).objectStore(name)
:既存のObject Storeに対するTransactionを開始する.modeは'readonly'
かreadwrite
.いずれも
IDBObjectStore
の実装オブジェクトを返却してくれる.そのオブジェクトは,例えば以下のようなRequest発出用のメソッドを有している.
put(value, key)
:Recordをputする.既存のkeyを使用した場合は上書き.add(value, key)
:Recordをaddする.既存のRecordを使用した場合はエラー.get(key)
:指定したkeyのRecordをgetする.getAll()
:すべてのRecordを配列型でgetする.delete(key)
:指定したkeyのRecordをdeleteする.clear()
:すべてのRecordをdeleteする.4. サンプル
ここまでに得た情報を踏まえて,とりあえずザックリとサンプルを書いてみる.Chromeの「79.0.3945.88」で動くことを確認.
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> </head> <body> <button class="showButton">Object Storeの表示</button> </body> <script src="scripts/main.js"></script> </html>var connection; window.onload = function () { var openRequest = indexedDB.open('testDB'); openRequest.onupgradeneeded = function () { connection = openRequest.result; var objectStore = connection.createObjectStore('testObjectStore', {keyPath:'id'}); objectStore.put({id:1, name:'taro'}); objectStore.put({id:2, name:'jiro'}); objectStore.put({id:3, name:'saburo'}); } openRequest.onsuccess = function () { connection = openRequest.result; } } function showObjectStore() { var humans = getAllSync().then( function(humans) { for(var i = 0; i<humans.length; i++) { var human = humans[i]; alert(human.name); } } ); } function getAllSync() { return new Promise( function (resolve, reject) { var transaction = connection.transaction('testObjectStore', 'readonly'); var request = transaction.objectStore('testObjectStore').getAll(); request.onsuccess = function (e) { resolve(request.result); } request.onerror = reject; } ) } document.querySelector('.showButton').addEventListener('click', showObjectStore);
testDB
内にtestObjectStore
を作り,初期設定として3種類のRecordを格納している.ボタンを押下すると,そのすべてを取得し,alertで表示する,という挙動になる.細部については,以下のとおり.
createObjectStore
メソッドの第二引数{keyPath:'id'}
は,「idをキーと認識します」という宣言のようなもの.- 先ほども述べた通り,リクエスト処理は非同期なので,Promiseによる「待ち」を施している.
- 今回で言うと
transaction.objectStore('testObjectStore').getAll()
がいつ終わるか不明.- なお,
onload
内の各種put
にも,たぶん本当は対策が必要.- 無事データが取得できたら,resultに配列としてデータが格納されているので,
alert
で全員分の名前を表示.5. Key Generator
上記の例だと,keyPathで設定したid値を手入力で指定しているが,どうせならそこは自動で設定して欲しいと誰もが思うはず.幸い,「Key Generator」という仕組みが存在する.
用法は簡単で,上記の一部を以下のように書き換えるだけ.
var objectStore = connection.createObjectStore('testObjectStore', {keyPath:'id', autoIncrement:true}); objectStore.put({name:'taro'}); objectStore.put({name:'jiro'}); objectStore.put({name:'saburo'});
put
時にidというkeyがなくなっているが,勝手に生成してくれる.6. 終わりに
非同期処理の対策をミスると面倒くさいという点を除けば割と便利だと思う.ちょっとした自作ブラウザアプリを作るだけなら,これだけで十分だと思う.