1分でわかる要点
-
同じオリジンに複数のアプリを置くと、CacheStorageを共有します。Service Workerのscopeを分けても、キャッシュ名は自動で分離されません。
-
「現行キャッシュ以外を全部消す」という更新処理は、別アプリのキャッシュまで削除する可能性があります。
-
削除対象は、自アプリが所有し、廃止してよいと確認した世代に限定します。現行世代、別アプリ、所有者不明の名前は残します。
-
テストでは削除できたことに加えて、残すべきキャッシュの内容、更新前から開いている画面、オフライン時の動作を確認します。
同じホストの下に、 /catalog/ と /notes/ という二つのWebアプリがあるとします。それぞれでService Workerを登録し、オフライン用のデータを保存しています。
/catalog/ の更新後、 /notes/ のオフライン表示ができなくなる。そんな干渉は、更新時のキャッシュ削除処理でも起こり得ます。
この記事では、同じオリジンで動くアプリ同士が、更新のたびに相手のキャッシュを削除しないための設計を整理します。掲載コードは、実装からの抜粋と、削除範囲を説明するための例に分けて示します。特定の本番環境で改善を実測した報告ではありません。
なぜ別アプリのキャッシュが見えるのか
オリジンは、スキーム、ホスト、ポートの組み合わせです。https://example.com/catalog/ と https://example.com/notes/ はパスが違っても同じオリジンです。
Service Workerのscopeは、どのURLのページをそのService Workerが制御できるかに関わる範囲です。一方、CacheStorageには、scopeごとの専用領域が自動で作られるわけではありません。同じオリジンのアプリが同じストレージ領域を使う場合、caches.keys()には両方のキャッシュ名が並びます。
厳密には、ブラウザーのストレージ分割などにより、保存領域はオリジンだけでは決まらない場合もあります。ここでは、同じブラウザープロファイルで同一オリジンのアプリを通常のトップレベルページとして開く状況を想定します。scopeを分ければCacheStorageも分離される、と考えないことが重要です。
参考: MDNのscopeの説明、Service Workers仕様のCacheStorage、web.devの更新ライフサイクル解説
図1 scopeが違っても保存領域は共有する
scopeを分けても、キャッシュの所有者は自動では分かれません。図は、同じブラウザープロファイルで、同一オリジンのアプリを通常のトップレベルページとして開く場合を示しています。
危ないのは現行以外をまとめて削除する処理
更新時には、使わなくなったキャッシュを片づけたくなります。そこで、activateイベントに次のような処理を書きがちです。以下は危険な削除条件を示す説明用コードで、現在の実装からの抜粋ではありません。
const CACHE_NAME = 'catalog-v3';
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
)
);
});
この条件が確認しているのは、「自分が今使う名前と違うか」だけです。「自分が作ったキャッシュか」は確認していません。
catalog-v2 は削除されます。 notes-v1 も削除されます。名前が違う理由を区別していないためです。
実装ではプレフィックスで削除範囲を絞る
次は、修正が反映された実装からの抜粋です。キャッシュ名の定義とactivate処理を、間の処理を省いて並べています。名前と処理は変更せず、長い行だけ読みやすく改行しています。
const CACHE_VERSION = 'v3';
const CACHE_PREFIX = 'dmc08-';
const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => k.startsWith(CACHE_PREFIX) && k !== CACHE_NAME)
.map((k) => caches.delete(k))
)
).then(() => self.clients.claim())
);
});
削除条件にk.startsWith(CACHE_PREFIX)が加わり、dmc08-で始まらないキャッシュは対象外になります。ただし、このプレフィックスを別アプリが使わないという命名規約が前提です。また、同じプレフィックスで現行名と異なれば、未知の新世代も削除対象になります。後で示す廃止一覧方式は、ここから削除対象をさらに限定する説明用の設計例です。
同じファイルのfetch処理には、オリジンとパスを確認して対象外のリクエストを通過させる条件があります。しかし、その条件はactivate内のcaches.keys()やcaches.delete()には適用されません。リクエストの処理範囲と、更新時に削除してよい範囲は、それぞれ決める必要があります。
ここで確認できるのはコード上の削除範囲です。別アプリのキャッシュが実際に失われたことや、利用者に障害が起きたことまでは、このコードだけから断定できません。
キャッシュ名に所有者を含める
まず、同じオリジンで重複しないアプリIDを決めます。複数チームや本番・検証環境が同居するなら、その区別も含めます。
たとえば、次の命名規約です。
<アプリID>::<用途>::<世代>
example.catalog::assets::v3
example.notes::assets::v1
「::」は区切り専用とし、アプリIDや用途の中には使わないことにします。アプリIDはオリジン内で一意に管理します。同じ名前の採用を各アプリの判断に任せるだけでは、重複を防げません。
ここで、name.startsWith('app')という短い前方一致にすると、 app-tools のような別名まで含まれます。区切りを含めた完全なアプリID単位で判定するか、名前を分解してアプリIDを完全一致で比較する必要があります。
ただし、区切り文字さえ付ければ安全、というわけでもありません。「::をIDに含めない」「IDを重複させない」という規約とセットで境界が成立します。これは誤操作を減らすための命名ルールであり、同一オリジン内の悪意あるコードからキャッシュを守るアクセス制御ではありません。
削除してよい旧世代を明示する
以下は実装からの抜粋ではなく、名前と削除条件を変えた説明用の最小例です。廃止する世代を列挙します。キャッシュが一つの用途だけで、 v1 と v2 は廃止してよいと確認済み、 v3 を現在使うという前提です。
const CACHE_PREFIX = 'example.catalog::assets::';
const CACHE_NAME = `${CACHE_PREFIX}v3`;
/** 廃止してよいと確認した自アプリのキャッシュ名。 */
const RETIRED_CACHE_NAMES = new Set([
`${CACHE_PREFIX}v1`,
`${CACHE_PREFIX}v2`,
]);
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
const targets = names.filter(
(name) =>
name !== CACHE_NAME && RETIRED_CACHE_NAMES.has(name)
);
await Promise.all(targets.map((name) => caches.delete(name)));
})()
);
});
この例は完全一致で削除対象を限定するため、似た名前の別アプリや、まだ知らない新世代のキャッシュを巻き込みません。ロールバックして古いService Workerを再配布する場合にも、「自分の現行以外は古い」と決めつけずに済みます。
代わりに、廃止一覧の保守が必要です。更新を何世代も飛ばす利用者を考慮し、まだ回収したい過去の名前を一覧から早く消さないようにします。自動判定を使う場合は、アプリIDと用途の一致、世代の形式、削除してよい世代の範囲を明文化してから置き換えます。
複数の現行キャッシュを使うアプリなら、保持する名前もSetで管理します。用途が違うキャッシュや、継続利用するデータ領域まで、更新のたびに削除しないためです。
event.waitUntil()には、削除完了まで待つPromiseを渡します。activateに伴う非同期処理をイベントの寿命と結びつけるためです。上の例は失敗を握りつぶしていませんが、複数の削除はトランザクションではなく、一部だけ成功する可能性があります。失敗時の記録や再試行の方針は、アプリ側で別途決めます。
このコードには、install、キャッシュの中身を準備する処理、fetch、更新を利用者に案内する処理は含めていません。完成したService Workerとしてそのまま置き換えるためのコードではありません。
参考: MDNのwaitUntil()、Service Workers仕様のdelete()
図2 自アプリの廃止対象だけを削除する
廃止一覧は、自アプリの完全なプレフィックスから作った名前で管理します。古そうに見える名前を推測で削除せず、現行世代と一覧外のキャッシュを残します。
名前を変えるときと古い画面が残るとき
新しい命名規約を導入しても、既存のキャッシュ名は自動では変わりません。以前の名前が自アプリのものと確認できるなら、移行用の削除一覧に完全一致で加えます。 v1 や static のように所有者が分からない名前は、推測で削除しない方針にします。
もう一つ確認したいのが、旧版の画面です。通常、新しいService Workerは旧Service Workerが制御するクライアントがなくなるまで待機します。skipWaiting()を使うと、その待機を短縮できます。その場合、新しいService Workerが、旧版のHTMLやJavaScriptで動いている画面のリクエストを扱うことがあります。
旧画面が遅れて読み込むファイルを旧キャッシュにしか置いていなければ、旧キャッシュの削除が画面を壊す可能性があります。更新通知後に再読み込みする設計、移行期間に必要な世代を保持する設計、旧画面との互換性を保つ設計などから、アプリに合うものを選びます。clients.claim()の使用も含めて更新の流れを確認します。
確認した実装では、skipWaiting()とclients.claim()の両方が使われています。削除条件を狭める修正だけで、旧画面との互換性まで確認できたことにはなりません。
参考: web.devのService Workerライフサイクル
回帰テストでは残す側も確認する
削除条件のテストでは、少なくとも次の名前を同時に用意します。
-
自アプリの廃止対象 v1 、 v2 : 削除する
-
自アプリの現行 v3 : 残す
-
自アプリの未把握の v4 : この例では残す
-
example.notes::assets::v1 : 別アプリなので残す
-
example.catalog-tools::assets::v1 : 前方一致しそうな別アプリなので残す
-
example.catalog::runtime::v1 : この例の削除対象と用途が違うので残す
-
static 、 v1 などの所有者不明な名前: 残す
関数の単体テストでは、削除候補の集合が期待どおりか、削除処理を待っているか、失敗が握りつぶされていないかを確認します。実装とは別に同じ条件を書いたテストだけでは、実際のactivate処理の退行を見逃します。可能なら実際のService Workerのactivateハンドラーを動かして確認します。
次に、同じオリジンの別パスに二つのアプリを置いたブラウザーテストを行います。残す各キャッシュに識別用のレスポンスを入れ、片方の更新後にも名前と内容の両方が残っていることを確かめます。caches.open()は名前がなければ新規作成するので、「openできた」だけでは保持の証拠になりません。
更新の試験では、初回インストールだけで済ませず、旧版からの更新、複数世代を飛ばした更新、旧タブを開いたままの更新を含めます。片方だけでなく、もう片方のアプリの更新でも相手のキャッシュが残るか確認します。最後にネットワークを切り、必要な画面や遅延読み込みが動くかを確かめます。
テストは専用プロファイルや隔離したテスト用オリジンで実行します。普段使っているサイトの保存データをまとめて消す手順は、この回帰テストには不要です。
読み取り範囲とHTTPキャッシュは別に確認する
削除を限定しても、caches.match(request)を使うと、名前を指定していない場合はCacheStorage内の複数のキャッシュを検索します。同じURLを複数の世代が保存していると、意図しない世代のレスポンスを拾う原因になります。自アプリの現行キャッシュだけを読みたいなら、caches.open(CACHE_NAME)で取得したCacheに対してmatch()するなど、読み取り先も限定します。
確認した実装の読み取り処理にも、次の呼び出しがあります。以下は実装からの抜粋で、関数全体を変更せず掲載しています。
function cacheFirst(request) {
return caches.match(request).then((cached) => cached || fetch(request));
}
この関数はキャッシュ名を指定していません。削除条件を狭めても、読み取り先が自アプリの現行キャッシュだけに限定されるわけではありません。削除範囲と読み取り範囲は別の確認項目です。
また、ここで扱っているCacheStorageと、ブラウザーがHTTPヘッダーなどに基づいて管理するHTTPキャッシュは別の仕組みです。CacheStorageの名前を削除する処理で、HTTPキャッシュまで消えるわけではありません。fetch()がHTTPキャッシュを利用することもあるため、「CacheStorageを消したから、次は必ず最新データがサーバーから届く」とは限りません。
参考: web.devのService WorkerキャッシュとHTTPキャッシュ
まとめ
複数のアプリを同じオリジンに置くなら、キャッシュの所有者と廃止条件を、アプリ側で明示する必要があります。
最初に確認したいのは、activateの削除条件です。「現行以外」を削除しているなら、「自アプリが所有し、廃止してよい世代」に対象を絞ります。そのうえで、他アプリのキャッシュの内容が残ることと、更新前から開いている画面が動くことを確かめます。
キャッシュを片づけたあとに何が残るべきかまで決めると、更新処理を安心して見直せるようになります。