ブラウザのキャッシュクリア
Web 開発者であれば、誰もが一度は誤って問題のあるフロントエンドアセットをリリースしてしまった経験があるのではないでしょうか。しかもキャッシュの有効期間を30年に設定して。非常に困りますよね。これではユーザーが手動でキャッシュをクリアしない限り、問題のあるアセットがキャッシュに残ってしまいます。でも果たして本当にそうでしょうか。
短い TTL をアセットに設定せずにこの問題に対処する方法は実は多数あります。さらに、これらの方法には質の悪いリリースや問題が存在しなくてもアセットの素早い更新を計画できるというメリットもあります。これらのソリューションはいずれも、パージするアセットの URL がわかっていることと、スクリプトまたは HTML ページなど、実行可能な JavaScript を埋め込むことのできるアセットをアプリケーションがサーバーにリクエストしていることを前提としています。
location.reload(true)
最初のソリューション は[Steve Souders and Stoyan Stefanov came up with in 2012][2012年に Steve Souders 氏と Stoyan Stefanov 氏によって考案] (https://www.stevesouders.com/blog/2012/05/22/self-updating-scripts/)されたもので、「location」オブジェクトの「reload()」メソッドに「forcedReload」のブール型パラメーターが使用されることを利用したものです。MDN では以下のように説明されています。
これはブール型のフラグであり、「true」の場合、ページが常にサーバーからリロードされます。
リソースがキャッシュ内にあるかどうかに関係なく、ページの「すべて」のリソースがサーバーから読み込まれるのでしょうか。それともトップのドキュメントのみでしょうか。
トップレベルのドキュメントを目に見える形でリロードすることでユーザーの実行中のプロセスに干渉するのを回避するため、ここでは iframe を使うことが一般的に好まれます。トップレベルのドキュメント内のスクリプトで、以下を実行できます。
const ifr = document.createElement(‘iframe’);
ifr.src = “/forcereload?path=/thing/stuck/in/cache”;
ifr.classList.add(“hidden-iframe”);
document.body.appendChild(ifr);
「/forcereload」のレスポンスを以下のように設定します。
<iframe src=”/thing/stuck/in/cache”></iframe>
<script>
if (!location.hash){
location.hash = “#reloading”;
location.reload(true);
} else {
location.hash = “#reloaded”;
}
</script>
これをうまく機能させるには、iframe を作成し、無効化したいオブジェクトとは無関係な HTML ドキュメントを読み込み、さらにもう一度読み込むとともに、無効化したいオブジェクトも2回読み込む必要があります (1回目の読み込みはキャッシュから行われます)。これはかなりひどい処理です。さらに、ドキュメントにアタッチされた iframe が残るため、何らかの方法で削除しなければなりません。例えば、postMessage を使用して、iframe が削除可能であることを iframe から親に通知する方法があります。また、Philip Tellis 氏が指摘しているように、旧バージョンの自動更新しない Firefox の場合は、[go into an infinite reload loop]リロードの無限ループに陥ってしまいます。
結局のところ、[behave the way you might think it does]想定どおりには動作してくれません。MDN が指摘しているように、「forcedReload」引数は厳密には [the spec for the location interface]Location インターフェイスの仕様に含まれておらず、(少なくともサブリソースに関しては) この引数の値に基づいてネットワークフェッチを実行する場合、どのブラウザでも動作は変わりません。しかし、「reload()」については、ブラウザによって動作が異なります。Chrome では常にキャッシュから、Firefox、Edge、Safari では常にネットワークからサブリソースが読み込まれます。
「forcedReload」引数の影響は以下のみのようです。
- ドキュメント自体 (ここでは iframe の「reloader」) については、Firefox では「forcedReload」によってネットワーク経由でフェッチするように指示され、指示がない場合はキャッシュからフェッチします。その他のすべてのブラウザでは、常にネットワークからドキュメントがリロードされます。
- サブリソース (更新しようとしているスクリプトなど) については、ブラウザがリロードをネットワークにリクエストした場合、(Chrome 以外のすべてのブラウザでは)「forcedReload」を設定すると、リロードされるリソースに「ETag」ヘッダーや「Last-Modified」ヘッダーがある場合、条件付きリクエストの実行が阻止されます。一方、Chrome では「forcedReload」による影響はありません。いずれにせよ、ネットワークフェッチは行われません。
この手 法のもう1つのデメリットは、ブラウザの履歴に偽のエントリが追加されるのを防ぐ方法が存在しないことです。
以上が Steve Souders 氏が使用するソリューションですが、2012年に彼が作成したテストケースは現在の Chrome では機能しません。これは私自身のテストで確認済みです。Chrome の動作が変更されたのが原因のようです。この引数は仕様には含まれていないため厳密にはバグではありませんが、この手法を実装している方々もいたと思いますので、機能しなくなったのは残念です。
Vary + フェッチ
それでは、より優れていると思われるソリューションを見てみましょう。私は Vary ヘッダーが気に入っているのですが、ここでそれが役立つと思います。このヘッダーはすべてのブラウザでサポートされており、キャッシュキーではなくバリデーターとして使用されます。つまり、このヘッダーの値が変わると、既存のキャッシュされたオブジェクトは新しいリクエストに対して無効になり、「キャッシュ内の既存のオブジェクト」はダウンロードされた「新しい」オブジェクトに置き換えられます (この動作は、同じ URL の複数のバリアントを保存する CDN や他の「共有」キャッシュとは異なります)。
早速、サーバーからのすべてのレスポンスに「Vary」ヘッダーを設定し、指定されたものが存在しない場合にレス ポンスが変化するようにしてみましょう。
Vary: Forced-Revalidate
ブラウザは「Forced-Revalidate」ヘッダーを送信しないため、これは何の効果もありません。しかし、「fetch」は以下のようになります。
await fetch(“/thing/stuck/in/cache”, {
headers: { “Forced-Revalidate”: 1 },
credentials: “include”
});
この場合、何が起こっているのでしょうか。
- 「/thing/stuck/in/cache」をリクエストすると、キャッシュでヒットするものを探します。しかし、キャッシュにあるオブジェクトは、「Forced-Revalidate」のキーが「“”」(空の文字列) です。新しいリクエストは「Forced-Revalidate」の値が1であるため一致しません。また、リクエストに認証情報を含めて、通常のナビゲーションリクエストにこのレスポンスを確実に使用できるようにします。
- リクエストがネットワークに送信されます。サーバーは「Vary: Forced-Revalidate」を含んだまま、新しいバージョンのファイルを返します。
- ブラウザは、既存のキャッシュオブジェクトを「Forced-Revalidate: 1」のヘッダーを持つリクエストに対してのみ有効な新しいキャッシュオブジェクトで上書きします。
でも待ってください。今後、このキャッシュオブジェクトは「Forced-Revalidate」ヘッダーを持つリクエストでのみヒットすることになります。次回、ブラウザが通常の理由で (例えばナビゲーションやサブリソースとして) このファイルを読み込む場合、この特別なヘッダーは送信されないため、再びキャッシュミスになります。しかし、今回はダウンロードされたレスポンスに「“”」(空の文字列) の Vary キーが含まれるため、再利用可能になります。
[This is better]この手法の方が優れており、Edge、Chrome、Firefox、Safari のすべてが同一オリジンのリソースに対して正常に動作します。Firefox はクロスオリジンフェッチとナビゲーション用にキャッシュを分割するので、ナビゲーションキャッシュをクリアしません。今後ブラウザが複数のバリアントを保存するようになり、この手法の有効性がなくなる可能性があります。1行の JavaScript と若干変わった HTTP メタデータを使用し、オブジェクトを2回読み込む必要がありますが、それでも iframe を使用する必要がなく維持しやすいコードです。
もちろん、「headers: { “Forced-Revalidate”: 1 }
」を使用せずに、キャッシュをスキップするよう直接フェッチに指示できる方法が理想的です。
フェッチ + cache:reload
ここで、Fetch API の [Request object]Request オブジェクトの[cache property][キャッシュプロパティ(https://developer.mozilla.org/en-US/docs/Web/API/Request/cache)の登場です。これは、この問題を解決する最もシンプルかつ理想的な方法です。
await fetch(‘/thing/stuck/in/cache’, {cache: ‘reload’, credentials: ‘include’});
「reload」キャッシュモードでは、キャッシュを無視してネットワークから直接フェッチし、新しいレスポンスをすべてキャッシュに保存するように指示します。前述の手法と同様に、認証情報を含めて、キャッシュ目的の通常のナビゲーションと同様にフェッチが扱われるようにします。新し いレスポンスは今後のリクエストに対して直ちに使用可能になり、奇妙なヘッダーや iframe などは不要になります。
完璧に思えますよね。現時点では、この方法は [works in Edge, Firefox and Safari]Edge、Firefox、Safari で動作し、Chrome はあともう少しという段階です (Canary では問題なく動作しますがまだ安定していません)。同一オリジンのリソースに対するこの方法のブラウザによるサポートは、思ったよりもはるかに優れています。MDN のサポートテーブルは更新されていないので、Safari と Edge では最近になってサポートされるようになったようです。
ただし、Safari ではこの方法によってフェッチキャッシュのみがクリアされます。ナビゲーションによってフェッチキャッシュにコンテンツがキャッシュされますが、クリアはできません。また、Edge はこのクロスドメインをサポートする唯一のブラウザです。
フェッチ + POST
ここで、さらにすごい技をご紹介します。POST リクエストは、[invalidate cached content for that URL]URL のキャッシュされたコンテンツを無効化します。
安全ではないリクエストメソッドに対するレスポンスでエラー以外のステータスコードを受信した場合、キャッシ ュは必ず有効なリクエスト URI ([RFC7230] のセクション5.5) および Location と Content-Location のレスポンスヘッダーフィールド内の URI を無効化しなければなりません (存在する場合)。
問題はブラウザがこれに従い、レスポンスをキャッシュするのかということです。[Let’s see]それでは見てみましょう。フェッチを使用して、キャッシュされた URL に対するプログラムされた POST リクエストを生成します。
await fetch(‘/thing/stuck/in/cache’, {method:’POST’, credentials:’include’});
安全ではないメソッドであり、認証情報を含んでいるため、プリフライトリクエストを使用する必要があります。また、POST リクエストの結果がキャッシュ可能であると主張しているブラウザも含め、どのブラウザでも POST の結果がキャッシュされないことがわかりました (またはキャッシュされたとしても後の GET リクエストで使用されません)。そのため、無効化が行われてもキャッシュにコンテンツを再度生成するには最低3つのリクエストが必要になります。
この注意事項をふまえると、同一オリジンとクロスオリジンの両方のコンテンツのキャッシュをフェッチとナビゲーションの両方で無効化する単一のキャッシュメカニズムを採用する Chrome と Edge ではこの方法はうまく機能します。一方、Firefox と Safari は前述と同様のパターンに従い、ナビゲーションとフェッチを個別のキャッシュに分割するため、POST はフェッチキャッシュをクリアしますが、キャッシュされたオブ ジェクトがサブリソースの場合はキャッシュは無効化されません。
iframe での POST
やり始めたことは最後までやるべきですので、フォームを iframe に挿入し、そこで POST を実行してみましょう。わかっています。申し訳ありませんが「背に腹は…」というところです。
const ifr = document.createElement('iframe');
ifr.name = ifr.id = 'ifr_'+Date.now();
document.body.appendChild(ifr);
const form = document.createElement('form');
form.method = "POST";
form.target = ifr.name;
form.action = ‘/thing/stuck/in/cache’;
document.body.appendChild(form);
form.submit();
明らかな副作用がいくつかあります。これによりブラウザ履歴のエントリが作成され、レスポンスの非キャッシュという同じ問題に直面します。しかし、フェッチのためのプリフライトの要件から逃れることができます。また、ナビゲーションであるため、キャッシュを分割するブラウザは適切なキャッシュをクリアします。
この方法は[almost nails it]ほぼ成功です。Firefox はクロスオリジンのリソースのキャッシュされたオブジェクトを維持しますが、これは後続のフェッチにのみ使用します。各ブラウザは、同一オリジンとク ロスオリジンのリソースの両方においてオブジェクトのナビゲーションキャッシュを無効化します。
Clear-Site-Data
この記事では、不細工なソリューションから始まり、完璧なものを見つけたと思ったら、それほどではないことを発見し、結局はまた不細工なソリューションにたどり着きました。したがって、この物語は「すべてをワイプして白紙から開始」というサブタイトルがふさわしいソリューションで締めくくることになりそうです。
それでは Web 開発者の新たな大量破壊兵器 [Clear-Site-Data]Clear-Site-Data をご紹介します。
あらゆる URL のパージで、ターゲットオリジンへのすべてのリクエストに対するレスポンスで以下のレスポンスヘッダーを返すことができます。
Clear-Site-Data: “cache”
これで、キャッシュが消去されます。パージしたかったものだけではありません。オリジンのキャッシュ「全体」が消去されます。これで窮地から救われるかもしれません。
この方法のもう1つのメリットは、クライアント側の JavaScript を実行する立場にある必要がないという点です。したがって、画像やスタイルシートのリクエストに対してこのヘッダーを送信することもできます。このヘッダーの洗練さの欠如や残酷な効果はすがすがしい程です。
この機能に関する議論は数年前に遡りますが、つい最近になってようやく Chrome で見られるようになりました。しかし、この記事を書いている時点では、[reasons]何らかの理由により一時的に使用できなくなっています。つまり、現時点では[doesn’t work in any browser]どのブラウザでも機能しません。残念なことです。
最後に
以下に、各ブラウザがどのような状況で、サブリソースによって使用されたキャッシュを無効化するネットワークリクエストを実行するのか、まとめてみました。
手法 | Firefox | Safari | エッジ | Chrome | |
location.reload | ドキュメント、forceReload、同一オリジン | あり | あり | あり | あり |
ドキュメント、通常、同一オリジン | なし | あり | あり | あり | |
ドキュメント、forceReload、クロスオリジン | あり | あり | あり | あり | |
ドキュメント、通常、クロスオリジン | なし | あり | あり | あり | |
リソース、forceReload、同一オリジン | あり | あり | あり | なし | |
リソース、通常、同一オリジン | 場合による 1] | あり | あり | なし | |
リソース、forceReload、クロスオリジン | あり | あり | あり | なし | |
リソース、通常、クロスオリジン | 場合による 1] | あり | あり | なし | |
Vary + フェッチ | 同一オリジン | あり | あり 3] | あり | あり |
クロスオリジン | なし 2] | あり 3] | あり | あり | |
cache:reload | 同一オリジン | あり | なし 4] | あり | あり 5] |
クロスオリジン | なし 2] | なし 4] | あり | あり 5] | |
フェッチ + POST | 同一オリジン | あり | なし 4] | あり | あり |
クロスオリジン | なし 2] | なし 4] | あり | あり | |
iframe + POST | 同一オリジン | あり | あり | あり | あり |
クロスオリジン | あり 6] | あり | あり | あり | |
Clear-Site-Data | なし | なし | なし | なし |
[1] リソースに「Cache-Control: immutable」が設定されていない限り、ネットワークにリクエストを送信します。
[2] 外部オリジンのキャッシュをフェッチ/ナビゲーションキャッシュに分割するため、ナビゲーションキャッシュはクリアされません。
[3] フェッチはナビゲーションキャッシュとフェッチキャッシュの両方を無効化しますが、後続のフェッチではナビゲーションキャッシュにコンテンツが再度生成されません。
[4] ナビゲーションキャッシュはクリアされず、フェッチキャッシュのみがクリアされます。
[5] 現時点では Chrome Canary でサポートされています。
[6] フェッチキャッシュはクリアされません。
ここで取り上げたもの以外にも、サービスワーカーキャッシュ API など、他のキャッシュやストレージの機能がブラウザにはありますが、ここでは「Cache-Control」の HTTP ヘッダーを使用して実行するキャッシュの扱いに焦点を当てました。他の種類のストレージをクリアするメリットについては、また別の機会に別の記事でご紹介します。
結論として、スクリプトやその他のサブリソースを無効化したい場合は、「iframe + POST の手法」をお勧めします。これは、すべてのブラウザで同一オリジンとクロスオリジンの両方で機能します。
ただ、本当の意味で「正しい」手法は「cache:reload」なので、これを実際に使用できるように Safari や Firefox が今後、動作を変更することを願っています。