Edited at

Progressive Web Apps (PWA) 学習者のメモ その1  (Service Worker)


この記事について

PWAについての理解がふわっとしていたので、おさらいし直した内容を記しました。

2019年2月時点のメモとなりますが、誤記などありましたらどうぞご指摘ください。


Progressive Web Apps とは

Google が提唱・推進しているアプリ

https://developers.google.com/web/fundamentals/codelabs/your-first-pwapp/?hl=ja

概念はGoogle の説明を参照するとして、仕様面で見ると


  • Web技術 (html、css、JavaScript、および各種 Web API) を利用したアプリ

  • ウェブサイトで公開可能、Appストアを経由せずに配布できる

  • スマートフォンにインストール可能

  • Service Worker を利用し、アプリプログラムをキャッシュすることで、ネットにつながっていなくても動作可能


    • 通常のウェブアプリはネットへ接続できる状態を前提としている



  • プッシュ通知を使うことで、利用ユーザーに対する通知が可能

などの特徴を持つ。

ウェブ技術を使っているという点で、Apache Cordova を利用したハイブリッドアプリに近い。

ただし、PWAはウェブ経由で配布可能であり、パッケージ化するにあたって Apache Cordova などのライブラリは必ずしも必要としない。

ブラウザによって各種APIの実装状況は異なるため、すべてのブラウザ環境、スマホ環境で使えるわけではない。

(2019年2月の段階)


App Shellについて

https://developers.google.com/web/fundamentals/architecture/app-shell?hl=ja

PWAのコアにあたる。

特別な技術ではなく、「アプリが動く枠組み部分」「アプリ用テンプレート」と言った概念を言語化したもの。たとえば、下記のようなシンプルなhtmlでも「App Shell」になりうる。

<div id="main">のDOMに、各種情報をフェッチすれば、PWA の App Shell として成立する。


<!DOCTYPE HTML>
<html>
<head>
<title>App Shell</title>
</head>
<body>
<div id="main"></div>
</body>
</html>

Google の記事では、「App Shell はコンパクトであるべき」と言っている。シンプルな Shell ファイルをPWAのコアとして設計し、コンテンツとApp Shell を分けたほうが良い、とするようだ。

(明確な定義がないため「どこまでがシンプルなのか」は、実装者の判断になりうる)


Service Worker (サービスワーカー) について

https://developers.google.com/web/fundamentals/primers/service-workers/?hl=ja

ウェブページからの入力とは独立して、ブラウザのバックグラウンドで動作する。

プッシュ通知やバックグラウンド同期などを行う。

将来的にさらなる機能拡張が予定されている。

オフラインの状態でも何らかの形でアプリが動作するようサポートする機能が実装されている。

Promiseを多用するため、Promiseの概念・挙動を理解しておく必要がある。

Service Workerを利用するためには、明示的に有効化する必要がある。

下記はGoogle のチュートリアルに記載されている、Service Workerを有効化するサンプルコード。

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);
});
});
}

Service Worker が利用可能かどうかの判定を行い、利用可能であれば サービスワーカーの挙動を定義したJSファイル(ここでは sw.js) を登録する。

Service Worker の振る舞いは、Service Worker 登録用のファイルにJavaScript のコードとして記述する必要がある。


Push通知について

PWAユーザーに対して通知を送れる機能。

ユーザーに通知を送るAPIとしては

の2つがある。PWAの世界で一般的に「プッシュ通知」について説明する場合、後者のPush APIを利用したものを指す。

Push APIを利用すると、アプリを利用中のユーザーに対してプッシュ通知を送ることができるが、通知用のサーバーが必要になる。また、秘密鍵と公開鍵を作成して、アプリ側に公開鍵をセットする必要がある。

プッシュ通知はサーバーが必要なことから、Firebase を利用した実装を見ることが多い。

(必ずしもFirebaseである必要はないが、Firebaseを使う実装が手っ取り早い)


PRPL

PRPLパターン


  • Push: 最初の URL ルートに不可欠なリソースを Push(プッシュ)する

  • Render: 最初のルートを Render(レンダリング)する

  • Pre-cache: 残りのルートを Pre-cache(事前キャッシュ)する

  • Lazy-load: オンデマンドで残りのルートを Lazy-load(遅延読み込み)する。


PWAの対応ブラウザ

Service Worker、Fetch API、Push API が動作するブラウザ が PWA対応ブラウザ、と言っても良さそう。

