bfcache について覚えて帰ってもらいます。(転載)

動作サンプル消しちゃったの直しました。

以下の内容は、『 mixi Engineers' JavaScript Advent Calendar 2012 』の12月19日分として投稿したものの転載です。内容に差異はありません。

bfcache について覚えて帰ってもらいます。

こんばんは。日々、一体お兄ちゃんだけど愛さえあれば関係ないのかどうなのか、そこのところについて確認作業を怠らないものです。

今日、偶然にもこれを閲覧してしまったみなさまには、近代ブラウザの誇る謎機能、 bfcache について覚えて帰っていただきます。どうぞよろしくお願いいたします。

bfcache (正式名称なのか、別名なのか定かではありませんが Back-Forwad Cache とも呼ばれます)をおもむろに Google さんに検索いただくと、権限のありそうな回答は

です。どちらも Mozilla Developer Network の記事です。

さもあらんこの bfcache というのは、 HTML / DOM / CSS / ECMAScript 等の仕様でもなんでもなく、 UA であるブラウザ側が、ユーザの利便性を高めるために独自に実装しはじめたものです。その名前が表すとおり、戻る・進むボタンを押してページ遷移した際の体感速度を向上させてくれるもので、おそらくは Opera が最初に実装したものですが bfcache という名前をつけてその機序(と回避方法)を正確に世間に表明したのは Firefox が最初でした。現在では、 Firefox と Webkit で、このふるまいをするという意味での狭義の bfcache が実装されています(追記: Webkit では "Page Cache" と呼ぶようです。原文頁末の kzys さんのコメントをご参照ください)。

そして、その時の記事( Firefox 1.5 の実装です)が依然として最も権限のある記事になっています。そしてこの記事の不完全性が、 stackoverflow においてすら

「 bfcache を避けるにはどうしたらいいんだ?」
「 onunload イベントを付けろ」
「回避できない!」
「いや、俺は回避できた!」
「俺はできない!」
「俺はできる!」
「バーカバーカ」
「俺を…腰抜けって呼ぶんじゃねえ!」

という不毛な悲劇を数多く生んできました。ぜひ、賢明な読者のみなさまにおかれましてはここでこの bfcache について正確なところを把握して帰っていただきたいと思います。

bfcache とは二段階あります。

いきなりの最終回答誠に恐縮ですが、世に「 bfcache 」と呼ばれるものには、その効果及び回避方法の面から言うと二種類あります。1つは、おそらく皆様もよくご存知の、

  • onload ハンドラのスキップ
  • JavaScript オブジェクト状態の保存

が行われる段階です。JavaScript アプリケーション開発においては、こちらがよく注目されます。

次に2つ目は、

  • ブラウザのネットワーク層での暗黙リソースキャッシュ

です。いわゆる「キャッシュを削除してください」のあのキャッシュなのですが、

「このリソースはキャッシュしてもよいものだ」かつ、
「そして今この状況はそのキャッシュしておいたリソースの複製をユーザにレスポンスすべきだ」

とブラウザが判断した時に、暗黙のうちに、ブラウザが内部的に保存しておいたリソースの複製をブラウザから上のレイヤーにとってはあたかもサーバが200番でレスポンスしたかのように蘇らせる( HEAD 、 GET 等のネットワークアクセスは発生しない)。というものです。これも bfcache なのか?。と思うのですが、発生のタイミングが主に history トラバーサル時(戻る進むの時!)であり、かつ Firebug がこのリソース複製のレスポンスを bfcache と呼んでいることから、ここでは bfcache として扱います。

mixi では、いわゆる「ホーム」である home.pl 上で JavaScript が数多く動作するようになってから1つ目の bfcache については対応を行って来ましたが、長らく2つ目の bfcache については対応できておりませんでした。

そのため、

「 mixi 見る」
「通知エリアでイイネ!通知だ!」
「わーいクリック」
「通知消える」
「気になるニュースをクリックして遷移」
「ホームに「戻るボタン」で戻る」
「通知エリアでイイネ!通知だ!」
「わーいクリック」
「さっきと一緒じゃねえか!」
「 mixi オワコン」

