みんなの「作ってみた」

僕の考えた最強のService Workerキャッシュ戦略で爆速サービスを作った

2019/02/25

tiwu_official
tiwu_official

はじめに

聖地の写真を共有するサービス「Holy Place Photo」にService Workerによるキャッシュを実装しました。

そのためネットワークがオフラインでも下記のようにサクサク動きます!!!!

また、LighthouseのPWAのスコアは100を取れました。

この記事ではService Workerを利用したキャッシュの設計、Laravel + Vue.jsのWebアプリケーションでの実装を解説していきたいと思います。

注意書き

  • 僕の考えた最強のService Workerのキャッシュの設計のため唯一の正解ではありません
  • キャッシュの設計はサービスによって違うと思うのでこの記事は一つの参考にしてください

Service Worker

まずService Workerについて簡単に解説します。

※今回はスコープについてはあまり説明しません

Service Workerとは

Service WorkerはWebページのバックグラウンドで動くスクリプトです。

オフライン対応・バックグラウンド同期・プッシュ通知などの機能を持ちます。

Progressive Web Appとセットでよく聞くと思います。

ここで注意してほしいのはService WorkerはリクエストにaddEventListenerを貼ることができるだけで、キャッシュする機能はService Workerには含まれていないという点です(キャッシュについてはCache Apiの項目で詳しく説明します)

詳しくはこちら。

Service Worker の紹介 | Web Fundamentals | Google Developers

Service Workerのライフサイクル

Service Workerはウェブページとは異なるライフサイクルで動作します。

Service Workerの登録(Install)

Serivce Workerを登録するためにはnavigator.serviceWorker.registerを利用します。

app.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js').then(() => {
      console.log('登録成功');
    },() => {
      console.log('登録失敗');
    });
  });
}
sw.js
self.addEventListener('install', (event) => {
  console.log('インストール');
});

登録が成功するとinstallが実行されます。

このinstallは一度だけ発生します。

installは静的なアセットをキャシュするのによく使われます。

Service Workerによる制御(Activate)

installが成功、またはすでにinstallが成功しているページを表示するとactivateが実行されます。

installが成功 = 初回のアクセス時
installが成功しているページを表示する = 2回目以降のアクセス時

activateは古いキャッシュを削除したりするのによく使われます。

activateが完了するとService Worker はそのスコープ内のすべてのページを制御します。

た だ し !

Service Workerを登録時点では(初回のアクセス時)制御されず、次に読み込まれた際に制御されるようになります。

初回にService Workerの制御が始まらない理由は「Service Worker 登録」に記載されています。

新しい Service Worker スレッドを開始してリソースのダウンロードとキャッシュをバックグラウンドで実行すると、ユーザーがはじめてサイトにアクセスしたときに迅速なインタラクティブ エクスペリエンスを提供するという目標に逆行することになります。

初回のアクセスの際はブラウザはバックグラウンドでスクリプトを実行している場合ではないぞ、と書かれています(個人的解釈)

初回アクセス時にService Workerの制御を開始させる

実はactivate時にclients.claim()を実行すると、強制的にService Workerの制御を開始できます。

sw.js
self.addEventListener('activate', (event) => {
  event.waitUntil(self.clients.claim());
});

Service Workerの更新

Service Workerが更新されていると判断される条件は、1ByteでもService Workerのスクリプトに変化があった際です。

更新を検知すると新しいService Workerは待機状態になります。

待機状態から新しいService Workerの制御を開始するためには、すべてのタブを閉じるか、現在開いているページから別のページに移動する必要があります(ページの更新・リロードするだけでは制御は開始されません)

そのため、新しいService Workerの制御を開始させるためには1step必要になります。

Service Workerの更新時に即座に制御を開始させる

install時にskipWaiting()を実行すると、新しいService Workerの制御を即座に開始させられます。

sw.js
self.addEventListener('install', (event) => {
   event.waitUntil(self.skipWaiting());
});

Service Workerによるオフライン対応

