knowledge base

マークアップ/フロントエンドエンジニアのWEB制作における備忘録です。平日はWEB屋、休日は社会人劇団の主宰・劇作家をしています。

【PWA】シリーズPWA (7) 暗号化してみよう

VAPID

やっぱり通知内容をカスタマイズしたい。 今回はそのために、VAPIDと呼ばれる仕組みを利用します。

必要なものは、Firebase Consoleから取得できる公開鍵と秘密鍵のペア。

SSHのようにこのキーペアを利用します。

利用する箇所は(1)通知登録時のPushManager (2)メッセージングサービスに通知を送るプログラム の2箇所です。

 

まず、Firebase Consoleからキーペアを取得します。

取得方法はUIの更新などあるため、今回も割愛しますが、以下のようなbase64形式のキーが取得できるはずです(セキュリティ上、...で省略しています)

公開鍵: BGz_P8oc2IJUlpLst ……
秘密鍵: 85FdjzL1HH52YtvFluR ……

ここまで取得できたら、まずmain.jsから変えてゆきましょう。

pushManager.subscribeにわたすオプションに項目を追加します。

navigator.serviceWorker.ready.then(function(registration) {
    return registration.pushManager.getSubscription().then(function(subscription) {
       if (subscription) return subscription;
        return registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: convertedVapidKey
        }).then(function (subscription) {
            console.log(subscription.endpoint);  //この値の「https://fcm.googleapis.com/fcm/send/」以下の文字列がトークンになります。
        });
    })
});

applicationServerKeyという項目が追加されたのはわかりましたが、convertedVapidKeyという新登場の変数がありますね。

こちらの変数に格納する値を設定します。 先ほどFirebase Consoleから取得した公開鍵を埋め込んでいる箇所がありますので、ご確認ください。

// Base64形式をUnit8形式に変換する
var urlBase64ToUint8Array = function(base64String) {
  var padding = '='.repeat((4 - base64String.length % 4) % 4);
  var base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
  var rawData = window.atob(base64);
  var outputArray = new Uint8Array(rawData.length);
  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}
var vapidPublicKey = '******';  // Firebase Consoleから取得した公開鍵を指定
var convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);

urlBase64ToUint8Arrayという関数がありますがここでは説明を割愛します。

スニペット的なものと捉えてください。

まとめると以下のようになります。

// Base64形式をUnit8形式に変換する
var urlBase64ToUint8Array = function(base64String) {
  var padding = '='.repeat((4 - base64String.length % 4) % 4);
  var base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
  var rawData = window.atob(base64);
  var outputArray = new Uint8Array(rawData.length);
  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}
var vapidPublicKey = '******';  // Firebase Consoleから取得した公開鍵を指定
var convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);

navigator.serviceWorker.ready.then(function(registration) {
    return registration.pushManager.getSubscription().then(function(subscription) {
        if (subscription) {
            //viewSubscription(subscription);
            return subscription
        }
        return registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: convertedVapidKey
        }).then(function (subscription) {
            console.log(subscription.endpoint);  //この値の「https://fcm.googleapis.com/fcm/send/」以下の文字列がトークンになります。
        });
    })
});

更に一手間加えます。

暗号化前は購読時に取得できるendpointの値をpush.jsに埋め込んでいましたが、今回は追加で値を取得して埋め込みます。

// Base64形式をUnit8形式に変換する
var urlBase64ToUint8Array = function(base64String) {
  var padding = '='.repeat((4 - base64String.length % 4) % 4);
  var base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
  var rawData = window.atob(base64);
  var outputArray = new Uint8Array(rawData.length);
  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}
var vapidPublicKey = '******';  // Firebase Consoleから取得した公開鍵を指定
var convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);

navigator.serviceWorker.ready.then(function(registration) {
    return registration.pushManager.getSubscription().then(function(subscription) {
        if (subscription) return subscription
        return registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: convertedVapidKey
        }).then(function (subscription) {
            var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
            var key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
            var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
            var auth = rawAuthSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
            var endpoint = subscription.endpoint;
            console.log(key);
            console.log(auth);
            console.log(endpoint);
        });
    })
});

keyとauthという値を取得しています。

サーバーのキーペア以外にも、この端末へ通知を届けるためのサブスクリプションのキーペアが存在し、そちらを取得しています。

こちらもスニペット的に機械的に利用してしまったほうが今は幸せかもしれません。

メッセージングサービスに暗号化した情報を送る

push.jsはガラリと変わります。

web-pushモジュールを使用しますので、あらかじめnpmでインストールください。

www.npmjs.com

const webpush = require('web-push');

webpush.setVapidDetails(
  'admin@xxxxx.jp',
  '*******', // Firebase Consoleから取得した公開鍵を指定
  '*******'  // Firebase Consoleから取得した秘密鍵を指定
);

// 複数指定可能 サブスクリプションが有効であればここに登録した全てのエンドポイントに通知が送られます
const subscribers = [{
  endpoint: '*******', // main.jsのconsoleに表示させたエンドポイントを指定
  expirationTime: null,
  keys:{
    p256dh: '*******', // main.jsのconsoleに表示させたkeyを指定
    auth: '*******' // main.jsのconsoleに表示させたauthを指定
  }
}];

// 通知の内容
const params = {
  title: 'プッシュ通知です!',
  msg: 'これはサーバから送っています.メッセージとアイコンも送っています ',
  icon:  '/icon/launcher-icon-144x144.png'
};

// 通知を送信
Promise.all(subscribers.map(subscription => {
  return webpush.sendNotification(subscription, JSON.stringify(params), {});
}))
.then(function(res) { console.log(res); })
.catch(function(err) { console.log(err); });

また、serviceworker.jsでは前回通知内容に固定の文字列を渡していたので、以下に変更します。

self.addEventListener('push', function(event) {
  var data = event.data.json();
  var title = data.title;
  var icon = data.icon;
  var msg = data.msg;
  event.waitUntil(
    self.registration.showNotification(title, {
      icon: icon,
      body: msg
    })
  )
});

暗号化前と同様にコンソールからこのプログラムを実行することで通知が送られるはずです。

通知のメッセージやタイトルがこちらで指定した内容になっていることも確認いただけると思います。

次回予告

次回は、この仕組みの上で複数端末に同時に通知を送る方法について解説します。

【PWA】シリーズPWA (6) プッシュ通知を送ってみる

はじめに

WebPushと呼びます。 ServiceWorkerを使わなくてもJavascriptでプッシュ通知自体は実装できます。

