ブラウザのキャッシュをクリアする

ブラウザのキャッシュをクリアする

最も一般的で最も簡単にフロントエンドのパフォーマンスを向上できる方法は、ブラウザにアセットをキャッシュすることです。しかし、いずれかの時点で開発者は長いキャッシュ寿命を持つアセットを誤ってリリースしてしまう場合があります。正す方法があるのです。キルスイッチの導入方法を教えましょう。

私のようにWeb開発をしている方であれば、誰もが一度は質の悪いフロントエンドアセットをリリースしてしまったことがあるのではないでしょうか。そのせいで30年というキャッシュ寿命を与えてしまうことになってしまいます。凶報です。ユーザが手動でキャッシュをクリアしない限り、残ってしまいます。果たしてそうでしょうか。

最近、Steve Soudersと朝食を一緒にしたのですが、相手がSteveということもあり、会話は3時間も続き、終わった時には頭が痛くなっていました。Steveに課題として考えさせられたのが、キャッシュの新鮮さが失効する前にブラウザキャッシュ内のオブジェクトを無効にするにはどうすればいいのかでした。彼は、間違ったキャッシュの寿命をうっかり提供してしまうことよりも、短いTTL(time to live)を与えずにSpeedcurve LUXスクリプトをすばやく更新できるように計画することに悩んでいました。検討してみると、これを実現する方法はたくさんありました。

実現させる解決方法はどれも、除去したいアセットのURLを知っていることや、また、アプリが実行可能なJavaScriptを埋め込める何かを、何らかのリクエストとしてサーバに要求し続けていることが前提となりますので、スクリプトあるいはHTMLページが必要となります。

location.reload(true)

1つ目の解決方法は2012年にSteveとStoyanが発案したもので、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のレスポンスは下記のようになります。

code

実現するためには、iframeを作成する必要があり、無効にしたいオブジェクトとは無関係なHTMLドキュメントを読み込み、再び読み込んだ後に無効にしたいオブジェクトを2回読み込みます(1回目の読み込みはキャッシュからになります)。かなり悪いです。これらに加え、ドキュメントにiframeが添付されてしまい、何らかの方法で削除しなければなくなります。恐らくフレームのpostMessageを使用して、親にフレームを取り外しても大丈夫なことを知らせればできるでしょう。Philip Tellisが指摘しているように、古いバーションの自動更新しないFirefoxの場合は、無限のリロードループに陥ってしまいます

つまり、いずれにせよ想定どおりに動作してくれないのです。MDNによってドキュメントされているforcedReload引数は、厳密には、ロケーションインターフェイスの仕様の一部ではありません。さらに、(少なくともサブリソースに関しては)その引数の値に基づいてネットワークフェッチをしてもしなくてもブラウザ動作は変わりません。しかし、reload()においては、ブラウザによって動作異なります。Chromeは常にキャッシュから、FirefoxやEdge、Safariは常にネットワークからサブリソースを読み込みます。

forcedReload引数の影響は下記のみのようです。

  1. ドキュメント自体(ここではiframeの「reloader」)に関しては、Firefoxでは、ネットワークを介してフェッチするようにforcedReloadで指示します。指示がない場合は、キャッシュからフェッチすることになります。その他のブラウザにおいては、常にネットワークからドキュメントを読み込みます。

  2. サブリソース(ここでは更新しようとしているスクリプト)に関しては、ブラウザが再読込みをネットワークにリクエストした場合、Chrome以外の全てのブラウザでは、forcedReloadを設定すると、再読込みされたリソースにETagヘッダやLast-Modifiedヘッダが付いている場合は、条件付きリクエストの実行を阻止します。Chromeでは、forcedReloadによる影響はありません。いずれにせよ、ネットワークフェッチは行われません。

この方法のもう1つの欠点は、ブラウザ履歴に偽のエントリが追加されるのを防ぐ術が実在しないことです。

以上が、Steveが活用している解決策ですが、2012年に作成したこのためのテストケースは、今のChromeでは動作しません。これは、私のテスト結果でChromeでは動作しないことが確認できています。Chromeの動作が変更されたので、この方法は置いておきましょう。仕様にはない引数なので厳密にはバグではありませんが、この方法を実装している人がいると思いますし、使えなくなってしまったのは残念です。

Varyヘッダ及びフェッチ

では、解決策として最適と思われる選択肢に移りましょう。私はVaryヘッダに傾倒していますので、ここではそれを使いたいと思います。全てのブラウザに実装されていますし、キャッシュキーではなく検証ツールとして使用されています。つまり、変化したヘッダの値が変更されていた場合、既存のキャッシュオブジェクトは新規リクエストに対して無効となります。さらに、既にキャッシュに存在するオブジェクトは新規にダウンロードされたオブジェクトに置き換えられます(同URLの複数のバリアントを格納するコンテンツデリバリネットワークやその他の「共有キャッシュ」によって動作は異なります)。