なぜ、Service Workerを導入するとオフライン対応が可能になるのでしょうか?

それはService Workerはネットワークへのアクセスを捕まえることができるためです。

Service Workerが制御している状態で、ネットワークへのアクセス(ページ遷移、リソースの取得など)が発生すると、fetchイベントが発火します。

sw.js
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then((response) => {
      if (response) {
        return response;
      }
      return fetch(event.request);
    })
  );
});

Service Workerはこのfetchイベントに対してaddEventListenerを貼ることができます。

上記例ではキャッシュにあったらキャッシュから返して、なかったらfetchを実行してネットワークへアクセスし取得します。

このようにfetch内でキャッシュから返すことができるため、あらかじめ全てのリソース(HTML,CSS,JS,IMGなど)をキャッシュしていれば、オフライン状態でもWebページが見れるようになります。

Cache API

次にCache APIについて解説します。

2度目になりますが、Cache APIはService Workerの機能ではありません。

キャッシュは削除されない限り有効期限切れにはなりません。

キャッシュはcaches.open(CACHE_NAME)を実行しキャッシュオブジェクトを取得して利用します。

詳しくはこちら。

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

キャッシュの登録

キャッシュへの登録はadd,addAll,putがあります。

const CACHE_NAME = 'v1';

caches.open(CACHE_NAME).then((cache) => {
  return cache.add('/hoge.png');
});

caches.open(CACHE_NAME).then((cache) => {
  return cache.addAll([
    '/fuga.png',
    '/piyo.png'
  ]);
});

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

addはURLを受け取り、ネットワークへアクセスして取得後、レスポンスオブジェクトをキャッシュに追加します。

addAllは配列を受け取り、ネットワークへアクセスして取得後、レスポンスオブジェクトをキャッシュに追加します。

putは取得済みのレスポンスオブジェクトをキャッシュに格納します。

addfetch + putのイメージです。

上記例ではService Workerのfetch内で、キャッシュから取得できなかった場合、fetchして、
レスポンスオブジェクトをputを利用しキャッシュに追加しています。

キャッシュの削除

削除はdeleteを利用します。

caches.open(cacheName).then((cache) => {
  cache.delete('hoge.png');
});

キャッシュの利用

追加のところで出ていますがmatchを利用します。

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

matchdeleteにはオプションが利用できます。

  • ignoreSearch: trueを指定するとクエリストリングを無視するようになります
  • ignoreMethod: trueを指定するとGET,HEAD以外のMETHODが利用できます

僕の考えた最強のService Workerキャッシュ戦略

ということで僕の考えた最強のService Workerキャッシュ戦略を解説していきたいと思います。

今回のキャッシュ戦略のポイントは下記になります。

  • 次のページに遷移する際に必要なリソースは全てキャッシュから返す
    • HTMLの有効期限は1日にする
    • アセット(CSS,JS)は無期限
    • ただしサードパーティのリソースは除く
  • 初回のユーザーもService Workerの制御下に置く

1つずつ解説していきたいと思います。

次のページに遷移する際に必要なリソースは全てキャッシュから返す

次のページで必要なリソースは主に動的なHTMLと、静的なアセット(CSS,JS)とサードパーティのリソースに分類されます。

動的なHTMLは有効期限を1日にして、静的なアセット(CSS,JS)は無期限キャッシュするようにします。

より頻繁に更新されるようなサイトでは、HTMLの有効期限はより短くていいかもしれません。

動的なHTMLのキャッシュ

document.querySelectorAll('.js-sw-fetch').forEach((e) => {
  const url = e.getAttribute('href')
  caches.open(cacheName).then((cache) => {
    cache.match(url).then((response) => {
      if (!response) {
        cache.add(url);
        localStorage.setItem(url, Date.now());
      } else {
        const time = localStorage.getItem(url);
        if (!time || Date.now() - time > 60 * 60 * 24 * 1000) {
          cache.delete(url).then(() => {
            cache.add(url);
            localStorage.setItem(url, Date.now());
          });
        }
      }
    });
  });
});