といった悲劇を招いていました。

とはいえ、まずは1つ目から順番にご説明したいと思います。下記の PHP をご覧ください。

<?php
header("Cache-Control: public");
?>
<!DOCTYPE html>
<html>
<head>
</head>
<body onload="document.getElementById('test').innerHTML='JS time:'+(new Date()).toString();">
<p><?php
echo "PHP time:" . date("Y/m/d g:i:s");
?></p>

<p id="test"></p>

<p><a href="1_back.php">traverse</a></p>

<script type="text/javascript">
var kido = {
  ibuki : null
};

window.onpageshow = function(evt) {
  if ( evt.persisted ) {
    alert("yay! bfcache!");
  }
};
</script>
<button onclick="kido.ibuki='kawaii'; alert('set.')">set</button>
<button onclick="alert('kido.ibuki == ' + kido.ibuki);">alert</button>

</body>
</html>

これは

  • サーバ側の時刻を php から出力
  • クライアント側の時刻を onload ハンドラ内で出力
  • インライン JavaScript でオブジェクトを生成
  • ボタンクリックでそのオブジェクトのプロパティ値を変更できる

というものです。

実際に動作するサンプルはこちらです。

サンプルを表示しましたら(できれば Firefox が望ましいです)、

  1. 「 set 」ボタンを押し
  2. 「 alert 」ボタンを押して alert されるのを確認し
  3. 2行目の JS から出力された時刻の秒に注目し、
  4. 「 traverse 」リンクをクリックし
  5. 遷移した先のページで「戻るボタン」で戻ってください。

どうですか?。 "yay! bfcache!" と表示されましたか? JavaScript の時刻の秒のところが、先ほどと同じではありませんか?。そして、「 alert 」ボタンを押すと、依然として kawaii と表示されませんか?

これが、1つ目の bfcache です。これらはつまり

  • インライン JavaScript 実行のスキップ
  • onload ハンドラ実行のスキップ
  • JavaScript オブジェクトの保存と回復
  • DOM 状態の保存と回復

が行われていて、

  • window.onpageshow イベントのイベントオブジェクトの persisted プロパティが true

になった、ということを示しています。

え?。ならない?。「戻るボタン」で戻ってきても、 "yay!" って言われない?。秒が毎回更新される?。戻ってきたあとに「 alert 」ボタンを押すと、 "null" と表示される?

あるんですそういう場合。特に(おそらくですが)ネットワークと CPU が「十分に速い」とブラウザが判定した場合、つまり、「このリソースはキャッシュしてもよいものだ」かつ、
「そして今この状況はそのキャッシュしておいたリソースの複製をユーザにレスポンスすべきだ」という2つの条件のうち、良い意味であっても後者が満たされないとブラウザが考えた場合は、この1つ目の bfcache 処理は行われない場合があります。恐ろしいですね。誰かソース見てあとで僕に教えてください。

ここまでが、 bfcache 第1段階目の仕組みです。これを回避する方法は簡単で、

  • onunload イベントにフックする

です。これは bfcache の第1段階にのみ有効な回避方法で、空のハンドラでも良く、とても簡単です。こちらが body タグに onunload 属性をつけたものです。中身は空です。

これで、第1段階目の bfcache は絶対に起こらなくなります。 window.onpageshow イベントの event.persisted は常に false ですし、 JS 秒も毎回更新されます。おめでとうございます。

さて、次が2つ目の bfcache です。まず、先程の onunload イベントにフックした例を、もう一度ご覧ください。今度は「 PHP time 」というところに注目して、

  1. 「 traverse 」リンクをクリック
  2. 「戻るボタン」で戻る

を行なってみてください。 onunload イベントにフックしたおかげで JS の出力する時刻は更新されても、 PHP から出力されている時刻は更新されていないと思います。今度は、どんなに速いマシン、太いネットワークでもなるはずです。戻るボタン進むボタンを交互に連打すると、 JS の時間だけが進んでいって面白いです。