しかしそれはサイトをブラウザ表示したときのみ通知されるもので、そうでなくても送られるプッシュ通知とは異なるものです。

(厳密にいうと参照先のオブジェクトが異なります。通常の通知機能はNotificationオブジェクトを利用しますが、プッシュ通知機能はServiceWorkerRegistration.showNotificationメソッドを利用します。ただ引数はNotificationオブジェクトのコンストラクタと同じだとか。プッシュ通知を実装場合は、後者を使います)

ただし、ネイティブアプリケーションのそれとは異なり、WebPush によるプッシュ通知はブラウザが起動しているときでないと受信できません。

ブラウザを起動していない間にアプリケーションサーバーからプッシュ通知が送信された場合は、その次にブラウザを起動したタイミングで受信されます。

今回はキャッシュ制御よりも若干複雑になりますが、一つ一つをゆっくり紐解きつつ丁寧に追ってゆきます。

前提知識としてのWebPush APIの仕様やPushManagerの仕様についてはあえて解説はしませんので、適宜下記のリファレンスや参考記事等も合わせてご覧ください。

developer.mozilla.org

developer.mozilla.org

developers.google.com

qiita.com

基本的な仕組み

このような流れになります。

  1. メッセージングサービスでプロジェクトの登録
  2. manifest.jsonにプロジェクトとの紐付けを記述
  3. クライアント側ではServiceWorker登録時に通知の購読をしておく
  4. メッセージングサービスに対してリクエストを送る。この時にメッセージングサービスを利用するための認証情報やエンドポイントなどをリクエストヘッダーに混ぜて送る。この時に正しく暗号化されていれば通知に表示するメッセージなどをpayloadで送ることもできる。
  5. メッセージングサービスが、送信されてきたエンドポイントへ通知を送る。
  6. ServiceWorkerが通知を受け取ったら、showNotificationメソッドを利用してクライアントアプリ(ブラウザ)に通知

フロントエンドでの対応が必要となるのはこのうち 1 / 2 / 6 です。 特に 4 はバックエンドの開発やDB・インフラ設計が必要になります。

なにを用意すればWebPushが利用できるの?

先述の通りフロントエンドのみでは完結しません。WebPush実装に必要なものは次の3要素です。

  • プッシュサーバ:メッセージングサービスを提供するサーバ。インフラやサービスを構築する代わりにFirebaseが提供するFirebase Cloud Messagingを利用するのが近道。
  • アプリケーションサーバ:プッシュサーバに通知をリクエストするサーバ。今回でいうとHTMLをアップしているWebサーバーです。
  • クライアントアプリ:HTMLとServiceWorker。

アプリとメッセージングサービスを紐付けましょう

まずはアプリとメッセージングサービスとを紐づけるために、 というのもこれを最初に行なっていないと(後述するvapidを使用していない実装方法の場合) 通知を許可するダイアログを表示することも、通知を購読したり表示するロジックの検証をすることもできません。

現状メッセージングサービスはFirebase Cloud Messaging一択と言っても差し支えないので、Firebaseでプロジェクトを登録します。

プロジェクトの登録や必要な情報を取得するためのUIや手順は、各種サードパーティのコンソールと同様に頻繁に変更されるのでここでは割愛します。

無事に取得できたら、manifest.jsonにてgcm_sender_idというキーに対応する値としてメッセージングサービスに登録したプロジェクトの送信者ID番号を記述します(ここでは01234567が送信者ID番号であると仮定しています)。

"gcm_sender_id" : "01234567"

これでアプリとメッセージングサービスが紐づいたので、通知を許可するダイアログの表示と、通知の購読を実装します。

通知を許可するダイアログの表示と、通知の購読

こちらは至極シンプルで、ServiceWorkerRegistration.pushManager.subscribeメソッドを利用します。

基本的にプッシュ通知まわりはこのPushManagerが提供するインターフェイスを利用すことになります。

ServiceWorkerが登録されるとPushManagerも利用できるため、そのタイミングでsubscribeメソッドを呼び出します。

navigator.serviceWorker.ready.then(function(registration) {
	return registration.pushManager.subscribe({
		userVisibleOnly: true
	}).then(function (subscription) {
		console.log('通知の購読を開始します');
	});
});

subscribeメソッドを実行した時にメッセージングサービスとアプリが紐づいていれば、通知の許可をたずねるダイアログが表示され、許可したら購読が始まります。

ですがこのままだと、次回以降の来訪時にも、通知の購読を行なってしまうため、すでに購読済みの通知があるかを判定します。

こちらはmain.jsに記述をします。

navigator.serviceWorker.ready.then(function(registration) {
	return registration.pushManager.getSubscription().then(function(subscription) {
		if (subscription) return subscription;
		registration.pushManager.subscribe({
			userVisibleOnly: true
		}).then(function (subscription) {
			console.log('購読を登録しました');
		});
	})
});

getSubscriptionメソッドにて、すでに購読済みの通知があるかどうか判定します。

その際に購読済みの通知がある場合はそこで終了し、なければ先述した通り購読の開始を行います。

こちらで、通知を許可するダイアログの表示と、通知の購読が実装できました。

通知を表示する

実際に通知が来た時に、通知を表示するのはServiceWorkerの役割です。

通知がくるとpushイベントが発火するため、serviceworker.jsに以下のように記述します。

self.addEventListener('push', function(event) {});

ここに通知が来たときの処理を追記してゆきます。

eventオブジェクトにて(然るべき暗号化を行なっていれば)通知の内容を参照できるのですが、今回は暗号化については割愛するので、eventオブジェクトは参照せず、固定のタイトルやテキストを表示させることにします。

self.addEventListener('push', function(event) {
	self.registration.showNotification('PWA Test', {
		icon: '/icon/launcher-icon-144x144.png',
		body: 'Lorem ipsum dolor sit amet, consectetur adipisicing elit'
	})
})

通知の表示はshowNotificationメソッドで行います。

第一引数に通知のタイトルを、第二引数にアイコンとテキストなどを指定することができます。

これで、メッセージングサービスから通知が来た時に、ブラウザにその内容を表示することができます。

ちなみに、もし然るべき暗号化がされており、eventオブジェクトに通知の内容が格納されている場合は、以下のようにすることができます。

self.addEventListener('push', function(event) {
	var data = event.data.json();
	var title = data.title;
	var icon = data.icon;
	var body = data.body;
	self.registration.showNotification(title, {
		icon: icon,
		body: body
	})
})

アプリケーションサーバーからメッセージングサービスへリクエストを送る(からの、送って通知を表示する)

