knowledge base

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

【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のもっとも基礎的な実装である、ミニ情報バーの表示について方法をまとめます。

jQueryオブジェクトかどうかの判定方法

instanceof演算子を利用します。 変数objにはjQueryオブジェクトもしくはVanillaなDOMオブジェクトが入っています。

obj instanceof jQuery

jQueryオブジェクトであれば戻り値はtrueになります。「instanceof XXX」の「XXX」部は文脈によってjQueryそのものが代入されている変数に変更してください。

ページ離脱防止機能を実装する

beforeunloadイベントを利用して実装

先日、とあるCMSの管理画面にて、記事入力途中・画像入力途中でのページ離脱防止機能の実装を行いました。

要件としては、Google Chromeのみで、離脱時にアラートを表示するというものでした。

ページ遷移前に発火するbeforeunloadというイベントを利用した実装を行なったのですが、僕自身このイベントに真正面から向かい合って実装に取り組んだことがなかったので、ところどころ小さな落とし穴があったため、その備忘録としてこの記事を書かせていただきます。

ちなみに、beforeunloadイベントの詳細はこちらを参照ください。

developer.mozilla.org

テストは必ず入力フィールドのあるページで

とても基本的な落とし穴ですがテストは必ず入力フィールドのあるページで行なってください。

Google Chrome公式でもRequire user gesture for beforeunload dialogsと記載されています。

文字入力や画面になんらかのアクションがないと、離脱防止は動きません。開いて何もせずに「戻る」を押してしまうと、何も起きずに離脱してしまいます。

https://donow.jp/skillup/?p=3271

www.chromestatus.com

僕はこれに気づかず、入力フィールドのないページでなんどもブラウザバックボタンを押しては、何も起こらないことに首をひねってしまいました。

window.onbeforeunloadイベントプロパティに関数を登録

イベントハンドリングにはaddEventListenerメソッドやonメソッド(jQuery)などの方法がありますが、後述する理由によりwindow.onbeforeunloadイベントプロパティに関数を登録する方法を採用しています。

Chromeとそれ以外のブラウザとで若干方法が異なりますが、必ずreturnをするかe.returnValueを指定してください。

同時に指定することも可能ですが、とにかくreturnをしないと離脱防止は動きません。

window.onbeforeunload = function(e){
    return "このページを離れますか?"; // Google Chrome以外
    e.returnValue = "このページを離れますか?"; // Google Chrome
}

また、Google Chromeのみ、カスタムメッセージ(つまりreturnValueやreturnに渡している文字列)は設定できず、ブラウザデフォルトのメッセージしか表示ができない仕様になっているそうです。

この件についてはこちらの記事をご参照ください。

qiita.com

フォームのsubmit時は無効にする

beforeunloadイベントはすべてのページ遷移時に発火するので、当然ながらフォームのsubmit時も発火します。

ユーザビリティのため、フォームのsubmit時は無効にするのが好ましいでしょう。

方法としてはフォームのsubmit時にbeforeunloadイベントとそれに対応する関数との紐づけを削除するのですが、removeEventListenerメソッドでもoffメソッド(jQuery)でもその紐づけを切ることができませんでした。

そこで、window.onbeforeunloadプロパティにnullを代入することで対応しました。

$('form').on('submit', function(e){
    e.preventDefault();
    window.onbeforeunload = null;  // 関数を削除
    var $this = $(this);
    var isPassed = confirm('記事を登録しますか?');
    if (isPassed) $this.submit();
});

記事の登録時には確認ダイアログを表示してほしいとの要件であったため、このような実装になっています。

これらをまとめると、以下のようになります。

window.onbeforeunload = function(e){
    return "このページを離れますか?"; // Google Chrome以外
    e.returnValue = "このページを離れますか?"; // Google Chrome
}

$('form').on('submit', function(e){
    e.preventDefault();
    window.onbeforeunload = null;  // 関数を削除
    var $this = $(this);
    var isPassed = confirm('記事を登録しますか?');
    if (isPassed) $this.submit();
});