ブラウザは HTTP ヘッダを使ってキャッシュの制御を行うが、それ以外にも Service Worker と CacheStorage を使ったキャッシュも存在する。
Service Worker はリクエストを制御し書き換えることが可能なので、HTTP ヘッダの指定を無視した振る舞いをさせることができる。
例えば HTTP ヘッダを使ってキャッシュしないように設定したとしても、Service Worker でキャッシュしてそれを返してしまえば、サーバへの問い合わせは行われない。
この記事では、実際にコードを書いてどのような挙動になるのかを確認していく。
動作確認に使った環境は以下の通り。
- ウェブサーバ
- Deno v1.10.3
- ウェブクライアント
- Google Chrome 91.0.4472.77
環境構築
まずは検証用の環境を用意する。
index.html
。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Cache</title> </head> <body> <p id="result">No Data</p> <script> if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/service-worker.js'); }); } </script> <script src="/create-dom.js"></script> </body> </html>
このなかで/service-worker.js
を Service Worker として登録しているが、このファイルはまだ空でよい。
同じく HTML ファイルのなかで読み込まれているcreate-dom.js
は以下の内容にする。
function appendButton(text, eventListener) { const button = document.createElement("button"); button.type = "button"; button.textContent = text; button.addEventListener("click", eventListener); const paragraph = document.createElement("p"); paragraph.appendChild(button); document.body.appendChild(paragraph); } async function fetchScore(url) { const res = await fetch(url); const json = await res.json(); return { name: json.name, score: json.score }; } function showResult({ name, score }) { const result = document.querySelector("#result"); result.textContent = `${name}: ${score}`; } appendButton("Alice", async () => { showResult(await fetchScore("/api/alice")); }); appendButton("Bob", async () => { showResult(await fetchScore("/api/bob")); });
これで 2 つのボタンがページに追加される。どちらも API を叩き、その結果をページに表示する。
最後に、これらのファイルや API を配信するサーバを立てる。
server.js
という名前で以下のファイルを作る。
for await (const conn of Deno.listen({ port: 8080 })) { handleConn(conn); } async function handleConn(conn) { const httpConn = Deno.serveHttp(conn); for await (const { request, respondWith } of httpConn) { const path = new URL(request.url).pathname; switch (true) { case /^\/$/.test(path): { const html = await Deno.readFile(`./index.html`); respondWith( new Response(html, { status: 200, headers: { "Content-Type": "text/html" }, }) ); break; } case /\.js$/.test(path): { const jsFile = await Deno.readFile(`.${path}`); respondWith( new Response(jsFile, { status: 200, headers: { "Content-Type": "text/javascript" }, }) ); break; } case /^\/api\/alice$/.test(path): { respondWith( new Response(JSON.stringify({ name: "Alice", score: 90 }), { status: 200, headers: { "Content-Type": "application/json", }, }) ); break; } case /^\/api\/bob$/.test(path): { respondWith( new Response(JSON.stringify({ name: "Bob", score: 80 }), { status: 200, headers: { "Content-Type": "application/json", }, }) ); break; } default: { respondWith( new Response("Not Found\n", { status: 404, headers: { "Content-Type": "text/plain" }, }) ); break; } } } }
この状態で以下のコマンドを実行し、http://localhost:8080/
にアクセスする。
$ deno run --allow-net --allow-read --unstable --watch server.js
Alice
ボタンを押下するとAlice: 90
が、Bob
ボタンを押下するとBob: 80
が表示されるはず。
これで検証用の環境を用意できた。
HTTP ヘッダによるキャッシュ
まず、Cache-Control
フィールドを使ってキャッシュしてみる。
/api/alice
のheaders
に"Cache-Control": "max-age=30",
を追加する。
status: 200,
headers: {
"Content-Type": "application/json",
+ "Cache-Control": "max-age=30",
},
})
);
そうすると、/api/alice
のレスポンスヘッダにcache-control: max-age=30
が追加され、30
秒間キャッシュが有効になる。
そのためその間は、server.js
で定義しているAlice
のscore
をいくら変更しても、それをクライアントが取得することはない。
Bob
についてはキャッシュが有効になっていないので、score
を変更したあとにフェッチすれば、最新の値を取得できる。
Service Worker を使ったキャッシュ
空のファイルだったservice-worker.js
を以下の内容にする。
const VERSION = "v1"; self.addEventListener("install", (e) => { e.waitUntil( new Promise(async (resolve) => { const cache = await caches.open(VERSION); cache.addAll(["/api/bob"]); self.skipWaiting(); resolve(); }) ); }); self.addEventListener("activate", (e) => { e.waitUntil( new Promise((resolve) => { self.clients.claim(); resolve(); }) ); }); self.addEventListener("fetch", (e) => { e.respondWith( caches.match(e.request).then((response) => { if (response) { return response; } return fetch(e.request); }) ); });
こうすると、Service Worker のインストール時に/api/bob
にリクエストを飛ばし、その結果をキャッシュするようになる。
また、fetch
イベント内で、キャッシュがないかを確認している。そのため/api/bob
にリクエストがあった場合は、Service Worker がキャッシュの内容を返す。サーバへの問い合わせは発生しない。
そのため、Service Worker のインストール時のレスポンスがそのまま使われ続ける。server.js
による定義をいくら変更しても、それは反映されない。
これは、HTTP ヘッダでキャッシュしないように設定しても、Service Worker でキャッシュを返してしまえばサーバへのアクセスが発生しないことを意味する。
例えば、/api/bob
のCache-Control
を以下のように設定して、サーバへのアクセスが必ず発生するようにしてみる。
status: 200,
headers: {
"Content-Type": "application/json",
+ "Cache-Control": "no-store, no-cache, must-revalidate",
},
})
);
その後ブラウザの開発者ツールで Service Worker や CacheStorage を削除した上で、再度ページを読み込む。
そうすると、先程と同様に Service Worker のインストール時に/api/bob
にリクエストを飛ばすが、そのレスポンスにはcache-control: no-store, no-cache, must-revalidate
が含まれている。
この場合、通常は再アクセス時にサーバに問い合わせてレスポンスを取得するのだが、CacheStorage に保存したキャッシュを返すように Service Worker で設定しているため、サーバへの問い合わせが行われなくなってしまう。
ちなみに/api/alice
へのリクエストもfetch
イベントを経由するのだが、マッチするキャッシュが CacheStorage に存在しないため、サーバへの問い合わせを行おうとする。
このとき、HTTP ヘッダによる有効なキャッシュが存在すれば、それが使われる。この記事の例だと、30
秒間はキャッシュが使われ続ける。Service Worker のfetch
イベントを経由したからといって挙動が変わってしまうということはない。
CacheStorage の削除
service-worker.js
の内容を以下のようにすると、ページのリロード時や再訪時に Service Worker が更新され、CacheStorage からv1
と名付けられたキャッシュが削除される。
const VERSION = "v1"; self.addEventListener("install", (e) => { e.waitUntil( new Promise((resolve) => { self.skipWaiting(); resolve(); }) ); }); self.addEventListener("activate", (e) => { e.waitUntil( new Promise(async (resolve) => { await caches.delete(VERSION); self.clients.claim(); resolve(); }) ); }); self.addEventListener("fetch", (e) => { e.respondWith( caches.match(e.request).then((response) => { if (response) { return response; } return fetch(e.request); }) ); });
この状態でBob
ボタンを押下すると、常に最新の値をサーバから取得する。