今回はまだ実装も初期段階なので、

(1)直接プログラムを叩いてリクエストを送ります

(2)また複数端末に同時に通知を送ることはせず自身の端末にのみ通知を送ります。

理由は(1)(2)ともバックエンドの開発が必要になるためです。 比較的実装が楽なNode.jsにて(1)のプログラムを「push.js」というファイル名で作成してみます。

//requestモジュールをrequire
var request = require('request');

//オプションを定義
var url = 'https://fcm.googleapis.com/fcm/send';
var serverkey = '*****'; // Firebaseコンソールから取得できるサーバーキー
var token = '*****'; // 購読登録時にエンドポイントから取得できるトークン(端末ひとつひとつを識別する)

// HTTP header
var headers = {
	'Content-Type': 'application/json',
	'Authorization': 'key=' + serverkey
};

// request options
var options = {
	url: url,
	method: 'POST',
	headers: headers,
	json: {
		'to': token
	}
};

//リクエスト送信
request(options, function(error, response, body) {
	if (body) {
		console.log(body);
	}
	if (error) {
		console.log(error);
	}
});

こちらが実装できたらコンソールから node push.js で実行することで通知が行くはずです。

ちなみにrequest optionsとしてわたすjsonには通知内容となるpayloadも送れると書かれている記事がありますが、現在は(暗号化しないとpayloadをおくれないという)仕様変更により割愛しています。

さてここでサーバーキーはFirebaseコンソールから取得できるとして、トークンはどうやって取得するのでしょうか。

main.js側に一手間加えます。

navigator.serviceWorker.ready.then(function(registration) {
	return registration.pushManager.getSubscription().then(function(subscription) {
		if (subscription) return subscription;
		registration.pushManager.subscribe({
			userVisibleOnly: true
		}).then(function (subscription) {
			console.log(subscription.endpoint);  //この値の「https://fcm.googleapis.com/fcm/send/」以下の文字列がトークンになります。
		});
	})
});

コンソールに、subscription.endpoint プロパティを出力しています。

Push API の PushSubscriptionにはその端末を識別するエンドポイントや、このあと暗号化に用いる認証キーなどが格納されています。

ちなみにこのエンドポイントは端末によって異なりますので、複数の端末に通知を送りたい時は、全てのエンドポイントをDBなどで管理する必要があります。

今回の実装でいうと、コンソールに出力したエンドポイントからトークンとなる文字列をコピーし、push.jsに貼り付けることになります。

ちなみに

予備知識として補足

Basic認証が設定されていても通知は届く?
届きます。ただしミニ情報バーの実装について触れている通り、manifest.jsonを正しく読めているようにしましょう(マニフェストのみ認証を解除するか、usecredential属性を設定)
また、OSの通知UIがアイコンを取得しにいけない場合があるので、アイコンが置かれているディレクトリは認証を解除してあげたほうがベターです
通知のサブスクリプションが変わるタイミングはいつ?
以下のとおりです。
(1)通知のパーミッションに変更があった時
(2)ServiceWorkerがUnregisterされた時
意外にもServiceWorkerにアップデートがあった時は起こらないよう。

次回予告

次回は通知内容をカスタマイズするため、暗号化について触れたいと思います。

【PWA】シリーズPWA (5) キャッシュを制御して高速化やオフライン対応をしてみる

ServiceWorkerを動かすための最低限のベースができたので、ServiceWorkerに仕事をさせてみましょう。

まずは、メジャーな実装となるキャッシュ制御、つまるところ高速化やオフライン対応の実装を行います。

リクエストがあったらキャッシュを返すようにしてみる

今回の肝となる仕組みです。

ちなみに以前策定されていたHTML5のApplication Cache APIは、セキュリティ的な理由により廃止されました。

基本的な仕組み

  1. Cache API を用いて、指定されたファイルのキャッシュを構築する。ServiceWorkerのinstall時(installイベントのコールバック)にて行うのがお作法なよう。
  2. ブラウザからのリクエストがきたときに、そのリクエストを横取りして、構築してあったキャッシュからリクエストと同じURLのリソースを返す。リクエストの横取りはfetchイベントのコールバックで行うことができる。
  3. ServiceWorkerが更新されたとき、またはキャッシュしたいファイルに変更があった時に、古いキャッシュを破棄する。

Cache API

このAPIの概要や仕様はこちらを参照ください。

developer.mozilla.org

キャッシュの構築

新しいキャッシュを作るための CacheStorage.open の呼び出しと、キャッシュを追加するための addAll() の使用で構成されています。

caches.open('cache-v1').then(function(cache) {
  return cache.addAll([
    '/index.html',
    '/style.css',
    '/app.js',
    '/images/logo.jpg'
  ]);
});

インストール時に構築するのがお作法らしいので、こちらをinstallイベントに紐付けます。

self.addEventListener('install', function(event) {
   caches.open('cache-v1').then(function(cache) {
     return cache.addAll([
        '/index.html',
        '/style.css',
        '/app.js',
        '/images/logo.jpg'
     ]);
   })
});

addAllメソッドはPromiseを返すため(つまり全てのキャッシュを追加した終わったら、というタイミングが取れる)、addAllが完了するまで次のライフサイクルに遷移しないようにします。

そのため、waitUntilメソッドでラップします。

self.addEventListener('install', function(event) {
 event.waitUntil(
   caches.open('cache-v1').then(function(cache) {
     return cache.addAll([
        '/index.html',
        '/style.css',
        '/app.js',
        '/images/logo.jpg'
     ]);
   })
 );
});

キャッシュが追加されたかどうかは、Google Chromeの開発者ツールでApplicationタブ「 Cache > Cache Storage」から確認できます。

f:id:ShinImae:20190810155415p:plain

リクエストを横取りする

ServiceWorkerはブラウザとサーバーの間に立つものなので、ブラウザからのリクエストを横取りできる。

横取りした際に、先に構築したキャッシュのうちリクエストとマッチするものをレスポンスとして返すことができる。

ブラウザからのリクエストを受けた時に処理を紐づけるにはfetchイベントを使います。

fetchイベント

このイベントの仕様についてはこちらを参照ください。

developer.mozilla.org

self.addEventListener('fetch', function(event) {
  // ブラウザからのリクエストを横取りできる。
  // リクエストに対して何もしない場合は単にreturnするだけでよい。
  return;
});
コード中の引数eventにはFetchEventオブジェクトが渡ってきます。このFetchEventの.respondWith()メソッドを使うことで、ブラウザに対するレスポンスをすり替えられます。

