今回はService Workerを用いたオフラインキャッシュとそのテクニックについて紹介していきます。
Service Workerを用いたオフラインキャッシュを実装することで、インターネットに繋がっていない状態でWebページが閲覧可能になったり、高速でWebページを表示することが可能となります。
それでは順を追って解説していきましょう。
Service Workerとは
まずはじめにService Workerとは、ブラウザとは別スレッドで動作するイベント駆動型のJavascript Workerのひとつです。
httpsプロトコルでしか利用できないものの、最近ではブラウザの実装も進み、広く利用され始めてきています。
元々はAppCacheの代替として、プログラマブルにキャッシュを扱えるAPIを目指して開発が始められました。
AppCacheには様々な実装上の問題点やセキュリティ上の問題があり、その解決をモチベーションとしてService Workerが生まれました。
そうして開発が進められたのち仕様に落とし込まれ、その後Push APIなどの追加を経て現在の形に至ります。
今回利用するのは、その中でもService Workerの花形ともいえるキャッシュを扱うCache APIとなります。
それでは、そんなService Workerがどのようにしてキャッシュを扱っているかについて触れていきましょう。
Cache APIとは
Cache APIとは先に触れた通り、Service Workerに実装されているプログラマブルにキャッシュを扱うためのAPIのことを指します。
Service Workerでは仕様の中でCache Storageというキャッシュの保存先として用いる、ブラウザ上に実装されたキャッシュ用のストレージを所持しており、Cache APIはそのインタフェースとして振舞います。
このCache Storageは、プログラマブルにキャッシュを扱うことによるセキュリティ上の懸念から、indexeddbのようにoriginごとにネームスペースを持っており、そこにキャッシュを保存するようになっています。
これにより他originへの影響や、他originのキャッシュをService Workerが取得することによる悪用を避けることが可能となっています。
ちなみにこのCache Storageはもの自体は独立しているためService Worker外からも利用可能です。
次は、このCache APIを利用してオフラインキャッシュを実現する方法についてです。
Cache APIを用いたオフラインキャッシュの実現
ここまででService Workerがいかにしてキャッシュを扱うかについて説明してきましたが、問題はどのタイミングでCache APIを叩くかという点です。
これにはService Workerの特徴であるイベント駆動が役に立ちます。
Service Workerは主に以下のようなイベントを持ちます。
- oninstall
- onactivate
- onfetch
流れとしては、Service Workerのインストールに完了すると oninstall
が発火し、その後Service Workerが有効化されるタイミングで onactivate
が発火します。
onfetch
が発火するタイミングはブラウザがインターネットに向けてファイルをリクエストするときです。
つまり、 oninstall
のタイミングでファイルをキャッシュし、 onfetch
でそのキャッシュからファイルを返すことで、ブラウザからのファイルのリクエストがインターネットに出る前にレスポンスを返すことが可能です。
これによりリクエストはブラウザ内で完結するためオフラインキャッシュが実現されるといった仕組みです。
Service Workerを用いたオフラインキャッシュの実装
ここまでで前置きは一旦終了です。
ここからは実際にService Workerを用いてオフラインキャッシュを実装していきましょう。
オフラインキャッシュを実装する上でのテクニックを織り交ぜつつ解説していきます。
Service Workerの登録
まずはService Workerを登録する部分です。
先に述べた通り、Service WorkerはJavascript Workerのひとつなので登録しなければ始まりません。
任意のページに以下のscriptタグを埋め込みましょう。
<script>
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/sw.js', { scope: '.' }).then(function(registraion) {
registraion.update();
});
}
</script>
ここでは、 sw.js
を .
をスコープとしたService Workerとして登録しています。
このスコープは重要で、Service Workerが有効化されるリソースの範囲を指定しています。
index.html
でこれを実行した場合はすべてのページで有効化されますが、 /foo/bar.html
で実行した場合は index.html
では有効になりません。
このスコープは /foo/
のような指定の仕方も可能で、これにより影響範囲を細かに設定することができます。
ここで早速テクニックのひとつ目です。
register時に registration.update()
を実行しています。
通常Service Workerは有効化されたページを開いた際に、 Cache-Controlに従ってService Workerを取得し更新を試みます。(Cache-Controlに極端に長いmax-ageを指定された際の対策として最低24時間に1度更新します)
そこで、ここではregister時に強制的にupdateすることで、ページを開き直さなくてもService Workerが更新されるようにしています。
また、 registration.update()
はService Worker内のプログラム上で実行することもできるので、任意のタイミングで更新することも可能です。
次はService Worker本体の部分です。
先ほど登録した sw.js
の中身を追っていきましょう。
キャッシュの登録
まずは、 oninstall
によるキャッシュの登録です。
あらかじめ定義しておいたキャッシュしたいファイルの一覧 STATIC_FILES
をFetch APIで取得し、 STATIC_CACHE_KEY
をキーとしてCache Storageに保存しています。
STATIC_CACHE_KEY
はキャッシュしたいファイルが更新された場合に書き換えることでキャッシュのバージョンを管理します。
// sw.js
const ORIGIN = location.protocol + '//' + location.hostname;
const STATIC_CACHE_KEY = '1';
const STATIC_FILES = [
ORIGIN + '/',
ORIGIN + '/stylesheets/index.css',
ORIGIN + '/javascripts/index.js',
'https://file.kaihar4.com/images/icon.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC_CACHE_KEY).then(cache => {
return Promise.all(
STATIC_FILES.map(url => {
return fetch(new Request(url, { cache: 'no-cache', mode: 'no-cors' })).then(response => {
return cache.put(url, response);
});
})
);
})
);
});
言い忘れていましたが、Service Workerではファイルを取得する際にXHRではなくFetch APIを利用します。
これはFetch APIが返す Response
オブジェクトをCache APIが利用するためです。
余談ですが、Fetch APIはXHRと異なりPromiseを返す設計で扱いやすく、今後はService Workerだけに限らずブラウザでも利用することが可能となっていきます。
ここでもテクニックが登場します。
fetch
に与える Request
オブジェクトの第2引数にオプションとして { cache: 'no-cache', mode: 'no-cors' }
を指定しています。
今回の実装ではキャッシュはService Workerがインストールされた時にしか取得されません。
そのため、タイミングによってはmax-ageが切れる直前のファイルがキャッシュされて古いキャッシュを握ったままになってしまうことがあるため、 no-cache
によって Cache-Control: no-cache
でリクエストさせています。
また、Fetch APIはcors未対応のWebサーバからデータを取得する no-cors
に対応しています。
ここでは、これを利用することによりWebページにありがちな外部リソースへのリクエストもキャッシュ可能にしています。
ちなみに、 no-cors
によるレスポンスをそのままプログラムが扱えるのは好ましくないため、 Response
オブジェクトでラップすることでプログラムからは読み込めないがキャッシュとしては利用できるということを実現しています。
これもXHRではなくFetch APIが採用されている理由のひとつです。
キャッシュからレスポンスの返却
次は、今登録したキャッシュから onfetch
にてレスポンスを返します。
// sw.js
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});
ここは単純で、キャッシュがあったらそれを返し、無い場合はFetch APIにて結果を取得します。
event.request
は Request
オブジェクトなのでそのまま使用することができます。
ここまでで基本的なオフラインキャッシュの実装は終了です。
ここから先ではオプショナルなオフラインキャッシュのテクニックを解説していきます。
古いキャッシュの削除
ここまででキャッシュの取得と返却が完了しましたが、このままでは STATIC_CACHE_KEY
の数だけ古いキャッシュがCache Storageに蓄積されてしまいます。
その対策として、Service Workerが有効になった際に発火する onactivate
にて古いキャッシュを削除する処理を行います。
テクニックですね。
// sw.js
const CACHE_KEYS = [
STATIC_CACHE_KEY
];
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys.filter(key => {
return !CACHE_KEYS.includes(key);
}).map(key => {
return caches.delete(key);
})
);
})
);
});
CACHE_KEYS
に保持しておきたいキャッシュのキーを入れておくイメージです。
キャッシュのバージョン管理の自動化
先に、 STATIC_CACHE_KEY
を書き換えることでキャッシュのバージョンを管理すると書きましたが、とても手動で書き換える気にはなりません。
そこで私は、以下のようにしてキャッシュのバージョン管理を自動化しています。
いわゆるテクニックってやつです。
// sw.js
const VERSION = "<%= hash %>";
const STATIC_CACHE_KEY = 'static-' + VERSION;
何をしているのかというと、 ejsにてバージョンを外から受け取れるようにし、gulpでビルド時にバージョンを与えています。
// gulpfile.js
var gulp = require("gulp");
var babelify = require("babelify");
var browserify = require("gulp-browserify");
var plumber = require("gulp-plumber");
var uglify = require("gulp-uglify");
var ejs = require("gulp-ejs");
var execSync = require("child_process").execSync;
gulp.task("sw", function() {
var hash = execSync("git rev-parse HEAD").toString().replace(os.EOL, '');
gulp.src("assets/javascripts/sw/sw.js")
.pipe(plumber())
.pipe(ejs({
hash: hash
}))
.pipe(browserify({
transform: "babelify"
}))
.pipe(uglify())
.pipe(gulp.dest("build"));
});
ここではバージョン番号としてgitのrevisionを採用しています。
これにより、gitの最新revisionが変更されるたびに STATIC_CACHE_KEY
が変更され、キャッシュを最新のファイルで保つことができます。
昨今のフロントエンド開発ではタスクランナーは手放せないため、このようにすることでバージョンの手動管理から脱却しています。
Service Worker自体のキャッシュ管理
前述のService Workerの更新チェックや registration.update()
は、Service Worker自身の Cache-Control
に基づきます。
そのため、max-ageを長く設定してしまうと、更新したいタイミングに更新されないといったことが起きてしまいます。
そこで、サーバ側でService Workerのmax-ageを0に設定することをお勧めします。
no-storeにして毎回サーバから取得させてもいいですが、上述のバージョン管理の自動化を行った場合には、適切なタイミングでsw.jsが変更されるため、max-ageを0にした方が不要なリクエストが発生しなくなります。
これにより、適切なタイミングでのみService Workerの更新が走ることになります。
これもまたテクニックです。
受動的オフラインキャッシュ
最後はPush APIと組み合わせた受動的オフラインキャッシュです。
(これを実現するにはpayload付きWeb Pushである必要があります)
これはPush APIにてアプリケーション側からブラウザに対してプッシュ通知を行った際に、キャッシュさせたいURLを付与しておくことでそれをService Worker側でプッシュ受け取り時にキャッシュし、ユーザがプッシュ通知をクリックしたときに高速でWebページを開くためのテクニックです。
// sw.js
self.addEventListener('push', event => {
const options = event.data.json();
event.waitUntil(
caches.open(STATIC_CACHE_KEY).then(cache => {
fetch(new Request(options.data.url, { mode: 'no-cors' })).then(response => {
cache.put(options.data.url, response);
}).then(() => {
self.registration.showNotification(options.title, options);
});
})
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
onpush
で受け取ったイベントのjsonからキャッシュしたいURLを取り出しFetch APIで取得後キャッシュに格納し、そのあとでユーザに見せるための通知を表示させています。
これにより、ユーザが通知をクリックする際にはファイルがキャッシュ済みであるため、インターネットへのリクエストなしでユーザはページにアクセスすることが可能となっています。
Service Workerはブラウザのlifetimeとは独立して動作するため、このようなことも可能となっています。
まとめ
長くなりましたが、以上でService Workerを用いたオフラインキャッシュとそのテクニックの解説は終了です。
テクニックはいかがでしたでしょうか。
テクニックをまとめると、
- registration.update() の実行でページを開きなおすことなくService Workerを更新
- oninstall時にFetch APIでキャッシュを取得する際は
no-cache
で常に最新ファイルを取得 - キャッシュはバージョン管理して古いキャッシュは削除する
- キャッシュのバージョン管理はejsでビルド時に外から与えることで自動化
- Service Workerのファイルはmax-age=0にして常に最新を保つ
- Push APIを組み合わせることでプッシュ通知と同時にファイルをキャッシュし高速なユーザ体験を実現
といったところになります。
最後の受動的オフラインキャッシュなんかは、今後モバイルのブラウザのPush APIの実装が進むことで重要度が上がってくるのではないかと考えています。
特にブログやニュースサイトにおいて新しい記事の通知をするといった用途とは相性がいいでしょう。
最後にここまでのソースコードをまとめて終わりとします。
<script>
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/sw.js', { scope: '.' }).then(function(registraion) {
registraion.update();
});
}
</script>
// sw.js
const VERSION = "<%= hash %>";
const ORIGIN = location.protocol + '//' + location.hostname;
const STATIC_CACHE_KEY = 'static-' + VERSION;
const STATIC_FILES = [
ORIGIN + '/',
ORIGIN + '/stylesheets/index.css',
ORIGIN + '/javascripts/index.js',
'https://file.kaihar4.com/images/icon.png'
];
const CACHE_KEYS = [
STATIC_CACHE_KEY
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC_CACHE_KEY).then(cache => {
return Promise.all(
STATIC_FILES.map(url => {
return fetch(new Request(url, { cache: 'no-cache', mode: 'no-cors' })).then(response => {
return cache.put(url, response);
});
})
);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys.filter(key => {
return !CACHE_KEYS.includes(key);
}).map(key => {
return caches.delete(key);
})
);
})
);
});
self.addEventListener('push', event => {
const options = event.data.json();
event.waitUntil(
caches.open(STATIC_CACHE_KEY).then(cache => {
fetch(new Request(options.data.url, { mode: 'no-cors' })).then(response => {
cache.put(options.data.url, response);
}).then(() => {
self.registration.showNotification(options.title, options);
});
})
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});