こんにちは、masakaです。
前の会社でPWAについて少し調べていたのですが、特に発表することもなかったので
今回はその内容をブログにしてみました。
ざっくりとした説明と、簡単な実装をやってみたいと思います。
PWAとは
ネイティブアプリみたいなWebアプリ
PWAは「progressive web apps」のことを指します。2015年に発表され、話題に上がってきました。
PWAに対応した代表的なサイトとして、Twitterや国内だとSUUMOなどがよく例に挙げられます。
PWAというワードで検索すると、「ネイティブアプリみたいな」Webアプリである、という抽象的な内容が散見されます。
この表現は間違っていないですが、何をもって「ネイティブアプリみたい」というのか、いまいちはっきりしません。
もう少し詳しく理解してみることにします。
PWAの代表的な機能
機能的な面で調べると、PWAで出来ることとして代表的なものには以下があります。
- ホーム画面への追加:アプリの様にインストールし、ホーム画面から起動できる
- オフライン起動:ネットワークに接続していなくても使用できる
- バックグラウンド同期:オフライン時に操作した内容を、ネットワーク接続されたタイミングでバックグラウンド送信する
- プッシュ通知:アプリからの通知
これらの機能が実装されることがPWAなのでしょうか?
Webとネイティブアプリ、それぞれの特徴とPWAが求めるもの
Google によるPWAの説明には、ネイティブアプリとWeb アプリの特徴を次のように書いてあります。
- ネイティブアプリの特徴 - 機能性・信頼性に優れる
- ホーム画面、タスクバーなどに存在しネットワークに影響されず使用できる
- 機能が豊富
- Web アプリの特徴 - 到達性に優れる
- 単一コードベース
- デバイスを問わず、誰でもどこでもアクセスできる
その上で、PWAはWebアプリに双方の長所を持たせることを目指しているようで、以下のような記載がされています。
PWAを支える三つの柱
加えてネイティブアプリのように感じさせるための三つの柱についても書かれています。
- 1.Capable (機能)
- Webは様々なことが出来るように機能拡張し、成長し続ける
- これらを取り入れることによりネイティブアプリでしか出来なかったことがWeb で可能になっていく
- Webは様々なことが出来るように機能拡張し、成長し続ける
- 2.Reliable (信頼)
- ネットワークに影響されずに使用できる
- ネットワーク接続が出来ない、あるいは低速なネットワークでも使用できる
- ネットワークに影響されずに使用できる
- 3.Installable (インストール可能)
- ブラウザの中で動くのではなく独立したアプリとして動く
1.はともかく、2.と 3.は具体的ですね。
- Reliable
- Fast
- Engaging
という特徴を書いていたようなのですが、少し変わっているようです。
結局のところ、PWAとは
これまでの内容で、つまるところPWAとは
ネイティブアプリ的なユーザー体験を得られるWebアプリ
であり、
- ネットワーク状態に影響されず、
- 独立したアプリのように起動でき、
- 新しいWebAPIを採用し、機能的にネイティブアプリに大きく劣らないようにする
といった要素を満たしておくと、結果的にそうなるという感じでしょうか。
PWAに求められる、より具体的な内容はPWAのチェックリストを見ると分かります。
よりよいユーザー体験を提供しましょう的な内容が多い印象です。
PWAの対応状況
PWAはあくまでWebアプリなので、ブラウザによって対応状況が変わってきてしまいます。
What Web Can Do Todayというサイトでは、アクセスされたブラウザでどの機能が使えるか表示してくれます。
iOSでもプッシュ通知くらいは使えてほしいですが、iOS14でもまだ未対応です・・。
デスクトップ環境もPWAの対象に
PWAは元々モバイル環境をターゲットにしていたようですが、ChromeとEdgeはデスクトップ環境でもPWAに対応しています。
Chromeはversion67でPWAについての記載があります。
モバイルでPWA対応していたものは、特にデスクトップ用にコードを変更する必要はなく、そのままデスクトップPWAとして使えます。
PCでもPWAを利用できる状況になってきています。
[実践編] PWA化への第一歩
ここからは実践編です。今回は手始めに、PWAっぽくするために
- インストール可能にする
- オフラインで使用できる
をやってみます。
デスクトップPWAとしての使用をターゲットにします。
PWA化のために必要なこと - [Web App Manifest]と[Service Worker]
PWA化において欠かせない要素は、
の二つがあります。
manifest
manifestファイルはウェブアプリについての情報や、挙動についての設定が記したファイルです。
ダウンロードし、アプリとして動かすために必要な情報が記載され、中身はJSONです。
ユーザーが取得できるよう、Web上に配置します。
Service Worker
Service Workerはブラウザのメインスクリプト処理とは別に、バックグラウンドとして実行されるスクリプトです。
PWAの肝となる部分かと思います。
ServiceWorkerはWebアプリ(ブラウザ)とネットワークの間で動作します。
必要に応じてキャッシュからデータを取得するか、ネットワークへリクエストするかを制御できます。
オフライン時や、通信環境が悪い状況での実行に密接に影響しそうな機能ですね。
Webサイトからインストール出来るようにする - A2HS
アプリとしてホーム画面などに追加する機能を「A2HS」と呼びます。(Add-to-Home-Screen の略)
早速、これをやってみましょう。
サンプルアプリとWebサーバの準備
まず、何かWebアプリが必要です。
htmlとJavaScriptでごく簡単なクリック連打ゲームを作成しました。
これで試してみましょう。
GitHubにもあります。
クリック連打ゲームのソース
index.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>button mashing</title> <link rel="stylesheet" type="text/css" href="app.css" /> </head> <body> <div class="wrapper"> <div>目標クリック連打数:<span class="js-target-count">10</span></div> <div class="timer"> <span class="js-timer-label">経過時間:</span> <span class="js-spend-time"></span> </div> <div class="click-btn-wrapper"> <button class="js-click-target btn-click-target"> <span>Click!</span><br /> <span class="js-counter"></span><br /> </button> </div> <div> <button class="js-reset btn-reset">リセット</button> </div> <div class="change-count-wrapper"> クリック数を変更する <ul> <li> <button class="js-change-count btn-change-count" data-count="10"> クリック数:10回 </button> </li> <li> <button class="js-change-count btn-change-count" data-count="20"> クリック数:20回 </button> </li> <li> <button class="js-change-count btn-change-count" data-count="30"> クリック数:30回 </button> </li> </ul> </div> </div> <script src="app.js"></script> </body> </html>
app.css
.wrapper { padding: 10px; max-width: 400px; margin: 0 auto; text-align: center; font-size: 14px; } .click-btn-wrapper { margin: 10px auto; } .btn-click-target { display: inline-block; text-decoration: none; color: #668ad8; background-color: #fff; width: 80px; height: 80px; border-radius: 50%; border: solid 2px #668ad8; text-align: center; overflow: hidden; font-weight: bold; transition: 0.4s; } .btn-click-target:hover { background: #b3e1ff; } .change-count-wrapper { max-width: 240px; margin: 20px auto 0px auto; } .btn-change-count { position: relative; display: inline-block; font-weight: bold; padding: 0.25em 0.5em; text-decoration: none; color: #00bcd4; background: #dbebf8; border: none; transition: 0.4s; } .btn-change-count:hover { background: #00bcd4; color: white; } .header-change-count { border-bottom: solid 3px black; } ul, ol { padding: 0; text-align: center; margin: 3px 0px; } ul li { position: relative; list-style-type: none!important; padding: 0.3em 0.3em 0.3em 0.3em; margin-bottom: 1px; vertical-align: middle; } .btn-reset { position: relative; display: inline-block; font-weight: bold; padding: 0.25em 0.5em; text-decoration: none; color: #00BCD4; background: #ECECEC; border: none; border-radius: 0; transition: .4s; } .btn-reset:hover { background: #636363; }
app.js
let clickCount = 0; let playing = false; let targetCount = 10; let spendTime = 0; let timerId = 0; const clickTarget = document.querySelector(".js-click-target"); const resetBtn = document.querySelector(".js-reset"); const counterText = document.querySelector(".js-counter"); const targetCountText = document.querySelector(".js-target-count"); const spendTimeText = document.querySelector(".js-spend-time"); const timerLabelText = document.querySelector(".js-timer-label"); const countChanger = document.getElementsByClassName("js-change-count"); Array.prototype.forEach.call(countChanger, (btn) => { btn.addEventListener("click", (e) => { targetCount = btn.getAttribute("data-count"); targetCountText.innerHTML = targetCount; }); }); const start = () => { clickCount = 0; time = 0; playing = true; timerLabelText.innerHTML = "経過時間:"; counterText.innerText = clickCount; spendTimeText.innerHTML = time / 1000; timerId = setInterval(() => { time += 10; spendTimeText.innerHTML = (time / 1000).toFixed(2); }, 10); }; const complete = () => { playing = false; timerLabelText.innerHTML = "連打終了! 記録:"; clearInterval(timerId); clickTarget.disabled = true; setTimeout(() => (clickTarget.disabled = false), 2000); }; const reset = () => { playing = false; clickCount = 0; counterText.innerText = clickCount; spendTimeText.innerHTML = 0; timerLabelText.innerHTML = "経過時間:"; clickTarget.disabled = false; clearInterval(timerId); }; // 初回クリックでスタート clickTarget.addEventListener("click", () => { if (playing) return; start(); playing = true; }); // クリック時のカウントアップ clickTarget.addEventListener("click", () => { if (!playing) return; counterText.innerText = ++clickCount; if (clickCount >= targetCount) { complete(); } }); // リセットボタン resetBtn.addEventListener("click", () => reset());
これらhtml, css, jsファイルを配置したらローカルでwebサーバを起動してアクセスできるようにします。
(ここではnode.jsのhttp-server
を使いますが、なんでも構いません)
npm install -g http-server
ファイルを配置した場所で
http-server -p 8001
これでhttp://localhost:8001/index.html`にアクセスできるようにします。
※ちなみに、GitHub Pagesを使うとHTTPSでアクセスできるので楽です。
A2HSを実装する
やることはそう多くはありません。以下を実行すれば、インストールが可能になります。
- 1.manifest(Web App Manifest)を作成する
- htmlのヘッダにmanifest.jsonへのlinkを記述する
- 2.ServiceWorkerを登録し、fetchイベントをハンドリングさせる
この他にHTTPS経由で提供される必要があるようですが、localhostの場合はHTTPでもOKです。
1.manifestの作成
まずmanifestを作成していきます。
中身はJSONですが、拡張子は.webmanifest
にするべきなようです。が、.json
でも動きます。
ここではmanifest.webmanifest
として作成します。
manifest.webmanifest
{ "name": "Button mashing", "short_name": "Bm", "description": "クリック連打", "icons": [ { "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png" } ], "start_url": "./index.html", "display": "standalone", "background_color": "#FFFFFF", "theme_color": "#FFFFFF" }
ざっと項目の説明です。
- name: アプリの名称。
- icons: ホーム画面やデスクトップに表示するアイコン。192192と512512があると良いらしいです。(とりあえず適当に作るか拾う)
- start_url: インストールして実行するファイルのパス。
- display: 表示方法。アプリっぽく動かすには、
standalone
かfullscreen
を指定。fullscreenだとステータスバーなどのUIも表示されない。
パス系はmanifestファイルの場所からの相対パスです。
作成したら、index.htmlにこのmanifestへのリンクを記載します。
index.html
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>button mashing</title>
<link rel="stylesheet" type="text/css" href="app.css" />
+ <link rel="manifest" href="manifest.webmanifest" />
</script>
</head>
2.Service Workerの登録
SeriveWorkerの実態はJavaScriptです。
sw.js
にSeriveWorkerの本体を記載し、それをアプリ内から登録します。
sw.js
self.addEventListener('fetch', (e) => {})
インストールに必要なのは、このfetchイベントをハンドリングさせるだけです。コールバックは空で構いません。 (ServiceWorker内では、selfにServiceWorkerGlobalScopeがbindされています。)
これをアプリケーション内で登録します。
index.html
<head> <meta charset="utf-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>button mashing</title> <link rel="stylesheet" type="text/css" href="app.css" /> <link rel="manifest" href="manifest.webmanifest" /> + <!-- ServiceWorker --> + <script> + if ("serviceWorker" in navigator) { + window.addEventListener("load", function () { + navigator.serviceWorker.register("./sw.js").then( + function (registration) { + // Registration was successful + console.log( + "ServiceWorker registration successful with scope: ", + registration.scope + ); + }, + function (err) { + // registration failed :( + console.log("ServiceWorker registration failed: ", err); + } + ); + }); + } + </script> </head>
ここまで変更したら、localhost:8001/index.html
にアクセスします。
アドレスバーの右端に、+
のアイコンが追加されました。
クリックしてインストールしてみます。デスクトップにアイコンが追加され、起動してみると・・
起動できました。これでデスクトップに追加することができるようになりました。
オフラインでも実行できるようにする
アプリとして起動できるようにはなりましたが、このままではオフラインでは使用できません。
コンテンツをオフラインで使用できるようにするため、キャッシュ処理をService Workerに書かなければならないのですが、workboxを使うと簡単に試せます。
sw.jsにworkboxを利用したファイルのキャッシュ処理を書いてみましょう。
sw.jsに以下を追加します。
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.0.2/workbox-sw.js'); workbox.precaching.precacheAndRoute([ '/index.html', '/app.css', '/app.js' ]);
pre-cacheを使用しています。(インストール時にキャッシュされる機能)
本来はファイルにリビジョンを指定して、バージョン管理を行うことも必要ですが今回は割愛します。
キャッシュ処理を追加したところで、再度アプリをインストールし直してみます。(アンインストールはアプリ右上のメニューからできます)
http-serverを停止したり、オフライン状態でもインストールしたゲームを起動できるようになっています。
どういう仕組みになっているのか軽く確認してみましょう。
開発者ツールを見るとキャッシュされたファイルはService Workerから取得していることがわかります
cache storageに保存されています
これで、オフライン実行が有効になります。
PWA化への第一歩を踏み出せました。
おわりに
PWAとは何かの把握と実装の第一歩をやってみました。
機会があったらプッシュ通知あたりもやってみたいと思います。
PWA自体は対応するサイトも増えてきているようですが、普及に関してはiOSの対応が未だ不透明である点がネックですね。
とはいえiOSも徐々に対応機能を増やしてきてはいるので、今後も発展が見込めそうな技術かと思います。
エンジニア中途採用サイト
ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
ご興味ありましたら是非ご確認をお願いします。
https://career-recruit.rakus.co.jp/career_engineer/カジュアル面談お申込みフォーム
どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
以下フォームよりお申込みください。
forms.gleイベント情報
会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください! rakus.connpass.com