とのことなので、たとえばどんなリクエストに対しても’Hello World'という文字列を返すこともできます。

self.addEventListener('fetch', function(event) {
  return event.respondWith(new Response('Hello world'));
});

ちなみに、respondWithメソッドの仕様についてはこちらを参照ください。

developer.mozilla.org

この要領で、キャッシュの中からリクエストのURLにマッチするものを探し出してreturnをすれば良いのです。

matchメソッドについては先のリファレンスを参照ください。

リクエストされたURLは、fetchイベントのコールバック関数内でevent.requestで参照できます。

それをもとに、キャッシュの中からリクエストにマッチするものを探し出す方法は次の通り。

caches.open('cache-v1').then(function(cache){
  return cache.match(event.request).then(function(response) {
    return response;
  });
})

ただ、もしキャッシュの中にリクエストされたURLがなかったときはネットワークエラーとなってしまいます。

そこでもしキャッシュの中にリクエストされたURLがなかったときは、リクエストされたURLをネットワークに取得しにゆきます。

caches.open('cache-v1').then(function(cache){
  return cache.match(event.request).then(function(response) {
    return response || fetch(event.request);
  });
})

取得にはfetch(ServiceWorkerのfetchイベントとは異なるのでご注意を)を用います。

event.request は先述したfetchイベントのコールバック関数内で参照できる変数であることを思い出してください。

これらを組み合わせると、次のようになります。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('cache-v1').then(function(cache) {
      return cache.match(event.request).then(function(response) {
        return response || fetch(event.request);
      });
    })
  );
});

これで、リクエストがキャッシュと同じであれば、サーバーではなくキャッシュを返すようになりました。

また、さらにもう一手間かけるとするならば、リクエストされたURLがキャッシュに存在しなかった時、つまり fetch(event.request) によってリソースが取得できた時に、キャッシュに追加しておくということが可能です。

fetch(event.request).then(function(response) {
  return caches.open('cache-v1').then(function(cache) {
    cache.put(event.request, response.clone());
    return response;
  });
});  

response(またはrequest)は一度しか参照できないそうなので、複製したものを参照しています。

キャッシュに追加するのがresponseではなくresponse.clone()なのはそのためです。

このフォールバックも含めると以下のようになります。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('cache-v1').then(function(cache) {
      return cache.match(event.request).then(function(response) {
        return response || fetch(event.request).then(function(response) {
          return caches.open('cache-v1').then(function(cache) {
            cache.put(event.request, response.clone());
            return response;
          });
        });
      });
    })
  );
});

古いキャッシュを削除する

ですがこれだけで終わりではありません。

このままではキャッシュのバージョン(「cache-v1」で表されるキー)が新しくなるたびに、古いキャッシュが残り続けてしまいます。

キャッシュもストレージの一種なので、キャッシュのバージョンが変わったタイミングで、古いキャッシュを破棄する必要があります。

新しいキャッシュが構築されてから破棄することになるので、ServiceWorkerが変更された時に発火するactivateイベントを利用します。

キャッシュのバージョンはserviceworker.jsに書かれているため、この文字列の変更はそのままServiceWorkerの変更となります。

self.addEventListener('activate', function(event) { 
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          return cacheName !== 'cache-v2';
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
});

keys / deleteメソッドについては先のリファレンスを参照ください。

ここでは消したくないキャッシュのバージョンが「cache-v2」とハードコーディングされています。

そこで、このキャッシュ名を変数に格納しつつ、まとめます。

const CACHE_NAME = 'cache-v1';
const cacheList = [ '/index.html', '/style.css', '/app.js', '/images/logo.jpg' ];

self.addEventListener('install', function(event) {
  event.waitUntil(
   caches.open(CACHE_NAME).then(function(cache) {
     return cache.addAll(cacheList);
   }).then(function(){
     self.skipWaiting();
   })
  );
});

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.match(event.request).then(function(response) {
        return response || fetch(event.request).then(function(response) {
          return caches.open(CACHE_NAME).then(function(cache) {
            cache.put(event.request, response.clone());
            return response;
          });
        });
      });
    })
  );
});

self.addEventListener('activate', function(event) { 
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          return cacheName !== CACHE_NAME;
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    }).then(function(){
      clients.claim();
    })
  );
});

キャッシュ取得後にアクセスして、Google Chromeの開発者ツールのNetworkタブで、以下のようにServiceWorkerからレスポンスが返されていれば成功です。

f:id:ShinImae:20190810155451p:plain

なぜ「キャッシュ取得後にアクセス」と書いたかは後述しますので、今はちょっとだけご留意ください。

ともあれ、こちらでリクエストに対してキャッシュを返せるようになりました。

下層コンテンツが大量にあるサイトなどではすぐにキャッシュがいっぱいになってしまうので、そう言った場合はオフライン用のエラーページを返すようにしたり、またはフォントなどサイズの大きなリソースのみを返すでも良いかもしれません。

どれくらいまでキャッシュできるのかという制限についてはこちらを参照ください。

love2dev.com

基本的にはそのマシンのハードディクスの容量に対する割合になりますが、一般的な目安は20GBに対してキャッシュストーレージのクオータは50MBです。

オフラインのときは

オフライン対応も、上記と同じ要領になります。

オンライン時のリソースをそのまま返すのも一つの手法ですが、情報の鮮度に差異が出てしまうので、よくあるのがオフライン用のリソースを返すやり方です。

下記はtrivagoでの手法を少し噛み砕いて改変したものです。

/offline/ 配下にある同じ名前のリソースを返しています(もちろんこのリソースもキャッシュに追加されている前提です)。

caches.open('cache-v1').then(function(cache){
 let req = new URL('/offline/', event.request);
  return cache.match(req).then(function(response) {
    return response;
  });
})

オフライン状態の判別は、navigator.onLineの値または、いちど試しにfetchをかけてその結果で判定します。

navigator.onLineプロパティの仕様についてはこちらを参照ください。

developer.mozilla.org

ちなみにtrivagoは、情報の鮮度に左右されないよう、オフライン時はブラウザで楽しめる迷路ゲームをレスポンスとして返しています。

だが、実際にCSSを編集して更新してみると

先に結論を述べてしまいますが、CSSを編集してキャッシュ名を変更したのちにリロードをしても、CSSの変更は反映されません。

反映されるのはそののち もう一度リロードをした時 、つまるところ編集したCSSがキャッシュとして取得されたのちです。

これについては下記の記事で述べられている通り、オフラインファーストという考え方に基づいているそうです。

