Web●●●○○

Service Worker

Service Worker ・ さーびすわーかー

ページとネットワークの間に入るプログラム可能なプロキシ。オフライン対応とキャッシュ制御の要。

✦ 30秒で試す — 航海回線が切れてもページが開く。ただし一度訪れていればこの回線でニュースを開くと、表示はどうなる?予想してみる

概要

Service Worker は、Web ページとネットワークの間に割り込んで動く JavaScript のプログラムです。ページが出す HTTP リクエストを横取り(インターセプト)できるので、「このリクエストにはキャッシュから即答する」「これはネットワークへ取りに行く」「オフラインなら代わりのページを返す」といった判断をコードで書けます。ブラウザに内蔵された、プログラムできるプロキシ(中継役)だと考えると分かりやすいでしょう。

一度登録されるとページを閉じたあともブラウザに残り、必要なときにブラウザが起こします。ページとは別のスレッドで動くので、重い処理をしてもページの描画を邪魔しません。その代わり DOM には一切触れず、ページとは postMessage というメッセージのやり取りでしか話せません。この「ページから独立した裏方」という立ち位置が、オフライン対応・プッシュ通知・バックグラウンド同期といった「ページが開いていなくても動く」機能の土台になっていて、Web サイトをアプリのように使えるようにする PWA の心臓部でもあります。

なぜ生まれたか

Web はもともと「毎回サーバから取ってきて表示する」仕組みで、ネットワークが切れた瞬間に何もできなくなるメディアでした。一方ネイティブアプリは、コードもデータも端末に持っているのでオフラインでも当たり前に動きます。この差を埋める最初の試みが HTML5 の AppCache(Application Cache)でしたが、キャッシュする中身をマニフェストファイルで宣言するだけで、「いつ・何を返すか」を開発者が制御できませんでした。「更新したのに古い画面が出続ける」「意図しないページまでキャッシュされる」といった挙動が止められず、悪名高い仕様になってしまいます。

Service Worker は、この失敗から「宣言ではなく、コードで制御させる」という方針に切り替えて 2014 年ごろに設計されました。何をどうキャッシュし、どのリクエストにどう答えるかをすべて JavaScript で書けるようにしたことで、オフライン対応はブラウザ任せの魔法から、開発者が設計する普通のプログラミングになりました(AppCache はその後、主要ブラウザから削除されています)。

ただし、すべてのリクエストを横取りできる力は、悪用されれば中間者攻撃そのものです。そのため Service Worker は HTTPS で配信されたページからしか登録できません。例外は開発用の localhost(と 127.0.0.1)で、ここでは HTTP のままでも登録できます。

詳細

ページとネットワークの間に立つ

Service Worker の位置は、ページとネットワークの間です。登録するときに決めた範囲(スコープ。既定ではスクリプトを置いたディレクトリ以下)のページが出すリクエストは、HTML の取得も、CSS・画像・JavaScript の取得も、API の呼び出しも、すべていったん Service Worker に fetch イベントとして届きます。Service Worker はそこで、自分専用の保管庫である Cache API を見るか、ネットワークへ転送するか、その場で応答を組み立てるかを決め、respondWith() で答えを返します。何もしなければ、リクエストはそのままネットワークへ流れます。

ブラウザ(スコープ /app/ のページ)ページタブ1ページタブ2要求も応答もService Worker を通るService Worker別スレッド・DOM なしfetch イベントで横取りCache API要求と応答の控え照会・保存サーバネットワークの先必要なら転送切れていてもキャッシュで答えられる
Service Worker の位置。スコープ内のページのリクエストはすべて Service Worker を通り、Cache API とネットワークのどちらから答えるかをコードで決める

Cache API は、リクエストとレスポンスの組を丸ごと保存できる、Service Worker のための保管庫です。ブラウザが HTTP ヘッダに従って自動で使う HTTP キャッシュとは別物で、いつ入れ・いつ返し・いつ消すかをすべてコードで決めます。容量はオリジン(サイト)ごとに端末の空きに応じて割り当てられ、容量が逼迫すればブラウザが丸ごと消すこともあるので、「消えても困らない控え」として扱います。

ライフサイクル — install / activate と更新の罠

Service Worker には独特のライフサイクルがあります。ページが navigator.serviceWorker.register('/sw.js') を呼ぶとブラウザがスクリプトを取得し、まず install イベントが走ります。ここで、オフラインでも必要なファイル(アプリの殻の HTML・CSS・JS)を事前にキャッシュに入れるのが定石です。続く activate イベントで古い版のキャッシュを掃除し、以降はスコープ内のページのリクエストを制御し始めます。

つまずきやすいのが更新です。ブラウザはスコープ内のページを開いたときなどにスクリプトの更新を確かめ(スクリプトについては HTTP キャッシュを最長 24 時間しか信用しません)、1 バイトでも違えば新しい Service Worker をインストールします。ところが、古い Service Worker が制御中のタブが1つでも残っている限り、新しい版は「待機(waiting)」のまま有効になりません。デプロイしたのに全タブを閉じるまで反映されない — これは事故ではなく、同じサイトのページを新旧のワーカーが入り混じって制御しないための安全設計です。急ぎたいときは skipWaiting() で待機を飛ばせますが、開いているページが読み込んだ古い JS と、新しいワーカーが返すキャッシュの中身がずれる危険を理解したうえで使います。

register で登録

install イベント完了

install 失敗

制御中の旧ワーカーがいない

旧ワーカーが制御中

全タブが閉じる or skipWaiting

activate イベント完了

新版に置き換え

インストール中

インストール済み

破棄

有効化中