各ブラウザの Service Worker の対応状況は以下が参考になる。2016年頃と比べると、ずいぶん対応が進んでいる。

https://caniuse.com/#search=Service%20Workers

Fetch APIの対応状況は以下

https://caniuse.com/#search=Fetch%20api

Push API

https://caniuse.com/#search=push%20api


実際のアプリ

簡単なPWA対応アプリを作り、挙動を見てみる。

アプリの仕様は以下とする。


  • Qiita の最新記事を確認できるアプリ

  • 立ち上げ時に、その時点の最新記事タイトルをリンク付きで表示する。

  • ボタンを押すと、QiitaのAPIを叩き、最新記事を再取得、再描画する。




PWA対応していないウェブアプリ

まずは、PWA対応をしていない、普通のウェブアプリの状態で作ってみた。

サンプルは以下。

https://newqiitapost.firebaseapp.com/v1/

コードは以下の通り。

index.html

<!DOCTYPE HTML>

<html lang="ja">

<head>
<meta charset="utf-8" />
<title>Qiita の最新投稿取得アプリ V1</title>
<link rel="stylesheet" href="./bootstrap.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./main.js"></script>
</head>

<body>
<div class="col-sm-3"></div>
<div class="col-sm-6">
<h1 class="text-center">Qiita の最新投稿取得アプリ V1</h1>
<h2 id="newitem" class="text-center"></h2>
<button id="button" onclick="getPost()" class="btn center-block">クリックして最新投稿を確認</button>
</div>
<div class="col-sm-3"></div>
</body>
</html>

JS(main.js)

"use strict";

getPost();
function getPost() {

fetch('https://qiita.com/api/v2/items')

.then(response => {

return response.json();

}).then(res => {

const title = res[0].title;
const url = res[0].url;
const data = `<a href="${url}">${title}</a>`;
document.getElementById("newitem").innerHTML = data;

}).catch(function (error) {

console.log(error);

});

}

cssは素のBootStrapをインクルード利用している。


スマートフォンにアプリとしてインストール可能にする

次に、スマホにインストールできるようにする。(Ver.2)

スマートフォンにインストールしてアプリのように利用するためには


  • manifest.jsonの設定

  • Service Worker 登録

の2つの設定が必要となる。あわせて、インストール時のアイコン画像も要求される。

https://developers.google.com/web/fundamentals/web-app-manifest/?hl=ja

https://developers.google.com/web/fundamentals/primers/service-workers/?hl=ja


manifest.jsonの設定

manifst.jsonでは


  • short name


    • ユーザーのホーム画面でテキストとして使用



  • name


    • ウェブアプリのインストール バナーに使用



の2つは必須。他に、以下のような情報を記述する。


  • icons


    • アプリのアイコン画像。スマホのホーム画面登録時、および起動時のスプラッシュ画面などに使われる。



  • start_url


    • 起動時のURL。省略可能だが、指定したほうが良い。省略した場合、登録時に表示されている画面のURLが起動画面となる



  • background_color


    • アプリ起動時のスプラッシュ画面の背景色



  • display


    • アプリの表示タイプ。「standalone」を指定すると、ブラウザのUIが非表示となり、アプリっぽい画面になる。「browser」を指定すると、ブラウザのUIが利用される。



  • orientation


    • 指定すると、アプリ使用時の画面の向きを矯正する。「landscape」を指定するとスマホを横向けにした操作画面となる。「portrait」を指定すると縦向けの操作となる。



他にもいくつかある。

これを踏まえて、manifest.jsonを作成。以下は例。

{

"short_name": "Qiita の最新投稿取得アプリ V2",
"name": "Qiita の最新投稿取得アプリ V2",
"icons": [
{
"src": "icon-192-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "icon-512-512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "index.html",
"display": "standalone",
"orientation": "portrait",
"background_color": "#00C721F"
}


Service Worker を利用したデータキャッシュ

次に、Service Worker 登録用のJSを作成する。

Service Worker 登録用のファイルでは大まかに言って


  • キャシュするファイルの定義

  • スコープの定義

  • キャッシュのインストール、フェッチ、アクティベートの設定

などを行う。

先に作ったVer1のウェブアプリでは


  • index.html

  • main.js

  • bootstrap.min.css

の3ファイルが App Shell を構成するファイルだった。これらをService Worker にキャッシュするよう指定する。

index.html。head内で 「manifest.json」を読み込んでいる。

<!DOCTYPE HTML>

<html lang="ja">

<head>
<meta charset="utf-8" />
<title>Qiita の最新投稿</title>
<link rel="stylesheet" href="./bootstrap.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./main.js"></script>
<link rel="manifest" href="./manifest.json">
</head>
<body>
<div class="col-sm-3"></div>
<div class="col-sm-6">
<h1 class="text-center">Qiita の最新投稿</h1>
<h2 id="newitem" class="text-center"></h2>
<button id="button" onclick="getPost()" class="btn center-block">クリックして投稿情報を確認</button>
</div>
<div class="col-sm-3"></div>
</body>
</html>

main.jsに、Service Worker の登録を行うスクリプトを追記。

"use strict";

registSW();
getPost();

function registSW() {

// Service Worker 対応ブラウザの場合、スコープに基づいてService Worker を登録する

if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('./sw.js', { scope: './' }).then(function (registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}, function (err) {
console.log('ServiceWorker registration failed: ', err);
});
});
}
}