qiita.com

つまりアプリ起動時は既にストアしてあるバージョンで起動し、その際にアップデートがあれば取得しておく。

そして次の起動時にアップデートを適用するという、オフライン時でも確実に起動できることを優先した考えかたです。

・まずアプリケーションをインストールする
・起動されたらとにかくインストール済みのバージョンで動き出す
・起動後にバックグラウンドで更新の確認を行う
・更新バージョンがある場合はバックグラウンドでダウンロードとインストール処理を行う
・完全にシャットダウンして次に起動した時、更新済みのバージョンで起動する https://qiita.com/masato_makino/items/abc3866f367bfeb81576

リソースがServiceWorkerから返されているか確認するために「キャッシュ取得後にアクセス」と書いたのはこのような理由のためです。

WebサイトであればCSSの更新が即時反映されているべきですが、これはWebサイトではなくあくまでもPWAですので、ご注意ください。

さらにBASIC認証下の環境だと

どうやらrespondWithメソッドの引数に認証の必要なリクエストを渡してしまうと、403エラーを返してしまい、どうしてもキャッシュを返せませんでした。

例えばキャッシュを返す際にRequestオブジェクトを生成して、認証済みである旨を伝えるこんなやり方などが提示されていますが、残念ながら成功しませんでした。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('cache-v1').then(function(cache) {
      return cache.match(event.request).then(function(response) {
        return response || fetch(new Request(event.request, {credentials: 'same-origin'})).then(function(response) {
          // 以下省略
        });
      });
    })
  );
});

この部分の記述です

new Request(event.request, {credentials: 'same-origin'})

出展はこちら

stackoverflow.com

Requestオブジェクトにおけるcredentialsプロパティについてはこちら

developer.mozilla.org

これが本当の答えなのかわからないのですが、Chromiumのバグかもしれないという説を見つけました。

demoed this to one of the Google engineers responsible for implementing ServiceWorker in Chrome, and he determined that it was a Chromium bug. Filed here: https://stackoverflow.com/questions/37934972/serviceworker-conflict-with-http-basic-auth

この件については、調査継続中です。

【PWA】シリーズPWA (4) Service Workerを生成して実際に動かす

登録

registerメソッドを使う 一旦インストールしてしまえば、JSに変更がない限りServiceWorkerは更新されない。

このあたりはブラウザがよしなにやってくてる。registerするには後述するserviceworker.jsのパスを引数に指定するだけ。

navigator.serviceWorker.register('./serviceworker.js',  { scope: './' })
    .then(function(registration){
      console.log('Service Workerの登録に成功しました。');
    })
    .catch(function(error){
      console.error('Service Workerの登録に失敗しました。', error);
    });

installイベント

self.addEventListener('install', function(event) {
  console.log('Service Workerのinstallイベントが発生しました。');
});
installイベントのハンドリングは必須ではありません。もしinstallイベントリスナーを設定しなかった場合でもService Workerの登録は問題なく進行します。インストールに関する処理を安全に行うためには、その間、Service Workerの登録が進んでいくのを待っていてもらわなくてはいけません。そのためのメソッドが、installイベントのコールバックが受け取るExtendableEventオブジェクトが持つ.waitUntil()メソッドです。
event.waitUntil() に Promise が渡されると、ブラウザはインストールの完了のタイミングと成功したかどうかを把握できます。

つまるところ何かの条件でインストールが 成功/失敗 したと判断したいときにはwaitUntilメソッドのコールバック関数内でPromiseオブジェクトを生成し、その中で resolve/reject をすればよい。

installイベントにしばしば紐づけられるのものに、キャッシュの生成がある。これについては後ほど詳述する。

取り急ぎ、installイベントにwaitUntilメソッドが紐づけられていることが多いが、これはinstallイベントに処理を紐づけるお作法だと思っておいて差し支えない。

waitUntilメソッドとは

waitUntilは自分自身が呼ばれたイベント内に置いて、イベント終了のライフタイムを自身の処理が終わるまで待つメソッド。

引数に渡された promise (正確には引数に渡された関数内で作られたpromise)が resolve されるまでイベントのライフタイムを延長する。

引数に渡された関数内の処理にはpromiseが終わってから次のライフタイムに移るようにするメソッドのようです。

activateイベント

install後すぐにactivateにならない場合がある。

ServiceWorkerを更新した時など、データの不整合等を防ぐために waiting 状態に移行し安全な状態になるのを待つ時がそれである。

すぐに active 状態にしても問題ない場合は、skipWaiting()を呼ぶことで active 状態にすることができる install後すぐにactivateさせるには

self.addEventListener('install', function(event) {
  self.skipWaiting();
});

skipWaitingメソッドとは

先述の通り、データの不整合などないか確かめるために(主にServiceWorker更新時)waiting状態を経てactive状態になる。

この時に、データの不整合がないと確実にいえる場合などに、即時active状態に遷移させるためのメソッド。

waiting状態は場合によってはけっこう長いので、データの不整合チェックが不要なアプリは、このメソッドを実行しておいた方が良い。

また、仮にactivateしたとしても、初回訪問時は何も起こらず、2回目にそのページを開いた時にならないと、fetchイベントやpushイベントに紐付けた関数が実行されないそうです。

そこで、activateイベントにclients.claim()というメソッドを紐付けることで、初回訪問時でも動かせることができるそうです。

self.addEventListener('activate', function(event) {
  clients.claim();
});
Service Worker がインストールされ、 fetch等のイベントを受け取れるようになると activate イベントが発火されます。 ただし、デフォルトでは、ページリクエスト自体が Service Worker を通過するまでそのページの fetch イベント等は処理されません。 ややこしいですが、簡単に言うとはじめにService Workerを登録したページ(register('./serviceworker.js')したページ)は、再度読み込むまで Service Workerの処理(fetchイベントの処理等)が走りません。 もしregisterしたページをすぐに有効にしたい場合は clients.claim()を activate で実行すればいいです。

claimメソッドとは

まだコントロールされていないページをコントロール状態にするメソッド。

Service Worker 側のスクリプトで使う。

Service Worker の実行コンテキスト ServiceWorkerGlobalScope は clients というフィールドを持っており、これは現在ブラウザで開いているページを保持する。

ページの visibilityState を取得したり、postMessage で通信したりできる。

こちらに詳しく解説されている。

nhiroki.jp

破棄

機会はほとんどないと思いますが、ServiceWorkerの破棄はunregisterメソッドを用いて、以下の通りで行えます。