待機中

稼働中

もう一つの注意は、Service Worker は用がなければブラウザに止められ、次のイベントで起こし直されることです。グローバル変数に状態を持っても次に起きたときには消えているので、残したいものは Cache API や IndexedDB に書きます。

fetch イベントの往復

稼働中の Service Worker は、スコープ内のページが出すリクエストのたびに fetch イベントを受け取ります。次の図は、キャッシュを先に見る書き方の往復です。

ネットワークCache APIService WorkerページネットワークCache APIService Workerページalt[控えがある][控えがない]リクエストで fetch イベント一致する控えがあるか照会保存済みのレスポンスrespondWith で即座に応答リクエストを転送レスポンス複製を保存respondWith で応答

ページ遷移(HTML の取得)には特有の問題があります。止まっていた Service Worker を起こすのに数十〜数百ミリ秒かかることがあり、そのあいだネットワークへのリクエストが出せないのです。これを埋めるのがナビゲーションプリロードで、有効にするとブラウザは Service Worker の起動と並行して HTML のリクエストを先に送り、ワーカーは fetch イベントの中で event.preloadResponse としてその結果を受け取れます。ネットワークを先に見る戦略のページ遷移では、ほぼ必ず有効にしておくべき設定です。

キャッシュ戦略 — どちらを先に見るか

fetch イベントの書き方は、結局「キャッシュとネットワークのどちらを、どの順で見るか」の戦略選びです。代表的なのは次の3つで、資源の性質ごとに使い分けます。

cache-first(キャッシュ優先) は、まずキャッシュを見て、あればそのまま返し、なければネットワークへ行く戦略です。ロゴやフォント、ファイル名に内容のハッシュが付いた JS・CSS(app.3f2a.js)のように、同じ URL の中身が変わらないものに最適で、ネットワークに一度も出ずに答えられます。逆に、同じ URL の中身が変わる資源に使うと、キャッシュがある限り古いものを返し続けます。

network-first(ネットワーク優先) はその逆で、まずネットワークを試し、失敗したときだけキャッシュの控えを返します。ニュース一覧のように「鮮度が命だが、オフラインなら古い内容でも出したほうがまし」なデータ向きです。弱点は回線が遅いときで、切れてはいないが遅い回線を律儀に待ち続けるため、数秒で諦めてキャッシュに切り替えるタイムアウトを組み合わせるのが定石です。

stale-while-revalidate は折衷案で、キャッシュを即座に返しつつ、裏でネットワークから取り直してキャッシュを更新します。応答はいつも一瞬、中身は「次に開けば最新」になります。多少古くても許されるアバター画像や一覧データで威力を発揮します。

回線のスイッチを切り替えながら、3つの資源を何度かリクエストしてみてください。応答がどこから来たか、どれだけ待ったか、中身がサーバの最新と比べて古いかが表に並びます。サーバを押すと中身の版が上がります。

⚡ 体験: 回線を切り替えて、キャッシュ戦略を比べる
ブラウザの中ページfetch を発行Service Worker戦略で振り分けるCache API要求と応答の控えサーバ押すと更新オンライン低速オフライン押してリクエストapp.3f2a.js/api/newsavatar.pngキャッシュの中身app.3f2a.js —/api/news —avatar.png —サーバの最新app.3f2a.js v1/api/news v1avatar.png v1
app.3f2a.js
/api/news
avatar.png
要求戦略どこから待ち中身
まだリクエストしていません

時間は見やすく引き延ばしています(表の待ち時間は想定値)。

左の資源の札を押してリクエストを飛ばしてください。まずオンラインで一通り → 回線のスイッチをオフラインや低速にして、もう一度。

実務では、これらの戦略やキャッシュの期限・件数の管理を部品として提供する Google 製のライブラリ Workbox を使うことが多く、素の API で全部書くことは減っています。それでも「どの資源にどの戦略か」を選ぶのは開発者です。そして、キャッシュに版の名前を付けて activate で古い版を消す設計を怠ると、直したはずのバグが古いキャッシュから蘇り続けるという典型的な罠にはまります。いざというときに全員のキャッシュを確実に捨てさせる手段(何もしない Service Worker を配る「キルスイッチ」や、Clear-Site-Data ヘッダ)も、最初から用意しておくと安心です。Service Worker は強力なぶん、「消し方」まで設計してはじめて安全に使える技術です。

試問

ページ・Service Worker(ページとネットワークの間で要求を振り分ける門番のプログラム)・Cache API(応答の控えの置き場)・サーバが並んでいます。3つの資源の札を押すとリクエストが飛び、回線のスイッチでオンライン・低速・オフラインを切り替えられます。キャッシュは最初は空です。

✦ 命題挑戦中

回線を切った状態で、3つの資源をすべて表示せよ

  • 回線をオフラインにしている
  • オフラインのまま、3つの資源をすべて表示できた(0/3)
⚡ 体験: 回線を切り替えて、キャッシュ戦略を比べる
ブラウザの中ページfetch を発行Service Worker戦略で振り分けるCache API要求と応答の控えサーバ押すと更新オンライン低速オフライン押してリクエストapp.3f2a.js/api/newsavatar.pngキャッシュの中身app.3f2a.js —/api/news —avatar.png —サーバの最新app.3f2a.js v1/api/news v1avatar.png v1
app.3f2a.js
/api/news
avatar.png
要求戦略どこから待ち中身
まだリクエストしていません

時間は見やすく引き延ばしています(表の待ち時間は想定値)。

資源の札を押すとリクエストが飛びます。回線のスイッチで、オンライン・低速・オフラインを切り替えられます。