え、当たり前だぁ?。本当にこれ、当たり前だと思いますか?。では、次のソースを見てください。

<?php
header("Cache-Control: no-cache");
?>
<!DOCTYPE html>
<html>
<head>
</head>
<body onload="document.getElementById('test').innerHTML='JS time:'+(new Date()).toString();" onunload="">
<p><?php
echo "PHP time:" . date("Y/m/d g:i:s");
?></p>

<p id="test"></p>

<p><a href="1_back.php">traverse</a></p>

<script type="text/javascript">
var kido = {
  ibuki : null
};

window.onpageshow = function(evt) {
  if ( evt.persisted ) {
    alert("yay! bfcache!");
  }
};
</script>
<button onclick="kido.ibuki='kawaii'; alert('set.')">set</button>
<button onclick="alert('kido.ibuki == ' + kido.ibuki);">alert</button>

</body>
</html>

Cache-Control:no-cache をレスポンスヘッダに載せることにしました。実際に動くものはこちら。

つまりこれは、 Cache-Control:no-cache と、 onunload イベントの合わせ技です。強そうです。どんなキャッシュも寄せ付けなさそうな感じがします。それでは、ぜひ

  1. 「 traverse 」リンクをクリック
  2. 「戻るボタン」で戻る

を行なってみてください。

どうですか?。 PHP 時間が更新されましたか?。おそらく、されないはずです。これが bfcache の第2段階です。 Firebug のネットワークパネルで、

「 BFCache のレスポンスを表示」というオプションを選択すると、ステータスのところに (BFCache) と明示されるようになるのが、この、リソースに対する bfcache です。 Chrome の devtool だと (from cache) です。

これは HTML だけではなく JS 、 CSS 、画像、すべての HTML 描画に関するリソースに適用されているようです。どこかのページで Firebug を有効にして、戻るボタンで戻ってみてください。結構多くのネットワークリソースが bfcache からレスポンスされて、実際にはネットワークリクエストが飛んでいないことがわかります。

考えなくても明らかなように、これは露骨にページ表示速度を向上させます。何しろ、 HEAD の確認にすら行きませんし、どうやらこれはオンメモリキャッシュのようです。そりゃ高速です。でも、困る時があるんです。

最近の JavaScript アプリケーションでは、 mixi のホームもそうですが、最初に HTML がサーブされた状態とその後 JavaScript によってコンテンツが追加・更新・変更されていった状態の間には結構な差異が生じます。リソースの bfcache が有効になっていると、「戻るボタン」で戻ってきた際にこの差異が全部吹っ飛んでしまい、最初にネットワーク越しにちゃんとレスポンスされた HTML の状態に戻ってしまう。というわけです。

これには、 mixi では通常 onunload イベントにフックして第1段階の bfcache を回避していることも影響しています。 bfcache をそのまますべて受け入れるように設定されていれば、基本的に JavaScript によって追加されたコンテンツもそのまま蘇ってくるはずだからです。

しかし、 bfcache が最初の登場した頃のブラウザ特に Safari はこの実装が非常に雑で、(第1段階の) bfcache を有効にしたままセキュアな JavaScript アプリケーションを作成することがとても困難でした。言い換えれば、簡単な対策であっても一度それをしてしまうとそれ以降の開発に足枷をはめるものでした。

とはいえ今なら、 bfcache を最大限に活かし、 event.persisted プロパティをチェックし、 bfcache から蘇った JS アプリケーションでも上手に動作させることは可能だと思います。ここのところはひとえに我々の技術不足と大規模開発における制約があると感じます。

話を戻しますと、リソースの暗黙 bfcache がネックになるのは XHR など何らからの手法で取得する JSON データもそうかもしれません。最後にネットワーク越しにサーバから取得された JSON をいつもブラウザが保存していて、ユーザがどこかのページに行ってしばらくしてからおもむろに「戻るボタン」で戻ってきた場合も、その最後に取得した JSON (それかなずいぶん時間が経っていることでしょう)が、いかにも最新のふりをして、しれっと200番でレスポンスしてくるのです。