function getPost() {

fetch('https://qiita.com/api/v2/items')
.then(response => {
return response.json();

}).then(res => {

const title = res[0].title;
const url = res[0].url;
const data = `<a href="${url}">${title}</a>`;
document.getElementById("newitem").innerHTML = data;

}).catch(function (error) {
console.log(error);
});
}

Service Worker インストール用のファイル sw.js


// Service Worker のバージョンとキャッシュする App Shell を定義する

const NAME = 'qiita-post-app-v2-';
const VERSION = '002';
const CACHE_NAME = NAME + VERSION;
const urlsToCache = [
'./index.html',
'./main.js',
'./bootstrap.min.css',
];

// Service Worker へファイルをインストール

self.addEventListener('install', function (event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function (cache) {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});

// リクエストされたファイルが Service Worker にキャッシュされている場合
// キャッシュからレスポンスを返す

self.addEventListener('fetch', function (event) {
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin')
return;
event.respondWith(
caches.match(event.request)
.then(function (response) {
if (response) {
return response;
}
return fetch(event.request);
})
);
});

// Cache Storage にキャッシュされているサービスワーカーのkeyに変更があった場合
// 新バージョンをインストール後、旧バージョンのキャッシュを削除する
// (このファイルでは CACHE_NAME をkeyの値とみなし、変更を検知している)

self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.map(key => {
if (!CACHE_NAME.includes(key)) {
return caches.delete(key);
}
})
)).then(() => {
console.log(CACHE_NAME + "activated");
})
);
});

実際に動くサンプルはこちら。

https://newqiitapost.firebaseapp.com/v2/

このURLをAndroidのブラウザ(Chrome)で見ると、ホーム画面に登録するか (=インストールするか) を聞いてくる。

ホーム画面に登録すると、iconsで指定した画像が表示。

アイコンをクリックするとPWAが起動する。スプラッシュ画面のバックグラウンドカラー、および表示アイコンは、manifest.jsonで指定された内容が反映される。





Android 機でインストールした PWAは、アプリ管理画面上でアンイストールができる。ホーム画面のアイコンを削除しただけでは、アンインストールされない。





スマホをオフラインにした状態。ブラウザでアクセスしている画面は表示ができないが、PWAはオフラインでも起動が可能。










Service Worker のキャッシュ更新

今回のサンプルでは、Service Worker に登録した Cache Storage のkeyに「CACHE_NAME」を利用している。

Cache Storage のkey に変更があった場合 (=Service Worker ファイルに変更があった場合)


  • 新しいService Worker のファイルをキャッシュ

  • 古いキャッシュファイルの削除

を行う。ブラウザが開いている間はキャッシュの更新をペンディング状態にして待ち、次にブラウザを開きなおしたタイミングで新しいキャッシュの反映と、古いキャッシュの削除を行う、

以下は、Chrome の拡張機能で見たService Workerのキャッシュ入れ替えの様子。

新しいデータがキャッシュされるとともに、Cache Storage の入れ替えを待っていることがわかる。

sw_refresh.png

CacheStorage に同じアプリからキャッシュされた2つのデータが存在するが、ブラウザ・アプリを閉じてからもう一度開き直すと、古いキャッシュが削除され、新しいキャッシュが登録される。


オフライン時でもコンテンツデータが見れるようにする

オフライン状態でコンテンツを表示するためには、コンテンツデータをキャッシュする必要がある。

オフライン状態でも、コンテンツが見れるようにアプリを修正する。

以下は、Google のチュートリアル記事

