NGW開発BLOG
← 記事一覧へ
·#015

「?v=」で一度だけキャッシュを捨てる静的アセットのバージョン付け

JS/CSSを大きく差し替えたのに更新が効かない。原因はブラウザや中間キャッシュが掴む古いファイル。固定のバージョン文字列で一度だけキャッシュを無効化する運用を追う。

現象:更新したのに、新しい方が読まれない

あるライブラリ(クライアント側のJS/CSS)を、旧版から大きく差し替えた。手元では正しく動く。だが本番に上げると、一部の画面で更新が反映されない。既に描画済みの画面は普通に見えているのに、あるアクションを踏むと処理が途中で止まり、本来出るはずのダイアログが出ない。

切り分けにくい症状だった。エラーが「見えない」からだ。新しいコードは、旧版が提供していた互換シムを前提に呼び出しを書く。ところがブラウザが掴んでいるのはシムの無い古いファイルで、そこへ新しい呼び出しが来る。噛み合わず「クラスのコンストラクタを new なしで呼べない」的な例外が投げられる。例外はハンドラの外で投げられるので、ダイアログを出す処理まで到達しない。一方、ページ自体は既に描画を終えているから見た目は正常。「動いているのに動かない」の典型だ。

犯人はコードではなくキャッシュだった。ブラウザ、そして間に挟まる中間キャッシュが、URLの変わらない静的ファイルを「同じもの」とみなして古い実体を返し続けていた。

なぜ固定バージョンなのか

対策は単純で、読み込みURLにバージョン文字列を付ける。

<!-- Before -->
<script src="/lib/widget.js"></script>

<!-- After -->
<script src="/lib/widget.js?v=11.26.25"></script>

クエリ文字列が違えばブラウザは別URLとして扱い、キャッシュを引かず取り直す。これがキャッシュバスティングだ。

肝は、この v を「毎回変わる値」にしないことにある。デプロイ時刻やランダム値を入れれば確実に最新は取れる。だが、それだとアクセスのたびにURLが変わり、常にキャッシュミスする。せっかくのブラウザキャッシュも中間キャッシュも一切効かず、毎回フルダウンロードになって遅くなる。キャッシュを捨てたいのは「差し替えた瞬間の一回だけ」で、その後はむしろキャッシュを効かせたい。

だから値は内容に連動した固定の識別子——バージョン番号やファイル内容のハッシュ——にする。バージョンを 11.26.25 に上げた瞬間だけURLが変わってキャッシュが一度無効化され、以後は全員が同じ新URLを共有するので普通にキャッシュが乗る。次にライブラリを上げるときは、この文字列を手で一段上げる。運用としては「ライブラリ更新=バージョン文字列の更新」を必ずセットにする、という約束事に落ちる。

落とし穴:一箇所でも素のURLが残ると

もうひとつの教訓は網羅性だ。バージョン付けは、そのファイルを読み込むすべての経路で揃っていないと意味がない。どこか一箇所でバージョン無しの素のURL(?v= の付かない widget.js)が残っていると、そのページだけ古い実体を掴む。冒頭の「ダイアログが出ないのに画面は正常」は、まさに読み込み箇所の取りこぼしが生む症状だった。

読み込み経路は思ったより散らばる。共通テンプレート、個別画面の直書き、動的に生成される <script> タグ、CSSの @import ——。一括置換をかけるにせよ、grepで widget.js を全数拾って ?v= の有無を突き合わせるのが確実だ。「更新したのに直らない」の多くは、更新漏れではなく参照漏れである。

AIとの協働作業での学び

キャッシュバスティングは「変えたい時だけ変わる」識別子で行う。ハッシュやバージョン番号のような内容連動の値が理想で、常時ユニークな値は逆効果——キャッシュを捨てるどころか、キャッシュそのものを無効にしてしまう。そして、識別子を付ける以上に大事なのが、読み込み経路を一箇所も取りこぼさないこと。キャッシュ問題は「反映されない」という症状の裏に「参照がズレている」という原因が隠れやすい。疑うべきはコードの前に、そのコードを指すURLだ。