PWAとService Worker
JavaScriptのドキュメント内見かけることがある Service Worker。なにやら便利そうなな機能だなと思っていたのですが、利用するケースがありませんでした。
今回ようやくそのような機会がありましたので、ChagGPTの力をかりてそれを理解してみようと思います。
PWA
近年ではJavaScriptで多くの事ができるようになりました。Node.jsを利用すれば、ネイティブアプリの作成もできます。
しかし、それはWebアプリをネイティブアプリに変換するだけのもので、それができるようになった今でもWebアプリをネイティブアプリに近づけたいとよう要望が消えることはありません。
そのような、ネイティブアプリのに近づけたWebアプリを、Progressive Web Apps = PWAといいます。
従来のネイティブアプリかWebアプリかの違いのひとつに、ネットワークが遮断された時に起動できるか否かというものがありました。
今では、 Service Woker API を使う事で、ネットワークが遮断された時にもWebアプリを開くことができるようになりました。
以降で、このService Workerを使って、ネットワーク遮断時にキャッシュ領域からページを復元する方法を書き残したいと思います。
Service Woker API 使用のための要件とてSSL接続があります(localhostは除く)。
Service Worker
ここから実際に基本的な PWAを Servie Workerを使って構築していきたいと思います。
ページが接続できない場合、キャッシュからページを表示するようにします。
まず、ServiceWokerのコアとなるスクリプトを作成、配置します。
ここでは service-woker.js と名付けましたが、名前はどのようにしてもいいです。
ただ、このコアファイルを設置する場所より下の階層でないと Service Wokerは機能しません。
register
register は登録作業です。目的のページがロードされた時に、ブラウザに Service Workerの登録を指示します。
ユーザーエージェントの状態や身元情報を表す navigator から Service Woker が利用できるかチェック機構を入れるのが丁寧な書き方だと思います。
利用可能な場合はオブジェクトがそのままnavigatorに入っています。
このコードだけブラウザ側に登録して後は別のファイルにします。
index.html
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', ()=> {
navigator.serviceWorker.register('/service-worker.js')
.then(function(registration) {
console.log('Service Worker registered with scope:', registration.scope);
});
}</script>
登録時に有効なディレクトリを指定する事もでき、その際は register の第二引数に指定します。
install
これ以降のコードは独立した.jsファイル内に記述します。この jsファイルはWeb側の navigator.serviceWorker.register の引数として渡されますが、呼び出し元のDOMにはアクセスできない完全な別スレッドとなります。
register スクリプトを記述したページが表示された際に Service Workerが登録されていなかったり、そのバージョンが古い場合、install が行われます。
一連のインストール中に、作業させたい内容をイベントの中身として登録します。
ここでは、オフライン時に表示に用いるアプリ用のキャッシュを保存する指示を出します。
install の一部となりますので、ここで書いたコードが失敗すれば、install は失敗した扱いになります。
service-worker.js
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open('my-cache-v1.2').then(cache=> {
console.log('Opened cache');
return cache.addAll([
'/',
'/index.html',
'/offline.html',
'/styles.css',
'/script.js'
]);
})
);
});
コード内に self があります。通常のスクリプト内ではselfは グローバルオブジェクトのwindow を指しますが、Service Worker内では windowは存在しません。
このコード内で self は ServiceWorkerGlobalScope を指すものとなります。
言い換えるとService Workerは ServiceWorkerGlobalScope で実行されるものです。先ほどDOMにはアクセスできないと説明しましたが、その理由はここにあります。
waitUntilでラップして以降のキャッシュ非同期処理の終了を待機させています。
chases.openメソッドは、引数に指定した名前のキャッシュを操作するもので、ブラウザでも利用できるCache API を利用しています。このAPIは明示的にキャッシュを保存・参照できるものですが、通常のブラウザキャッシュとは別物で、それを制御できるものではありません。
ここでは名前を指定して、Service Worker用のキャッシュを保存しています。
キャッシュするアドレスは絶対パスで記述されていますが、相対パスを使うこともできます。相対パスを使う場合は Service Wokerのある場所や、registerメソッドで指定したスコープに依存することになります。
また Access-Control-Allow-Origin が適切に設定されていれば、httpから始まる外部のリソースも指定可能です。
接続不可能な時に、表示させるために必要なものをすべてキャッシュするようにします。index.htmlは通常、/ で表示できるようになっていますが、それをオフラインで実現する為には / もキャッシュに含める必要があります。
また、offline.html内で、style.css や script.jsを参照している場合は、それらもここにリストする必要があります。
activate
インストール成功後に avtivate(有効化) 処理が発生します。
これを利用して、古いバージョンのキャッシュを削除したりします。ここでは先のインストールで指定した「 my-cache-v1.2 」と 全バージョンの「 my-cache-v1.1 」があったとして、それらを残して他のキャッシュを削除します。
service-worker.js
self.addEventListener('activate', event=> {
const cacheWhitelist = ['my-cache-v1.1','my-cache-v1.2'];
event.waitUntil(
caches.keys().then(cacheNames=> {
return Promise.all(
cacheNames.map(cacheName=> {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
ここでも、waitUnitlを使って処理の終了を待ちます。Promise.allではキャッシュを削除する複数のPromiseをまとめています。
古い Service Worker を利用しているセッションがある場合は、この acitivate は待機され、リロード等のタイミングで実行されます。
もし、activateの待機をさせずに即時更新をしたい場合は、installイベントで次のように指定します。
service-worker.js
self.addEventListener('install', event=> {
event.waitUntil(
caches.open('my-cache-v1.2').then(cache=> {
return cache.addAll([
'/',
'/index.html',
'/offline.html',
'/styles.css',
'/script.js'
]);
})
);
self.skipWaiting();
});
event.waitUntilの後(外側)にself.skipWiting();を加えます。
fetch
fetch イベントは Service Woker のイベントで、ブラウザのリクエストをインターセプトします。イベントとして後に記述する処理で、カスタムしたレスポンスを返す事ができます。
この機能を使って、リクエストが失敗した場合にキャッシュからレスポンスを生成します。
service-worker.js
self.addEventListener('fetch', event=> {
event.respondWith(
fetch(event.request).catch(()=> {
return caches.match('/offline.html').then(res=>{
if (res) {
return res;
} else {
return new Response('404 Not Found',{ status:404, statusText: 'Not Found', headers: new Headers({'Content-Type': 'text/plain'}) });
}
});
})
);
});
respondWith は引数に渡した Promiseが解決したらそれをレスポンスとする関数です。
ここでは、ネットワーク接続が失敗した時(例外をキャッチした時)、キャッシュされたページ(のPromise)を返します。
caches.match は指定したページのキャッシュが存在した場合に中身をPromiseで返しますが、存在しない場合は undefinedが返るので、その際は明示的に404に差し替えています。
caches.matchでキャッシュを検索する際に、クエリパラメタを無視したい場合は第2引数に, { ignoreSearch: true }を与えます。ただ、筆者が試した環境では、このオプションはは jsファイルでは機能しませんでした。その場合はJSのコードで自ら .js?... があったらそれ以降をカットするようなコードを書けばよいです。
ここまでの処理ではWeb側を優先してキャッシュをフォールバックにしましたが、キャッシュがなければWebから取得するという場合は、次のようにします。
service-worker.js
self.addEventListener('fetch', event=> {
event.respondWith(
caches.match(event.request).then(res=> {
return res || fetch(event.request);
}).catch(function() {
return new Response('404 Not Found',{ status:404, statusText: 'Not Found', headers: new Headers({'Content-Type': 'text/plain'}) });
})
);
});
event.requestは文字列ではなくRequestオブジェクトですが、caches.matchメソッドは Requestオブジェクトも引き数として受けられます。ページのキャッシュを検索し、存在しなければ fetch API を使ってページを取得しています。
もしデータをPOSTすることがある場合は、インターセプトしてレスポンスを返してしまうここのコードは意図したように機能しませんので event.request.methodでPOSTメソッドだったらキャッシュを使わないように分離処理を書く必要があると思います。
ページキャッシュ以外にデータを保持したい場合、Service Worker内では localStorageが利用できないので、IndexedDBを使います。
Service Woker の更新と削除
service-worker.jsに記述している内容が変更されると Service Worker は再度 install を実行します。これは先に設定したキャッシュ名で判定しているわけではなく、service-worker.jp 全体で判定しています。
キャッシュ名はバージョニングを補助的に管理するものです。
また、一度インストールされた service-woker.js(サービスウォーカー)はブラウザの通常のキャッシュクリアでは消すことができません。
削除する場合は、開発者ツールなどから手動で削除するか、コード経由で削除指示を実行する必要があります。
コード上で削除をかける方法は次の通りです。
削除
navigator.serviceWorker.getRegistration().then(registration=> {
if (registration) {
registration.unregister().then(success=> {
if (success) {
console.log("Service Worker unregistered successfully.");
}
});
}
});
また、強制的に更新をかけたい場合は次のようにします。
ただし、既存のバージョンと同じと判断された場合は install や activateの処理は行われません。
強制更新
navigator.serviceWorker.getRegistration().then(registration=> {
if (registration) {
registration.update();
}
});
Web App Manifest
Service Worker を使って先のようなオフライン時のバックアップを利用することでネイティブアプリに近いユーザビリティが提供できるようになりますが、ブラウザの画面は通常通りだと思います。
Web App Manifestファイルを使うと、アドレスバーを消したり、ホーム画面にアプリのショートカットを置いたりすることができるようになります。
主な項目は次の通りです。
- name
アプリの名称
- short_name
略称
- start_url
起動時に表示するURL
- display
[fullscreen,standalone,minimal-ui,browser]から選択します。紹介した順番はネイティブアプリに近い景観順ですが、ブラウザによっては違いがない場合もあります。
- background_color
ロード時に表示される背景色
- theme_color
テーマカラー、ツールバーなどの色に影響します。
- icons
アプリのアイコンを複数のサイズで指定
jsonファイルの設定サンプルは次のようになります。少なくともひとつは144px角以上のアイコンがないとstandaloneのWebアプリとしてはインストールできないようでした。
web-app-manifest.json
{
"name": "My PWA",
"short_name": "PWA",
"start_url": "/index.html",
"display": "standalone",
"background_color": "#ffffff",
"icons": [
{
"src": "/images/icon-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/images/icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
ちなみに、ファイル名の拡張子は .webmanifest とすることを推奨する人もいます。
ファイルを生成配置したら、ページのヘッダに次の行を入れます。
index.html
<link rel="manifest" href="/manifest.json">この状態でブラウザからページを表示すると、ページを表示した際にインストールを選択できるようになります。Google Chromeだとアドレスバーの右端にアプリをインストールするためのアイコンが表示されます。
それを押してアプリをインストールすると、HOMEやデスクトップ画面にショートカットが作成され、そこから起動するとページはよりネイティブアプリに近い見た目になります。
アンインストールは立ち上げたアプリのメニューのどこかに組み込まれていると思います。アプリのアンインストールはService Worker の削除とは別物になります。