https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/offline-for-pwa?hl=ja



  • URL 指定可能なリソース (=App Shell) は、Cache API(Service Worker の一部)を使用

  • その他のすべてのデータには、(Promise ラッパーで)IndexedDB を使用


をそれぞれ推奨するとの記述がある。

IndexedDBは、NoSQLライクなブラウザ用のローカルストレージ。


IndexedDBを利用したコンテンツキャッシュを行いオフライン対応する

IndexedDBを利用して、オフラインで直前のキャッシュデータを表示するVer.3 を開発する。仕様は以下とする。


  • Qiita の最新記事を確認できるアプリ

  • 立ち上げ時に、その時点の最新記事タイトルをリンク付きで表示する。

  • ボタンを押すと、QiitaのAPIを叩き、最新記事を再取得、再描画する。



  • PWAとしてスマホにインストール可能

  • スマホインストール後、オフラインの状態のときは、直近に取得した記事情報を表示する。オンラインに変わったら、キャッシュした記事情報を削除して、最新の記事に入れ替える。

  • 以後、Qiitaから最新記事を取得するたびに、IndexedDBのデータを入れ替える。

今回は、IndexedDBのラッパー、localforageを利用してデータの管理を行うことにした。


localForage について

localForage は WebStorage (SessionStorage、LocalStorage)のようなコードでIndexedDBが使える、IndexedDBのラッパー。使いやすい。Mozzila財団がメンテナンスをしている。

localForageのレポジトリはこちら

https://github.com/localForage/localForage

localForageのドキュメントはこちら

https://localforage.github.io/localForage/


localForageの使い方

htmlで使う場合、

<script src="./localforage.js"></script>

という形でスクリプトを読み込む。

localForage の初期設定は「config」コマンドで行う。


  • IndexedDBに「qiitadb」という名前のデータベースをつくる

  • qiitadb内に「postJson」という名前のデータストアをつくる


    • (データストアは、RDBMSでいうテーブルに当たる)



という場合、以下のように指定する。

localforage.config({

name: 'qiitadb',
storeName: 'postJson',
});

configを記述しなくても、localForage は使うことができるが、データベース名は自動的に「localforage」、データストア名は「keyvaluepairs」に指定される。

同じオリジンで複数のアプリを動かし、かつlocalForageの初期設定を行わない場合、名前空間が重なって同じデータベース・データストアに異なるアプリのデータを保存することになるため、初期設定はしたほうが良いと思う。


localForage を使ったサンプル

実際に動いているサンプルはこちら。

https://newqiitapost.firebaseapp.com/v3/

Ver.2からの変更点は、main.js。取得してきたQiitaの記事を、localforage経由でIndexedDBに保存するようにした点。


  • 関数 getPostで、Qiita のAPIからレスポンスを取得

  • 通信が成功した場合、最新の記事を表示して、IndexedDBにでーたを保存する。


    • key は「qiita」、valueはQiitaの記事をJSONのまま登録



  • 通信が失敗した場合、関数 displayLocalを呼び出し


    • IndexedDBにデータが存在する場合、そのデータを呼び出して表示

    • IndexedDBにデータがない場合、再通信を促すメッセージを表示する



という流れで書いてみた。

index.htmlのヘッダーに、localforageを読みこむ一行を追加している。

main.js

"use strict";

registSW();
getPost();

localforage.config({
name: 'qiitadb',
storeName: 'postJson',
});

function registSW() {

if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('./sw.js', { scope: './' }).then(function (registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}, function (err) {
console.log('ServiceWorker registration failed: ', err);
});
});
}
}

function getPost() {

fetch('https://qiita.com/api/v2/items')

.then(response => {

return response.json();

}).then(res => {

const title = res[0].title;
const url = res[0].url;
const data = `<a href="${url}">${title}</a>`;
document.getElementById("newitem").innerHTML = data;
localforage.setItem('qiita', res[0]);

}).catch(function (error) {

displayLocal();

});
}

function displayLocal() {

localforage.getItem('qiita').then(cache => {

const title = cache.title;
const url = cache.url;
const data = `<a href="${url}">${title}</a>`;
document.getElementById("newitem").innerHTML = data;

}).catch(function (err) {

console.log(err);
const data = '<p>通信状況の良い場所でお試しください。</p>';
document.getElementById("newitem").innerHTML = data;

});
}

IndexedDBを確認すると、データベース名が「qiitadb」、データストアが「postJson」と指定されていることがわかる。






プッシュ通知

別稿として公開しました。

