はじめに
聖地の写真を共有するサービス「Holy Place Photo」にService Workerによるキャッシュを実装しました。
そのためネットワークがオフラインでも下記のようにサクサク動きます!!!!
Service Worker Demo pic.twitter.com/t04xSRl7B2
— tiwu (@tiwu_official) 2019年2月25日
また、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
を利用します。
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').then(() => {
console.log('登録成功');
},() => {
console.log('登録失敗');
});
});
}
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の制御を開始できます。
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の制御を即座に開始させられます。
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
});
Service Workerによるオフライン対応
なぜ、Service Workerを導入するとオフライン対応が可能になるのでしょうか?
それはService Workerはネットワークへのアクセスを捕まえることができるためです。
Service Workerが制御している状態で、ネットワークへのアクセス(ページ遷移、リソースの取得など)が発生すると、fetch
イベントが発火します。
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
は取得済みのレスポンスオブジェクトをキャッシュに格納します。
add
はfetch
+ 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;
});
});
})
);
});
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のキャッシュ
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 Observer
とrequestIdleCallback
とnavigator.connection.effectiveType
とprefetch
使って、リンクを高速に先読みするライブラリを知りました・・・。
最強のService Workerキャッシュ戦略は最強(改)にできそうです・ω・