ServiceWorkerとCache APIを使ってオフラインでも動くWebアプリを作る

  • 273
    Like
  • 0
    Comment
More than 1 year has passed since last update.

はじめに

Thetaの360°画像にぼかしを入れるWebアプリSphereBlur.com作った際に、オフラインでも動くようにするために、HTML5の新しい技術Service Workerを使った。なお、下の動画でホーム画面からネイティブアプリっぽく起動しているのはWeb App Manifestのおかげである。

オフラインでも動く

Service Worker

Service Workerは、通常のページの環境とは別に、バックグラウンドで実行されるJavaScript実行環境で、ページからのネットワークリクエストを横取りしたり、ウェブサイトからのPush通知を受けとって表示するといった、今まではできなかった処理をすることができる。Push通知の方は、去年Facebookが使い始めたので有名になったが、今回はPush通知ではなく、ネットワークリクエストを横取りする機能を使ってオフライン対応をした。

ネットワークリクエストを横取り(Fetchイベントハンドラ)

ページ側で下記のコードを実行すると、sw.jsがService Workerとして登録される。

index.html
navigator.serviceWorker.register('./sw.js', {scope: './'});

そして、sw.js側で、下記のようなFetchイベントハンドラを登録しておくと、scopeで指定したパス以下のページからのネットワークリクエストが来るたびにconsole.log(event.request.url);が実行される。

sw.js
self.addEventListener('fetch', function(event) {
    console.log(event.request.url);
  });

例えば、ページ側に<img src="test.png">があると、ページをリロードをするたびに、Service WorkerのDeveloper ToolsのConsoleにこのように表示される。 デモ

sw_demo01.png

Service Worker内で生成したレスポンスを返す

Fetchイベントハンドラ内で、event.respondWith()を呼ぶと、実際にサーバーへリクエストを投げるのではなく、Service Worker内でレスポンスを生成してページに返すことができる。 このデモページで、ボタンをクリックしてから"test"と書かれたリンクをクリックすと"Hello world"と表示されるが、これはService Worker内で生成されたレスポンスである。

sw.js
self.addEventListener('fetch', function(event) {
    if (event.request.url.indexOf('test') != -1) {
      event.respondWith(new Response('Hello world'));
    }
  });

サーバーから受け取ったレスポンスをService Worker内で変換して返す

Service Worker内ではFetch APIfetch()を使うことでサーバへHTTPリクエストを投げることができる。これとevent.respondWith()を組み合わせることで、サーバーから受け取ったレスポンスをService Worker内で変換してページに返す事ができる。例えばこのデモでは、Emscriptenを使ってコンパイルしたLibTIFFを使って、TIFFファイルをService Worker上でBMPファイルに変換してページに返している。

tiff2bmpsw.js
function getBmp(url) {
  return new Promise(function(resolve, reject) {
    fetch(url)
      .then(function(res) { return res.arrayBuffer(); })
      .then(function(buffer) {
        var filename = "tmp" + (++filecount) + ".tiff";
        FS.createDataFile(
            '/',
            filename,
            new Uint8Array(buffer), true, false);
        bmpOutput = [];
        var ret = Module.ccall('tiff_to_bmp',
                               'number',
                               ['string'],
                               [filename])
        if (ret == 0)
          resolve(new Response(new Blob(bmpOutput, {type: "image/bmp"})));
        else
          reject();
      });
  });
}

self.addEventListener('fetch', function(event) {
  if (event.request.url.indexOf('.tif') == -1)
    return;
  event.respondWith(getBmp(event.request.url));
});

現在仕様策定中のStreams APIを使えば、Jake Archibaldのこのデモのように、Service Worker上でMPEGファイルをGIFアニメーションファイルに徐々に変換しながらページに返すという、なんともトリッキーなこともできるようになる。

Fetch APIとCache APIを使ったレスポンスの保存

SphereBlur.comでは静的なコンテンツをページに返すだけでいいので、ServiceWorkerのインストール時に、必要となるリソースをFetch APIでサーバーから取ってきてCache APIを使って保存しておき、Fetchイベントハンドラでキャッシュから取り出して返すということをしている。

まず、インストール時は下記のようなコードで、STATIC_FILESにあるすべてのURLに対してfetch()を呼び、そのぞれのレスポンスをcache.put()で保存する。なお、response.okをチェックすることで、サーバーエラー等のレスポンスを誤って保存しないようにしている。

sw.js
var VERSION = 1;
var STATIC_CACHE_NAME = 'static_' + VERSION;
var ORIGIN = location.protocol + '//' + location.hostname +
             (location.port ? ':' + location.port : '');
var STATIC_FILES = [
  ORIGIN + '/',
  ORIGIN + '/jpeg_encoder_basic.js',
  ORIGIN + '/?homescreen',
  ORIGIN + '/img/ic_photo_black_24px.svg',
  ORIGIN + '/img/ic_save_black_24px.svg',
  ORIGIN + '/img/ic_loop_black_24px.svg',
  ORIGIN + '/img/4pt.svg',
  ORIGIN + '/img/7pt.svg',
  ORIGIN + '/img/10pt.svg'];
var STATIC_FILE_URL_HASH = {};
STATIC_FILES.forEach(function(x){ STATIC_FILE_URL_HASH[x] = true; });

self.addEventListener('install', function(evt) {
    evt.waitUntil(
        caches.open(STATIC_CACHE_NAME).then(function(cache) {
            return Promise.all(STATIC_FILES.map(function(url) {
                return fetch(new Request(url)).then(function(response) {
                    if (response.ok)
                      return cache.put(response.url, response);
                    return Promise.reject(
                        'Invalid response.  URL:' + response.url +
                        ' Status: ' + response.status);
                  });
              }));
          }));
  });

キャッシュからレスポンスの取り出し

Fetchイベントハンドラは、下記のようにキャッシュにあるURLのFetchイベントの場合にcaches.match()でレスポンスを取り出してページに返している。

sw.js
self.addEventListener('fetch', function(evt) {
    if (!STATIC_FILE_URL_HASH[evt.request.url])
      return;
    evt.respondWith(caches.match(evt.request, {cacheName: STATIC_CACHE_NAME}));
  });

古いキャッシュの削除

また、バージョンアップ時に、要らなくなったキャッシュを削除するために、Activateイベントハンドラは下記のようなコードになっている。

sw.js
self.addEventListener('activate', function(evt) {
    evt.waitUntil(
      caches.keys().then(function(keys) {
            var promises = [];
            keys.forEach(function(cacheName) {
              if (cacheName != STATIC_CACHE_NAME)
                promises.push(caches.delete(cacheName));
            });
            return Promise.all(promises);
          }));
  });

最後に

Service WorkerのFetchイベントハンドラを使うと、今回のように単純にキャッシュから返すだけ以外にも様々な事ができる。Jakeのこの記事The offline cookbookに詳しく載っているので、Fetchイベントを使いこなしたい場合はおすすめ。

また、今回は手動で静的コンテンツの一覧の管理をしているが、sw-precacheを使うと自動化できるので、コンテンツが多数ある場合はこちらを使うと良い。