では、サーバからの全レスポンスにVaryヘッダを設定し、存在しないものを変化させます。

Vary: Forced-Revalidate

これでは、ブラウザがForced-Revalidateヘッダを送信しないので全く影響はありません。しかし、フェッチ動作は下記のようになります。

await fetch("/thing/stuck/in/cache", {
  headers: { "Forced-Revalidate": 1 },
  credentials: "include"
});

実際にここでは何が起きているのでしょうか。

  1. /thing/stuck/in/cacheをリクエストするとキャッシュの中で一致するものを探します。しかし、キャッシュされたオブジェクトは””(空文字列)を使ったForced-Revalidateによって変化しています。新規リクエストは```Forced-Revalidate値が1なので一致はしません。また、リクエストに認証情報を含めてレスポンスが確実に通常のナビゲーションリクエストに使用できるようにします。

  2. ネットワークにリクエストが送信されます。サーバは新しいバージョンのファイルを返しますが、Vary: Forced-Revalidateを含んだままです。

  3. ブラウザは既存のキャッシュアイテムをForced-Revalidate: 1ヘッダ付きのリクエストにのみ有効な新しいアイテムで上書きします。

でも待ってください。これでこれからはキャッシュの中のアイテムはForced-Revalidateヘッダ付きのリクエストのみと一致することになります。次回ブラウザがナビゲーションとしてやサブリソースとしてといったような普通の理由でこのファイルを再読込みしようとした場合、特別なヘッダは送信しないのでキャッシュを再びなくすことになります。しかし、今回は””(空文字列)の変化したキーがダウンロードしたレスポンスに含まれますので、役に立つでしょう。

同一オリジンのリソースでEdge、Chrome、Firefox、Safariは全て正常に動作しているので、改善できました。Firefoxはクロスオリジンからのフェッチとナビゲーションのためにキャッシュを分割するので、ナビゲーションキャッシュをクリアしません。そうすることで、この先ブラウザが複数のバリアントを格納してこの方法を無効にする可能性があります。それでも、1行のJavaScript、ちょっと奇妙なHTTPメタデータ部分が残り、アイテムを2回読み込む必要もありますが、iframeはなくなり、かなり維持しやすいこのコードになると思います。

もちろん、headers: { "Forced-Revalidate": 1 }の代わりの何かでキャッシュを飛ばしてフェッチするように設定できるのが理想的です。

フェッチとcache:reload

ここでフェッチAPIのリクエストオブジェクトキャッシュのプロパティにたどり着きます。問題の解決に最も簡単で「正当」な方法に思えるでしょう。

await fetch(
  '/thing/stuck/in/cache', 
  {cache: 'reload', credentials: 'include'}
);

'reload'キャッシュモードの場合、キャッシュを無視し、ネットワークから直接フェッチするように指示し、さらに新規レスポンスは全てキャッシュに保存するように指示します。上記でも行ったように、認証情報を含め、フェッチがキャッシングを目的とした通常のナビゲーションと同様に扱われるようにします(扱われるようになるのではないかと思います)。新規レスポンスは直ちに今後のリクエストに対して使用可能になり、奇妙なヘッダやiframeなどが必要なくなります。

完璧じゃないですか。現時点でこの方法はEdge、Firefox、Safariで動作し、Chromeはもう少しというところです(Canaryでは問題なく動作しますが、安定していません)。同一オリジンのリソースのサポートは予想よりも遥かに優れていました。MDNのサポートテーブルは更新されていないため、それが、最近になってSafariとEdgeに影響したのではないかと思います。

さらに、Safariでは、この方法ではフェッチキャッシュのみをクリアにするだけです。ナビゲーションによってフェッチキャッシュを入れることはできますが、クリアすることはできません。また、Edgeはこのクロスドメインをサポートする唯一のブラウザです。

フェッチとPOST

さらに大きな技を出しましょう。POSTリクエストはそのURLのキャッシュされたコンテンツを無効にします

安全ではないリクエストメソッドの応答として非エラー状態コードを受信した場合、キャッシュは必ず有効なリクエストURI([RFC7230]のセクション5.5)及びLocationレスポンスヘッダ及びContent-Locationレスポンスヘッダのフィールド内のURI(存在する場合)を無効にしなければなりません。

問題はブラウザがこれを尊重し、レスポンスをキャッシュするのかということです。では見てみましょう。フェッチ動作を使って積み上げられたURL用にプログラミングでPOSTリクエストを生成します。

await fetch(
  '/thing/stuck/in/cache', 
  {method:'POST', credentials:'include'}
);

安全ではないメソッドであり、認証情報を含んでいるため、プリフライトリクエストで我慢するしかありません。さらには、POSTの結果がキャッシュ可能である(またそうであれば、後続のGETを満たすためには使用しません)ことを宣伝しているにも関わらず、POSTの結果をブラウザはキャッシュしないことが分かりました。そのため、無効であることが分かっても、キャッシュを再び埋めるには最低3つのリクエストが必要となります。

この注意がある上で、キャッシュを同一オリジンのコンテンツ及びクロスオリジンのコンテンツ両方の無効化、フェッチとナビゲーション両方の無効化をするものという1つに観点で捉えているため、ChromeとEdgeではこの方法で問題なく動きます。FirefoxとSafariは、上記で見たようにナビゲーションとフェッチを別々のキャッシュに分割するのでPOSTはフェッチキャッシュをクリアしますが、積み上げられたオブジェクトがサブリソースである場合は残念ながら動きません。

iframeでのPOST

さて、やり始めたことは最後までやらなければいけませんので、FORMを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();

良くない結果が出ました。これはブラウザ履歴のエントリを作ってしまい、レスポンスのノンキャッシュと同じ問題に直面することになります。しかし、フェッチに存在する、プリフライトの要求から逃れさせてくれますし、ナビゲーションなので、キャッシュを分割するブラウザは正しいものをクリアします。

これはほぼ成功します。Firefoxはクロスオリジンのリソースのために積み上げられたオブジェクトを保持しますが、あとに続くフェッチのためだけです。全てのブラウザはオブジェクトのためのナビゲーションキャッシュを、同一オリジンのリソースでもクロスオリジンのリソースでも無効にします。

Clear-Site-Data

私たちは醜いものから始めて完璧を見つけ、それから完璧は大したものではないということを発見し、結局は醜いものに戻りました。ですから、私たちの物語は「全てを焼き尽くせ」というサブタイトルがつくようなオプションで終わることになりそうです。大規模な破壊を行う開発者の武器、Clear-Site-Dataを試してください。

あなたがどんなURLを除去したいのだとしても、ターゲットオとなるオリジン上のあらゆるリクエストに次のようなレスポンスヘッダを返すだけでいいのです。

Clear-Site-Data: "cache"

すると、あら不思議。キャッシュが消えます。しかも、あなたが除去したいと思っていたものだけではありません。あなたのオリジンの全てのキャッシュが、なくなっています。でも、危機的状況から救ってくれるかもしれません。

このメソッドのもう1つの利点は、クライアント側のJavaScriptを実行する立場にある必要がないということです。ですから、画像やスタイルシートリクエストに応じて送ることもできます。洗練や華々しい効果が欠けているのは最悪ですが。

この機能に対する議論は数年前に遡りますが、Chromeに現れたのはつい最近です。しかし書いている時点では、ある理由から、一時的に使えなくなっています。つまり、現段階ではどのブラウザでも機能しません。残念。

結論

それでは、まとめましょう。どのような状況で、ブラウザはサブリソースに使われたキャッシュを無効化するネットワークリクエストを作るのでしょうか?

chart
注釈:
Technics:方法
same-origin:同一オリジン
cross-origin:クロスオリジン
resource:リソース
Yes:する
No:しない
Varies:場合による

[1]リソースがCache-Control: immutableを持たない場合はネットワークにリクエストを出します。
[2]外部のオリジンのためのフェッチ/ナビゲーションキャッシュを分割するので、ナビゲーションキャッシュはクリアされません。
[3]フェッチはナビゲーションキャッシュでもフェッチキャッシュでも無効にしますが、あとに続くフェッチはナビゲーションキャッシュを再読み込みしません。
[4]ナビゲーションキャッシュはクリアされず、フェッチキャッシュのみクリアされます。
[5]現在はChrome Canaryでサポートされています。
[6]フェッチキャッシュはクリアされません。

ここで言及したもの以外にも、例えばService WorkerのCash APIなど他のキャッシュや記憶能力がブラウザにはありますが、ここではCache-ControlHTTPヘッダをターゲットにするキャッシュを扱うことに焦点を当てました。他のストレージをクリアにするメリットについては、また別の機会に、別の記事でお伝えしたいと思います。

さて、まとめですが、もしスクリプトやその他のサブリソースを無効化したいなら、私はここで述べたiframeとPOSTのテクニックを使います。これは同一オリジンでもクロスオリジンでも、全てのブラウザで機能します。

「正しい」方法はcache:reloadなので、SafariやFirefoxは、将来的にはより実用的になるように動作を変更してくれることを願っています。