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

# はじめに

聖地の写真を共有するサービス「[Holy Place Photo](https://www.holy-place-photo.com/)」にService Workerによるキャッシュを実装しました。

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

<blockquote class="twitter-tweet" data-lang="ja"><p lang="en" dir="ltr">Service Worker Demo <a href="https://t.co/t04xSRl7B2">pic.twitter.com/t04xSRl7B2</a></p>&mdash; tiwu (@tiwu_official) <a href="https://twitter.com/tiwu_official/status/1100018179479224321?ref_src=twsrc%5Etfw">2019年2月25日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

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

![スクリーンショット 2019-02-25 22.10.58.png](https://qiita-image-store.s3.amazonaws.com/0/81308/e5a0a89d-5bc2-bded-fddc-b1a7fcde2d10.png)


この記事では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](https://developers.google.com/web/fundamentals/primers/service-workers/?hl=ja)

## Service Workerのライフサイクル

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

![スクリーンショット 2019-02-25 22.40.49.png](https://qiita-image-store.s3.amazonaws.com/0/81308/1a9c6110-5e9b-efad-76af-003e8f8651bf.png)


## Service Workerの登録(Install)

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

```javascript:app.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js').then(() => {
      console.log('登録成功');
    },() => {
      console.log('登録失敗');
    });
  });
}
```

```javascript: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 登録](https://developers.google.com/web/fundamentals/primers/service-workers/registration?hl=ja)」に記載されています。

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

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

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

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

```javascript: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の制御を即座に開始させられます。

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

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

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

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

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

```javascript: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](https://developer.mozilla.org/ja/docs/Web/API/Cache)

## キャッシュの登録

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

```javascript
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`は取得済みのレスポンスオブジェクトをキャッシュに格納します。

`add`は`fetch` + `put`のイメージです。

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

## キャッシュの削除

削除は`delete`を利用します。

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

## キャッシュの利用

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

```javascript
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;
        });
      });
    })
  );
});
```

`match`や`delete`にはオプションが利用できます。

- 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のキャッシュ

```javascript
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](https://readouble.com/laravel/5.4/ja/mix.html)を利用しています。

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

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

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


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

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

```php
<?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');
    }
}
```

```html
<div id="sw" data-sw-cache-list="{{$swCacheList}}"></div>
```

```javascript
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の制御中に何か処理をしたい場合は下記のように書くことで実現できます。

```javascript
  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](https://github.com/GoogleChromeLabs/quicklink)という、`Intersection Observer`と`requestIdleCallback`と`navigator.connection.effectiveType`と`prefetch`使って、リンクを高速に先読みするライブラリを知りました・・・。

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