navigator.serviceWorker.getRegistrations()
  .then(function(registrations) {
    for(let registration of registrations) {
      registration.unregister();
    }
});

ついでに(前回の復習)

本来であれば上記のみでPWAとして最低限の機能が利用できていたので、2019年2月時点で、若干仕様が変更になりました。

それはPWAとしての最低限の機能であるミニ情報バーの表示をするために、fetchイベントにハンドリングをしていなければいけなくなりました。

ということでServiceWorker側に以下を追記します。おまじないのようなものです。

self.addEventListener('fetch', function(event) {});

まとめると

ServiceWorkerを登録して動かせる状態にするための必要最小限なテンプレートを作ってみました。 main.js(ブラウザ側)

//ServiceWorkerの登録
navigator.serviceWorker.register('./serviceworker.js',  { scope: './' })
    .then(function(registration){
      console.log('Service Workerの登録に成功しました。');
    })
    .catch(function(error){
      console.error('Service Workerの登録に失敗しました。', error);
    });

serviceworker.js(ServiceWorker側)

// インストール時
self.addEventListener('install', function(event) {
   event.waitUntil(self.skipWaiting());
});

// フェッチ時
self.addEventListener('fetch', function(event) {});

// アクティブ時(開いているページをコントロールできるようになったら)
self.addEventListener('activate', function(event) {
  event.waitUntil(clients.claim());
});

次回予告

この上で、更にServiceWorkerに行わせたいことを記述してゆくことになります。

【PWA】シリーズPWA (3) Service Workerについて

これまでさんざんServiceWorkerという言葉が出てきましたが、ServiceWorkerとはブラウザとインターネットの間に立って色々と便利なことをしてくれるものです。

プロキシのようなイメージだと分かりやすいかもしれません。これがPWAそのものであるといっても過言ではありません。

前回予告なくJSのコードを書いてしまったので、ここからは技術的に掘り下げてゆきたいと思います。

Service Workerってなんなのよ (Service Workerのえほん)

ぜひこちらを読んでください。細かいことはわからなくて良いです。

知って欲しいのは、「ブラウザとネットワークの間に立っている存在」ということです。

qiita.com

 

前回か前々回の記事で、PWAにすることはとても簡単、けれどServiceWorkerに色々便利なことをやってもらうにはJSのスキルやノウハウが必要と書きました。

色々便利なこととは、例えばキャッシュを制御することによる高速化、WebPushを受け取ってブラウザに通知を表示する、などです。

それらはすべてブラウザとネットワークの間に立っているからこそできる所業の数々です。

これから先は、どうやったらキャッシュを制御したり、通知を表示したりできるか、その実装方法についてまとめます。

Service Workerを生成して実際に動かす前に

いきなり技術的な話に入っていきます。

まずはJSを書く前に、ServiceWorkerについて知ってゆきます。

動かすためには基本的に2種類のJSが必要

  • main.js (ファイル名は何でもいいが便宜上こう呼びます):ブラウザからServiceWorkerを制御するためのJS。navigator.serviceWorker にてServiceWorkerを制御するための「ServiceWorkerContainer」インターフェイスを参照し、これを通じてServiceWorkerとやりとりします。ServiceWorkerContainerインターフェイスは ServiceWorkerの登録、削除、更新及び送受信を行います。
  • serviceworker.js (ファイル名は何でもいいが便宜上こう呼びます):Service Workerにキャッシュや通知などをさせているJS。ServiceWorkerにはselfで参照する。selfのイベントリスナに関数を紐づけて、好きな処理を書いていく。使えるイベントについては後述。

特徴など

  • Service Workerは必要なときに起動するので、 イベントハンドリング中に なにか値を変数に保持しておいたとしても、次のイベントハンドリングの際には消えてしまいます。そのため、イベントの間で持続する情報を保持したい場合にはIndexedDBを使う必要があります。複数のページ間でのデータのやり取りが手軽にできるものではないと思っておいたほうがよいでしょう。
  • ServiceWorkerは自身が生成される階層(serviceworker.jsのある階層)より上の階層は制御できない。反対に、自身が生成された階層以下であれば、制御対象のディレクトリを指定できる
  • httpslocalhost 上でしか動作しない。

ServiceWorkerのライフサイクル

  • parsed :parsedは初期状態です。Service Workerはまだインストールされておらず、まだService Workerとして呼び出されたスクリプトが読み込まれたくらいの段階です。すぐさま次の状態へ遷移するため、あまり意識することはありません。
  • installing:installingは名前のとおり、Service Workerをインストールしている最中の状態です。Service Workerの新規インストールか、あるいは更新されている場合にもこの状態をとります。この際にService Workerのライフサイクルイベントであるinstallイベントが発生しており、Service Worker側のJavaScriptではこのイベントをハンドリングして必要に応じた(たとえばキャッシュを構築するような)インストール処理を行うこともできます。すでに有効なService Workerがインストール済みで、更新もされていない場合にはactivatedまでスキップされます。 
  • installed :installedは先のinstalling状態を問題なく通過した状態です。この段階でService Workerのインストールには成功していますが、まだService Workerは有効になっていません。Service Workerを新規インストールしている場合には、すぐに次のactivating状態へと遷移します。Service Workerを更新してる場合には、古いService WorkerがまだWebページを制御しているので、新しいService Workerはこの状態に留まります。この状態に留まった新しいService Workerが有効になるには、ユーザーがWebページから離れるなどして、ブラウザが安全に古いService Workerを解放できた後に再度Webページを開くことで、次のactivating状態に移る必要があります。また、この状態はオンライン上のドキュメントではwaitingと呼ばれることも多いです。
  • activating :installedの次の状態がactivatingです。Service Workerの新規インストール時には前段階のinstalledからすぐにこの状態になります。更新の場合には、古いService Workerに代わって新しいService Workerが有効になる段階です。installingのときと同じように、activating時にはライフサイクルイベントのactivateイベントが発生します。Service Worker側のJavaScriptではイベントをハンドリングして必要に応じた処理(たとえば古いキャッシュを破棄するような)をすることもできます。 activated :Service Workerが、対象のWebページを問題なく制御下に置いた状態です。この状態でService WorkerはWebページからのリクエストに反応するfetchや、Webページからのメッセージを受け取るmessageイベントを待つことができます。
  • redundant :次の理由でService Workerが無効となった状態です。(1)installイベント中にエラーが発生した(2)activateイベント中にエラーが発生した(3)新しいService Workerと置き換えられた

以下参考

app.codegrid.net

qiita.com

 

ServiceWorkerのイベント