画面内にあるリンクをDOMから引っこ抜いて、Cache APIを使ってキャッシュに登録します。

キャッシュにない場合はaddを利用して保存し、ローカルストレージに遷移するURLをkeyにしてタイムスタンプを保存します。

キャッシュにある場合は、有効期限が切れていたらdeleteを利用してキャッシュを削除して、addを利用しキャッシュに保存します。

静的なアセット(CSS,JS)

今回FWにLaravelを採用しており、フロントのビルドにLaravel Mixを利用しています。

アセットのバージョン管理にはLaravel Mixのmix.version()を利用しています。

そのためビルドされるファイルにはid=12345といったクエリパラメーターが付与されます。

これはmix-manifest.jsonで管理されており、bladeで利用するmix()はこのmix-manifest.jsonを見ています。

{
    "/js/app.js": "/js/app.js?id=27081b345ad90ec7716a",
    "/css/app.css": "/css/app.css?id=ebe23bcc66ba35ecfe6c"
}

画面表示時に必要なアセットをコントローラーからviewに埋め込み、JSで引っこ抜いてキャッシュに登録させます。

<?php

namespace App\Http\Controllers;

class Controller extends Controller
{

    /**
     *
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
     */
    public function index()
    {
        $manifests = json_decode(file_get_contents(public_path('/mix-manifest.json')), true);
        // swもビルドされているため、除外
        $files = array_filter($manifests, function($key) {
            return $key !== '/sw.js';
        }, ARRAY_FILTER_USE_KEY);
        $swCacheList = json_encode(array_values($files));
        return view('index', compact('swCacheList');
    }
}
<div id="sw" data-sw-cache-list="{{$swCacheList}}"></div>
JSON.parse(document.getElementById('wrapper').dataset.swCacheList).forEach((url) => {
  caches.open(cacheName).then((cache) => {
    cache.match(url).then((response) => {
      if (!response) {
        cache.delete(url.split('?')[0], {ignoreSearch: true}).then(() => {
          cache.add(url);
        });
      }
    });
  });
});

cache.delete(url.split('?')[0], {ignoreSearch: true})により、URLパラメーターが違う同じファイルのキャッシュを削除することで、無期限にキャッシュしているファイルをキャッシュから削除しています。

管理しきれる間は上記方法でいいと思いますが、管理しきれなくなってきた場合は、HTMLをキャッシュする際に、HTMLをパースしてJSとCSSのURLを取得してキャッシュするという動的な方法が良いかもしれません。

初回のユーザーもService Workerの制御下に置く

上記の解説の通りService Workerは通常初回アクセスのユーザーは制御下におきません。

今回は初回のユーザーも制御下に置くために、activate時にclients.claim()を実行します。

また、Service Workerの更新時も初回から更新したService Workerの制御下に置くために、install時にskipWaiting()を実行します。

Service Workerの制御の開始を検知する

install後の制御の開始や、更新後の制御の開始はcontrollerchangeイベントが発火します。

そのため、Service Workerの制御中に何か処理をしたい場合は下記のように書くことで実現できます。

  const controllerChange = new Promise((resolve) => {
    navigator.serviceWorker.addEventListener('controllerchange', resolve);
  });
  navigator.serviceWorker.register('/sw.js').then(() => {
    return navigator.serviceWorker.ready;
  }).then(() => {
    if (navigator.serviceWorker.controller) {
      // 更新時
      return navigator.serviceWorker.controller;
    }
    // 初回
    return controllerChange;
  }).then(() => {
    // Service Workerの制御下
  });

おわりに

サクサクなのは本当に気持ちよく、やってやった感が高かったです。

作った後に、quicklinkという、Intersection ObserverrequestIdleCallbacknavigator.connection.effectiveTypeprefetch使って、リンクを高速に先読みするライブラリを知りました・・・。

最強のService Workerキャッシュ戦略は最強(改)にできそうです・ω・