Progressive Web Apps (PWA) 学習者のメモ その2 (Push APIとFCM)


備忘録


PWAが使えるディスク容量

PWAにおける各ブラウザの容量上限は以下

(2019/2時点 Googleの記事より引用)


  • Chrome


    • 空き領域の 6% 未満



  • Firefox


    • 空き領域の 10% 未満



  • Safari


    • 50MB



  • IE10


    • 250MB



将来的に変わる可能性はあると思う。また、スマホ用のブラウザは不明。その時点で最新の情報をご確認ください。

IndexedDBは以下のようなテスト結果があった

http://iwatendo.hateblo.jp/entry/2018/02/15/215811


App Shell の規模感とキャッシュのメリット・デメリット

PWA、そしてService Worker を利用したキャッシュは便利な反面、のべつ幕なしに全ファイルをキャッシュしようとするとバグの温床になりそう。

特に、多量のファイルおよびデータをすべてキャッシュさせようとすると、必要以上にブラウザ、そしてデバイスのディスク容量を圧迫する。

どこまでをキャッシュさせ、どこまでは動的にフェッチするかは、PWAを開発する上でとても重要だろう。


PWAのデバッグ

Chrome の検証ツールに「Application」というタブがあり、このタブを使うことでPWAの各種シミュレーションやデバッグが行える。詳しくは下記の記事「Service Worker のデバッグ」を参照。

https://developers.google.com/web/fundamentals/codelabs/debugging-service-workers/?hl=ja


PWAの公開前チェック

Chromeの拡張機能となるが、Lighthouseを使うと、公開前のウェブアプリが一通りチェックできる。

https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/offline-for-pwa?hl=ja

Google の記事

https://developers.google.com/web/tools/lighthouse/

Qiita のLighthouse タグ

https://qiita.com/tags/lighthouse


各種学習リソース

Google のチュートリアル。網羅的で、実際のサンプルコードもあり、非常に勉強になる。自分のようなPWA初学者は必読

https://developers.google.com/web/fundamentals/primers/service-workers/?hl=ja

https://developers.google.com/web/fundamentals/codelabs/your-first-pwapp/?hl=ja

https://developers.google.com/web/fundamentals/codelabs/debugging-service-workers/?hl=ja

https://developers.google.com/web/fundamentals/codelabs/offline/?hl=ja

https://developers.google.com/web/fundamentals/codelabs/push-notifications/?hl=ja

Google のドキュメントはどうしてもGoogle が提唱する環境中心の記述になるため、MDNも合わせて読むとより理解が深まる。

https://developer.mozilla.org/ja/docs/Web/Apps/Progressive

Mozzila によるレシピブック集

https://serviceworke.rs/

IndexedDBのリファレンス

https://www.w3.org/TR/IndexedDB/

https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API

https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API/Using_IndexedDB

localforgeのリファレンス

https://localforage.github.io/localForage/

今回のサンプルではlocalForageを利用したが、IndexedDBの操作に使えるライブラリは他にもある。

以下はMDNからの引用



  • Dexie.js


    • 優良でシンプルな構文により高速なコード開発を可能にする、IndexedDB のラッパーとのこと




  • ZangoDB


    • MongoDB ライクなIndexedDB用インターフェイス




  • MiniMongo


    • MeteorJSで使われているらしい



  • PouchDB

日経電子版のPWA化に関する記事

https://employment.en-japan.com/engineerhub/entry/2018/06/05/110000


PWAのアプリ例

Starbucks

https://app.starbucks.com/

Google Maps GO

https://play.google.com/store/apps/details?id=com.google.android.apps.mapslite&hl=ja

日経電子版

https://www.nikkei.com/

事例サイト

https://pwa.rocks/

「これもPWAなの?」と驚いたPWAを2つ紹介


Pokedex.org

https://pokedex.org/

カントー地方のポケモンデータベース。画像はCSSスプライトで、ポケモンのデータはIndexedDBで管理されている。


アイドルマスターシャイニーカラーズ

https://shinycolors.enza.fun

https://shinycolors.idolmaster.jp/

ブラウザゲーですが、Android機ではPWA対応アプリとして、実機にインストールして使える。

キャラクターのアニメーションなど、ウェブ技術でここまで作り込めるのか、と思いました(小並感)。


その他

Qiita のAPIを何度も叩いてわかったことは、想像以上にスパム投稿が多いのだな、ということ。人気投稿サイトは狙われやすいのですね。

運営者の皆様、お疲れ様です。