各ライフサイクルにうつったときや、リクエストを横取りした時などにイベントが発火します。

  • installイベント :ServiceWorkerが登録された時(インストール成功とは違う)。registerメソッドで登録された時。
  • activateイベント : ServiceWorker がpush や sync や fetch などのイベントを処理できるようになった時。
  • messageイベント : クライアントから送られたpostMessageを受け取った時(ちなみにクライアントにpostMessageを送ることも可能)。
  • fetchイベント :ブラウザからのリクエストを横取りした時。
  • syncイベント :オフラインからオンラインになり、状態を同期した時。
  • pushイベント :サーバーからのプッシュ通知を受け取った時。

【PWA】シリーズPWA (2) ミニ情報バーを表示する

今回は、PWAの機能の中でもっともシンプルなミニ情報バーの表示をしたいと思います。

ミニ情報バーとは

PWAをブラウザで表示した時に画面下部に現れる、ホーム画面へのインストールを促すバナーのことです。

このバナーは正しい設定のもとPWA化されているWEBサイトであれば必ず表示されます。

下記はsuumo.jpをAndroidChromeで表示した際のキャプチャですが、赤く網かけした部分がそれにあたります。

 

f:id:ShinImae:20190217002024p:plain

ちなみに、2019年2月現在この機能があるのは、 Androidの対応ブラウザのみです。iOSのブラウザは PWAには対応していても表示はされません。

medium.com

ミニ情報バーを表示させるための最低限の条件は以下の通りです。

見てお分かりの通り、PWAとして成立させるための条件と重複します。すなわち、PWAになっていればこのミニ情報バーはデフォルトで表示されるのです。

  1. manifest.jsonを読み込んでいること
  2. サイトがSSL対応していること
  3. サイトにServiceWorkerが登録されていること

エンゲージメントについて言及されている記事もありますが、2019年2月現在仕様が変わったようで、初回訪問時には必ず表示されるようです。

www.suzukikenichi.com

manifest.json

manifest.jsonについては公式リファレンスを見ながら作成でも良いですが、ジェネレータも多数存在しますので、そちらを利用してもよいでしょう。

ミニ情報バーを表示させるために必須となる項目がありますので、以下は必ずmanifest.jsonにて指定してください。

  • short_name は必須
  • nameは必須
  • iconsは必須(192×192のpng画像であること)
  • 読み込み先の start_url は必須
  • displayの値は「standalone」または「fullscreen」

認証がかかっているテスト環境などでは、このmanifest.jsonをHTMLが読み込むことができずエラーとなってしまうので、その場合は下記のいずれかにて読み込めるようになります。

  • .htaccessでmanifest.jsonのみ認証を解除する
  • manifest.jsonを読み込んでいるmeta要素に「crossorigin="use-credentials"」を指定

htaccessで認証を解除する場合

<files manifest.json>
Satisfy any
order allow,deny
allow from all
</files>

crossorigin属性を指定する場合

<link rel="manifest" href="manifest.json" crossorigin="use-credentials">

github.com

確認方法

ChromeDevToolにて、「Applicationタブ > Manifest」にて指定した値が正しく表示されているか確認できます。

f:id:ShinImae:20190217002014p:plain

「Add to home screen」というリンクをクリックすることで、PC上でもミニ情報バーに相当するバナーを表示することができます。

ただし、2019年3月よりGoogle ChromeがデスクトップPWAに対応したことで、この確認方法はなくなりました。

実際にChromeのメニューからインストールをすることができ、その際にはデスクトップやLauncherにてアイコンが表示されます。

ServiceWorkerの登録

ServiceWorkerを登録するためのJSと、ServiceWorker本体となるJSを用意します。

ファイル名はもちろん任意ですが、ここでは前者をregisterServiceWorker.js、後者をserviceworker.jsとします。

HTML側で読み込みが必要なのは前者のみです。以下にその内容を記します。

if ('serviceWorker' in navigator) {
	// ServiceWorkerを登録
	navigator.serviceWorker.register('serviceworker.js', {
		scope: './',
	}).then(function(registration) {
		// 登録成功時
		console.log('登録成功です');
	}).catch(function(error) {
		// 登録失敗時
		console.log('登録できませんでした');
		console.log(error)
	});
}

ChromeDevToolにて、「Applicationタブ > Service Workers」の内容をみて、正しく登録されているか確認しましょう。

f:id:ShinImae:20190217002020p:plain

基本的にはこれだけで表示されることになっているのですが、実際に確認するとエラーが表示されてしまいインストールはおろか表示もできませんでした。

そのため、serviceworker.jsに以下を記述します。

self.addEventListener('fetch', function(event) {});

詳細はこちらを参照ください。

pawafuru.com

基本的には以上でWebAppBannerを表示することができるようになります。

ミニ情報バー対応状況の判定

Android版PWAは対応していますが、2019年2月現在、iOS版PWAは対応していません。

Google公式によると、対応ブラウザにはWeb App Bannerが表示される時に発行される「beforeinstallprompt」イベントと、バナーを表示する「installPromptEvent.prompt」メソッドがAPIとして用意されているようです。

developer.mozilla.org

そのAPIの有無を利用することで、Web App Bannerへの対応状況を判定することができます。

しばしばタッチデバイスを判定する方法と同様に、以下の条件で判定します。

"onbeforeinstallprompt" in window

対応している場合は「true」、対応していない場合は「false」が返ってきます。

こちらをもとに、ServiceWorkerの対応とWeb App Bannerの対応を組み合わせることができます。

if ('serviceWorker' in navigator) {
	// ServiceWorkerを登録
	navigator.serviceWorker.register('serviceworker.js', {
		scope: './',
	}).then(function(registration) {
		// 登録成功時
		console.log('ServiceWorker登録成功です');

		if ('onbeforeinstallprompt' in window) {
			// Web App Banner対応
			console.log('Web App Banner に対応しています');
		} else {
			// Web App Banner未対応
			console.log('Web App Banner 未対応');
		}
	}).catch(function(error) {
		// 登録失敗時
		console.log('ServiceWorker登録失敗です');
		//console.log(error);
	});
} else {
	console.log('ServiceWorker 未対応です')l
}

【PWA】シリーズPWA (1) はじめ一歩

このシリーズPWAは個人的な調査メモをもとにしたものです。

巷の情報が散在して体系的にまとまった書籍もない中、PWAについて全く知識がない状態から実装レベルまでなんとか知識を引き上げようともがいた痕跡です。

なので記事の引用や、実装するためのソースも多く含まれますがご容赦ください。