これを回避する方法は2つあります。

  • Cache-Control:no-store をレスポンスする
  • https プロトコルで、 Cache-Control:no-cache をレスポンスする。

です。 meta タグの http-equiv は通用しません。 HTTP レスポンスヘッダに載っていなければ駄目でした。また、ホストドキュメント( html )にだけこれを行うと、その html リソースだけ、常にネットワークから来るようになります。その HTML が使用する外部 JS や CSS ファイルには影響しません。影響させたければ、それらのリソースのレスポンスにも個別にこの対策が必要です。

とりあえず、証明書高いし、自分のドメイン用に持ってないんで、今回は no-store を載せてみます。ソースは以下です。

<?php
header("Cache-Control: no-cache, no-store");
?>
<!DOCTYPE html>
<html>
<head>
</head>
<body onload="document.getElementById('test').innerHTML='JS time:'+(new Date()).toString();" onunload="">
<p><?php
echo "PHP time:" . date("Y/m/d g:i:s");
?></p>

<p id="test"></p>

<p><a href="1_back.php">traverse</a></p>

<script type="text/javascript">
var kido = {
  ibuki : null
};

window.onpageshow = function(evt) {
  if ( evt.persisted ) {
    alert("yay! bfcache!");
  }
};
</script>
<button onclick="kido.ibuki='kawaii'; alert('set.')">set</button>
<button onclick="alert('kido.ibuki == ' + kido.ibuki);">alert</button>

</body>
</html>

実際に表示してみてください。 PHP 時間も JS 時間も必ず更新されます。「戻るボタン」で戻ってこようが、普通に遷移してこようが、必ず更新されます。

これが、良かれ悪しかれ bfcache を完全に回避した状態です。

ちなみに手元の Firefox では、この2段階目への回避策を行うと同時に1段階目も回避されるようです。Cache-Control:no-store で、 onunload へはフックしない例を用意してみました。いかがでしょうか。

以上が、今日覚えて帰っていただきたい bfcache の内容です。なんだか偉そうに説明しましたが、結局のところ、

「 history.back には no-cache は通用しなかった! no-store だった!しかもずっと前から!!」

という一言に尽きるわけです。

最後にブラウザごとに BrowserStack で試した感じですが(追記: 頁末の kzys さんのコメントもご参照ください)

Firefox では BrowserStack で試せる一番古い 3.0 から、綺麗に上記のとおりになっています。軽いサーバで第1段階がスキップされることもありました(でも、本当に軽さで決まっているかは、僕はソースを見ていないので確かなことは言えません)。

Chrome では、どうもわかりません。ひょっとしたら bfcache としてではなく、通常のキャッシュ機構として、上記で言うところの第2段階目の bfcache を実装しているのかなと思います。やはり history.back に対しては no-store じゃないとだめです。第1段階の bfcache は今は無いんじゃないかと思います。

Safari はますますよくわかりません。昔々、 1.x の時は、確実にありました。 mixi でもそこにハマって困ったことをよく覚えています。でも、今は Chrome 同様そもそも無いような感じがします。

IE はありません。10になってもありません。

以上、挙動・ふるまいから bfcache について調べてみました。ブラウザのソースを読むのが趣味の方、いらっしゃいましたら、ぜひこのところ、 firefox と webkit でどういう実装になっているか、教えていただけたら心の底から嬉しいです。

どうぞよろしくお願いいたします。


追記

でも、正直 no-store と no-cache の扱い、納得いかなくないですか?。と思いまして、 HTTP1.1 の RFC2616 を見てみたところ、

ヒストリ機能はキャッシュ機構とは違う
ヒストリ機能はキャッシュ機構の期限に従う「べきでない」

とか書いてありました…。そりゃ、 bfcache に no-cache 効かないです(効かないようにブラウザベンダも実装します)よね。だってこれ、1999年に標準化された仕様の話なんですもの…。