最近iOS向けのpwaが強化された(ouathに対応した)的な話を聞いてそろそろやってみないとなぁってことで実際に作ってみました。
電卓を選んだ理由は、lluminoというかなりネイティブに近いUI/UXのpwaの電卓アプリを知っていたからです。触ってみるとわかるのですが、まじですごいしpwaのポテンシャルを感じられます。
ここまでのものはなかなかしんどいにせよ、これに準じたものを目標にして作ってみました。
PWA Calculatorというアプリとして公開しているのでぜひ試してみてください!ホームに追加するとオフラインでもアプリみたいに動く様子が確認できると思います。(デプロイはnowで簡易にやってます)
そもそも電卓が難しかった
- https://qiita.com/nishina555/items/9ff744a897af8ed1679b
- https://qiita.com/micropig3402/items/d1221652cdc6a4efccbe
このへんの記事を見つけたので、まあ電卓ぐらいすぐ作れるやろ〜ぐらいの軽い気持ちでした。でも、実際作ってみると思いの他むずくてそもそもの設計で苦戦し多くのバグを生み出しました。w
正直TDDでやった方がよかったかも。。
具体的には、結構いろんな状態を持たないといけなかったからです。これまでの結果と入力中の数字、計算が終わったかどうか、何を表示させるか、、などです。
計算のロジック自体は、直前の数字 + 四則演算子 + 入力の値 で計算していたのですが、これだと初期値(直前の数字=0)のせいで、足し算以外正常に動きませんでした。。ほんとはバッファをもう一つ持たせて対応すべきなのでしょうが、直前の値の初期値をundefinedにして0と区別するという荒技で乗り越えました。
状態管理の設計のいい勉強になるので、気になる人はぜひ作ってみてください。
hooksによる状態管理
最初は、オーソドックスにreduxで状態管理をしようかなと思ったのですが、他のコンポーネントと状態を共有したり非同期で処理したりみたいな要素がなかったので、練習もかねてcustom hooksを作ってみました。
import { useState } from "react";
export const useCalculatorState = () => {
// 使うstateの宣言
const [value, _setValue] = useState(0);
const [operator, _setOperator] = useState("");
const [result, _setResult] = useState(undefined);
const [view, _setView] = useState(0);
// 途中の計算が行われたかどうか
const [isCalculated, _setIsCalculated] = useState(true);
// operatorのハイライトを行うかどうか
const [isHighLight, _setIsHiighLight] = useState(false);
// 状態更新の関数を宣言
const setValue = num => {
if (isCalculated) {
_setIsCalculated(false);
_setValue(num);
_setView(num);
} else {
const newValue = value * 10 + num;
_setValue(newValue);
_setView(newValue);
}
};
const setOperator = ope => {
_setOperator(ope);
_setIsHiighLight(true);
if (result === undefined) {
// undefinedのとき(初期値)は計算をせずにresultにセット
_setResult(value);
_setIsCalculated(true);
} else if (!isCalculated && operator) {
_setIsCalculated(true);
calculateResult(ope);
}
};
const equal = () => {
calculateResult(operator);
_setIsHiighLight(false);
};
const clear = () => {
_setValue(0);
_setResult(undefined);
_setView(0);
_setOperator("");
_setIsCalculated(true);
_setIsHiighLight(false);
};
// その他内部で使う関数
const calculate = ope => {
switch (ope) {
case "+":
return result + value;
case "-":
return result - value;
case "×":
return result * value;
case "÷":
return result / value;
default:
return result || value;
}
};
const calculateResult = ope => {
const _result = calculate(ope);
_setIsCalculated(true);
_setResult(_result);
_setView(_result);
};
return {
operator,
view,
isHighLight,
setValue,
setOperator,
equal,
clear
};
};
useStateを使って必要なstateを作成して、それを使って計算をしてstateを更新するhooksです。このhooks はviewに必要なstateとsetterだけを返しています。そうすることで、privateメンバ的なことを擬似的に作れているので保守性にも繋がるかなと思いました。
また、hooksにロジックと状態管理が完全に分離できているのでテストも容易です。(てかテストほんま書くべきやったw)
モバイルライクなUI/UXにする
完全にモバイルに振り切ったUIにしました。user-scalabler=noやonTouchStartを指定することでモバイルに最適化しています。onTouchStartを指定すると、タッチ中を:activeの擬似要素で取ることができるようになります。
最初、各ボタンが押されたときにonClickで状態を更新するようにしていました。が、めちゃくちゃ早くボタンを連打すると状態が更新されないことが発覚しました。パフォーマンステストとかもしてみたのですが、InputLatencyも10 msとかなり良好な数字でした。
結局原因は分からなかったのですが、onClickからonTouchEndに変更するとなぜかパフォーマンスが上がって高速な入力にある程度耐えるようになりました。(強い人教えてください。。)
service workerでオフラインに対応する
service workerとは、ブラウザ上のアプリとは全く別のところで常駐するjsのプログラムです。これを起動することでリクエストをプロキシしてcacheを詳細にコントロールしたり、サーバーとコネクションを維持したりすることができるようになります。
今回はcacheのAPIを用いて、
- service workerのinstall時にhtml/css/jsのファイルをキャッシュ
- リクエストをアプリが送るときにプロキシして、ファイルがあったらリクエストせずにキャッシュのファイルを返却
- キャッシュがなかったらリクエストを投げて、結果を返しつつキャッシュ
ということを実現しました。思ったより簡単に実現できました。(jsでpromiseが扱えるなら余裕)
結果、オフラインにしてリロードしてもファイルがあるからアプリが使える状態になりました。
self.addEventListener("fetch", event => {
const { request } = event;
if (request.method != "GET") return;
const handleRequest = async () => {
const cache = await caches.open(CACHE_VERSION);
// cacheを見に行ってあったらそれを返してリクエストはキャンセル
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
// cacheがないなら本来のリクエストを行う
const response = await fetch(request);
// responseをcloneしとかんとバグるっぽい
const _response = response.clone();
event.waitUntil(cache.put(request.url, response));
return _response;
};
event.respondWith(handleRequest());
});
上記はserviceWorker.jsのfetchのときのイベントハンドラーです。promiseを駆使してキャッシュの取得や実行、リクエストの実行などを行なっています。promiseだらけなのでasync/awaitを使えないとちょっぴり辛いです。event.waitUntilはservice workerにおける非同期の解決法の一つで、その処理が終わったらイベントを終了したいと言うものがある時はこれを使うようです。
よく分からなかったところとしては、responseオブジェクトがクローンしていないとundefinedになってしまうことでした。直前まで値が入ってるのにreturnすると無くなっていました。
manifest.jsonを書く
といってもそんなに書くこともなく、適当に拾ってきたmanifest.jsonをいじるだけで十分です。display: standaloneを設定することで、ネイティブみたいにブラウザのバーなどを消すことができます。
{
"background_color": "#000000",
"display": "standalone",
"name": "Dragon Calculator",
"short_name": "PWA Calculator",
"start_url": "/",
"theme_color": "#000000",
"icons": [
{
"src": "static/images/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./static/images/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
はまったところとしては、iOSのアイコンが全然設定できなかったことです。manifest.jsonに記述したらアイコンもそこからよしなにやってくれると思っていたのですが、どうもそうではなかったようです。自分でapple-touch-iconをheadタグで指定してやることでやっと設定することができました。
そんなともあれ、pwaが完成しネイティブライクに動きオフラインでもキャッシュを使って動かすことができるようになりました。
githubでソースを公開してるのでよかったら参考にしてみてください!issueに疑問点を列挙してあるのでつよつよエンジニアの方レビューしていただけますと幸いです。。