PWAとは何か?どんなもの?

 

PWAは「Progressive Web Apps」の略称で、モバイル向けWebサイトをGooglePlayストアなどで見かけるスマートフォン向けアプリのように使える仕組みのことです。PWAはそれ自体が何か特殊な一つの技術、というわけではありません。 レスポンシブデザイン、HTTPS化など、Googleが定める要素を備えたWebサイトであり、オフラインやプッシュ通知に対応するためのブラウザAPI(Service Workerなど)を利用しているWebサイトをPWAと呼びます。
定義がつかみにくかったので、改めて再定義すると、PWAはアプリではありません。 2019年時点では 特定の条件を満たした(挙動がアプリっぽい)webサイトのことを指します。もう少し厳密にいうと、裏側でService Workerというブラウザとは独立した環境によって制御されるwebサイトです。Service Workerは、ブラウザとインターネットの間に存在して動くプロキシのようなイメージが近いかもしれません。なんのこっちゃですが、とりあえずブラウザの裏側にService Workerというものがいること、そしてServiceWorkerがブラウザを制御しているのだということ覚えておいてください。Service Workerはブラウザから独立しているのでオフラインでも動きます。なんならサーバーがダウンしていても動きます。PWA化されていないページを閲覧していても動いています。そういった性質を利用してwebだけどプッシュ通知を受け取ったり、オフラインでも閲覧できたり、高速化を実現できたりします。(ちなみにServiceWorkerそのものについて説明をするとそれだけで本が書けてしまうと思うのでここでは割愛します。興味のあるかたはぜひMDNなどのリファレンスを合わせて参照いただけるとより理解が深まると思います)

ただし今後はアプリでもwebでもないものに進化してゆく予定のものです。

そのため、設計時や開発時に通常のweb制作とは異なる方法論や考え方が必要になる場面がありますので、ご注意を。

PWAを実装することでプッシュ通知やホーム画面へのアイコン追加など、アプリの特徴的な機能をWebサイトに持たせる事ができます。これにより、UX向上やユーザーエンゲージメントの改善にもつながるとして注目されています。
モバイル端末のホーム画面にアイコンを設置できるため、ユーザーはアイコンをタッチするだけでWebサイトを閲覧することが出来ます。 似た機能に「ウェブページのショートカットアイコンをモバイル端末のホーム画面に追加する」という既存機能があげられますが、PWAは単にショートカットをホーム画面に設置した場合と異なり、後に紹介するプッシュ通知やキャッシュの利用などの機能が備わっています。また見かけ上でも、PWAのアイコンから起動したWebサイトはURLバーもなくフルスクリーンで表示出来ますし、起動時のスプラッシュスクリーンも設定できます。
検索結果やURLから、通常のサイトと同じようにアクセスすることができる アプリ的な機能が取り上げられがちですが、もともとはWebサイトなので、通常のWebサイトと同じようにアクセスすることが出来ます。知人とページをシェアするときはアプリとは違いURLを送ればページを共有出来ますし、検索エンジンからのPWA対応のサイトを見つけることも出来ます。

ただし注意しなければいけないのは、キャッシュやプッシュ通知はPWAの機能でもなく、”ServiceWorkerができること” です。

ServiceWorkerにこの機能が備わっているわけではなく、ブラウザ( ≒ GoogleChrome)に備わっているCache APIやPush APIをServiceWorkerが操作できるに過ぎません。

導入事例

著名なものを挙げてみました。後のシリーズで詳述しますが、PWAになっているサイトを見分けるのは比較的容易です。

何を備えていればPWAと呼んでよいのか

何ができていれば最低限PWAと呼んで良いのか。

  • SSL対応
  • manifest.json
  • Service Workerに関連するJS(ファイルの名前はなんでもよい。中身が空でもよい。後述する2つのJSが必要)

上記3ついずれも必須ですが、裏を返せば、通常のWebサイトでもこの条件さえ満たせればPWAとすることができます。

SSL対応はサーバーの設定によるものなので、基本的には開発者サイドはあまり気にしなくてよいです。

manifest.jsonはテンプレート通りに記述すればよく、各項目は理解が難しいものではないです。

ServiceWorkerを登録するJSと、ServiceWorkerにさせたいこと(キャッシュ制御やプッシュ通知など)を記述したJSが必要で、開発者にとってもっとも理解力と技術力が求められるもの。Web制作のそれとは異なる方法論を求められたり、新しい概念が多いので、慣れるまで時間がかかる(情報も点在していますし)。

また、Googleが推し進めているものなので、基本的にはAndroid優位です。ですが最近はiOSでも(まだできないことが多いですが)徐々に対応が進んでいます。

できることできないこと / 誤解されていること などなど

  • PWA = 軽量 ではない。キャッシュストレージというものを利用して初めて速くなる。キャッシュストレージを利用しないと、パフォーマンスはブラウザでのそれと大差ありません。
  • 認証のかかっている環境下ではmanifest.jsonを読めないことがあるので注意。認証を通すための属性を追加するか、認証の外れたディレクトリを作成するか。
  • cookieは使えない(表示のたびにリセットされる)。WebStorageは大丈夫なよう
  • 今のところ ChromeにログインしているGoogleアカウントに紐づくような挙動などはない模様
  • iOSではできないことはこちらを参照。まだけっこうありそう。
    https://medium.com/@takeshiamano/ios%E3%81%AE11-3%E3%81%8B%E3%82%89%E3%81%AEpwa%E5%AF%BE%E5%BF%9C%E3%81%A7%E3%81%A7%E3%81%8D%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8-313f638a172b
  • iOSはスプラッシュスクリーンのカスタマイズはできない。真っ白くなる。端末ごとのサイズの画像をmetaで登録するという方法もあるが、apple-touch-icon同様に認証のかかっている環境下では確認できない。
  • iOSではブラウザからアプリへの値の引き継ぎはできません。
  • iOSではミニ情報バーも表示されません。
  • iOSのみPWA化させないことは可能。(JSでmanifest.jsonを読んでいるmetaタグを削除する)
  • iOSはPWA化していない実例はいくつかある。PWA化しているものはスプラッシュスクリーンが白くなっても違和感のないデザインのものが多い
  • start_urlにパラメータを付与すれば、アプリからの流入も計測できる。
  • ブラウザからアプリに値を引き継ぐ際にWebStorageを利用することができる。しかしiOSはこれができない。効果測定の際に不便。

次回予告

次回は、PWAのもっとも基礎的な実装である、ミニ情報バーの表示について方法をまとめます。