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

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