WordPressで画像を差し替えても、ブラウザキャッシュのせいで古い画像が表示され続ける ── これを防ぐ定番テクニックが、画像URLへの ?ver=日時 の付与(キャッシュバスター)です。

以前、同じテーマで「the_content フィルターでレンダリング時に付与する」方法を紹介しました。しかしこの方式は ページキャッシュが有効な環境では機能しない という構造的な問題があり、実運用ではサーバーキャッシュに負けてしまうケースが頻発していました。

WordPressで画像のURLに最終更新日時をクエリパラメータとして追加する方法 WordPressでコンテンツを更新した際に、画像のキャッシュが原因で更新が反映されないことがあります。この問題を解決するため、画像URLに最終更新日時をクエリパラメータとして追加する方法を紹介します。content_save_preでデータベース保存時に...  続きを読む

そこで今回は、投稿保存時にデータベースへ直接 ?ver= を書き込む 方式に移行しました。この方式なら、ページキャッシュが何段積まれていても確実に ?ver= を届けられ、しかも Better Search Replace で複数ページを一括更新できるおまけ付きです。


    以前の方式(the_content フィルター)の限界

    まず、元の実装を振り返ります。

    /* content内の画像URLに、最終更新日時をクエリ文字として追加 */
    function add_version_query_to_images($content) {
      global $post;
      $version = (new DateTime($post->post_modified))->format('YmdHis');
      // ... srcやsrcsetに ?ver= を付与 ...
      return $content;
    }
    add_filter('the_content', 'add_version_query_to_images');

    このコードは「投稿本文をブラウザに出す直前に、?ver= を差し込む」というアプローチでした。一見シンプルですが、運用すると以下の問題が表面化します。

    問題1:ページキャッシュと相性が悪い

    the_content フィルターは PHP が実行されて初めて動く処理です。ところが多くのレンタルサーバーでは、一度生成されたHTMLをキャッシュして、以後はPHPを回さずそのまま返します。

    初回アクセス  → PHP実行 → ?ver= 付与 → HTMLキャッシュ保存
    2回目以降     → キャッシュHTML返却(PHPは動かない)

    つまりキャッシュが生きている間は、投稿を更新しても 古い ?ver= のHTMLがずっと返り続けるのです。画像を差し替えても、DevToolsで見てもキャッシュが更新されない ── その正体はこれです。

    問題2:レンダリング時の負荷

    アクセスのたびに正規表現で本文を走査するため、投稿文が長いほど負荷になります。

    問題3:Better Search Replace での一括更新が効かない

    ?ver= がデータベースに存在しない(出力時に動的に付くだけ)ので、データベースに対して置換処理をかけても何も起きません。複数ページの画像を差し替えたいときに、1ページずつ「更新」ボタンを押す必要があります。


    新方式:content_save_pre で投稿保存時に書き込む

    これら全てを解決するのが、投稿保存時にデータベースへ ?ver= を書き込んでしまう 方式です。

    投稿保存 → content_save_pre で ?ver= 付与 → データベースに保存
    表示時     → データベースから読み出してそのまま出力(フィルター不要)

    メリットは3つ。

    観点 the_content 方式 content_save_pre 方式
    ページキャッシュ 効かない 効く(HTMLにすでに?ver=が含まれる)
    レンダリング負荷 毎回正規表現処理 ゼロ
    一括更新 不可 Better Search Replaceで可能

    content_save_pre は、WordPressが post_content を保存する直前に適用されるフィルターです。wp_insert_post_data でも同等のことができますが、$data 配列全体を受け取るため除外条件が煩雑になりがちで、content_save_pre は $content だけを直接受け取れるためシンプルに書けます。


    実装コード

    以下を functions.php に追加してください。

    /* ============================================================
     * 投稿保存時に content 内の画像URLへ ?ver=YmdHis を付与/更新
     * ------------------------------------------------------------
     * - フック: content_save_pre(post_content を直接フィルター)
     * - 対象タグ: <img>, <source>
     * - 対象属性: src, data-src0, data-src744, srcset
     * - 内部URL(自サイト or 相対パス)のみ処理
     * - data: URI、外部URLは除外
     * - 既存の ?ver= があれば最新値で置き換え
     * ============================================================ */
    add_filter('content_save_pre', 'iv_add_ver_on_save', 10, 1);
    
    function iv_add_ver_on_save($content) {
      // 自動保存・空コンテンツはスキップ
      if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return $content;
      if (empty($content)) return $content;
    
      // content_save_pre は slash-escape 済みで渡されるので一旦解除
      $content = wp_unslash($content);
    
      $version   = current_time('YmdHis');
      $site_host = parse_url(get_site_url(), PHP_URL_HOST);
    
      // <img> タグを処理
      $content = preg_replace_callback('/<imgb[^>]*>/i', function($m) use ($version, $site_host) {
        return iv_process_tag($m[0], $version, $site_host);
      }, $content);
    
      // <picture> 内の <source> タグも処理
      $content = preg_replace_callback('/<sourceb[^>]*>/i', function($m) use ($version, $site_host) {
        return iv_process_tag($m[0], $version, $site_host);
      }, $content);
    
      // 戻すときは再度 slash-escape
      return wp_slash($content);
    }
    
    /**
     * 1つのタグ内の対象属性を順に処理
     */
    function iv_process_tag($tag, $version, $site_host) {
      // 単一URL系の属性
      foreach (['src', 'data-src0', 'data-src744'] as $attr) {
        $pattern = '/(b' . preg_quote($attr, '/') . 's*=s*)(["'])([^"']*)2/i';
        $tag = preg_replace_callback($pattern, function($m) use ($version, $site_host) {
          $new_url = iv_apply_ver($m[3], $version, $site_host);
          return $m[1] . $m[2] . $new_url . $m[2];
        }, $tag);
      }
    
      // srcset(カンマ区切り複数URL)
      $tag = preg_replace_callback('/(bsrcsets*=s*)(["'])([^"']*)2/i', function($m) use ($version, $site_host) {
        $new_set = iv_apply_ver_to_srcset($m[3], $version, $site_host);
        return $m[1] . $m[2] . $new_set . $m[2];
      }, $tag);
    
      return $tag;
    }
    
    /**
     * 単一URLに ?ver= を付与(既存があれば除去してから付与)
     */
    function iv_apply_ver($url, $version, $site_host) {
      if ($url === '' || strpos($url, 'data:') === 0) return $url;
    
      // 絶対URL or プロトコル相対URL の場合、自サイト以外はスキップ
      if (preg_match('/^(?:https?:)?///i', $url) && strpos($url, $site_host) === false) {
        return $url;
      }
    
      // 既存の ver= を除去(3パターン対応)
      $url = preg_replace('/?ver=[^&]*&/', '?', $url);  // ?ver=xxx&yyy → ?yyy
      $url = preg_replace('/&ver=[^&]*/',   '',  $url);  // &ver=xxx     → 除去
      $url = preg_replace('/?ver=[^&]*$/', '',  $url);  // ?ver=xxx     → 除去
    
      // 新しい ver= を付与
      $separator = (strpos($url, '?') !== false) ? '&' : '?';
      return $url . $separator . 'ver=' . $version;
    }
    
    /**
     * srcset 文字列内の各URLに ?ver= を付与
     */
    function iv_apply_ver_to_srcset($srcset, $version, $site_host) {
      $items = explode(',', $srcset);
      foreach ($items as $i => $item) {
        $item = trim($item);
        if ($item === '') continue;
    
        // URL とサイズ指定(1x, 744w 等)を分離
        $parts = preg_split('/s+/', $item, 2);
        $url   = $parts[0];
        $desc  = isset($parts[1]) ? ' ' . $parts[1] : '';
    
        $items[$i] = iv_apply_ver($url, $version, $site_host) . $desc;
      }
      return implode(', ', $items);
    }

    data-src0 / data-src744 は独自の遅延読み込み属性なので、他のプロジェクトで使っていない場合は配列から外してください。逆に独自属性を追加したい場合はこの配列に足すだけです。


    実装のポイント

    一見すると単純な文字列置換に見えますが、実際に書いてみると落とし穴がいくつかあります。

    ポイント1:wp_unslash / wp_slash の往復は必須

    content_save_pre に渡される $contentslash-escape された状態"" に)で来ます。これを知らずに ["'] でマッチを取ろうとすると、キャプチャグループに </code> が混入してURLが壊れ、書き換えが効きません。

    処理の前に wp_unslash() で解除 → 処理 → wp_slash() で戻す、というサンドイッチ構造が正解です。

    ポイント2:タグ単位で切り出してから処理する

    最初は <img> タグを特定せず、src= を直接正規表現で探していましたが、[^>]* の貪欲マッチで別のタグまで巻き込む事故が起きます。

    ❌ /(img[^>]src=["'])([^"'])(["'])/
    ✅ 1. /<imgb[^>]*>/i でタグを切り出し
       2. その中で /(bsrcs*=s*)(["'])([^"']*)2/i

    2段階に分けることで、タグ境界を越えたマッチを防げます。

    ポイント3:preg_quote で属性名をエスケープ

    data-src0 のようなハイフンを含む属性名を正規表現に組み込むときは、preg_quote() でエスケープしておくと将来トラブルを回避できます。

    ポイント4:既存 ?ver= の除去は3パターンに分ける

    再保存のたびに ?ver= が増殖しないよう、既存の値を除去してから新しい値を付与します。3つのパターンを順番に処理すれば漏れがありません。

    $url = preg_replace('/?ver=[^&]*&/', '?', $url);  // ?ver=xxx&yyy → ?yyy
    $url = preg_replace('/&ver=[^&]*/',   '',  $url);  // &ver=xxx     → 除去
    $url = preg_replace('/?ver=[^&]*$/', '',  $url);  // ?ver=xxx     → 除去

    既存コードからの置き換え手順

    the_content フィルター版を使っていた場合、旧コードは 必ず削除してください。両方有効にすると二重に ?ver= が付いてしまいます。

    - add_filter('the_content', 'add_version_query_to_images');
    - function add_version_query_to_images($content) { ... }
    + add_filter('content_save_pre', 'iv_add_ver_on_save', 10, 1);
    + function iv_add_ver_on_save($content) { ... }

    切り替え後、既存の投稿を順次「更新」ボタンで再保存していくと、保存したページから順に ?ver= がデータベース側に書き込まれていきます。


    画像差し替え時の運用フロー

    この方式の最大のメリットが、画像だけ差し替えた場合でも一括対応できることです。

    Better Search Replace で一括更新

    画像を数枚差し替えたけど投稿を全部開いて保存し直すのは面倒 ── そんなときは Better Search Replace プラグインで ?ver= の値を一括置換します。

    1. ツール → Better Search Replace を開く
    2. 「検索」に既存の ?ver=20260101120000 を指定(現在データベースに入っている値を1件確認してコピーするのが確実)
    3. 「置換」に新しい ?ver=20260424153000 を指定
    4. テーブルで wp_posts を選択
    5. 「ドライラン」で件数確認 → 実行

    これで全投稿の画像URLのクエリが一括で更新され、ブラウザキャッシュが強制的に無効化されます。

    投稿単位で更新したい場合

    特定の投稿だけなら、管理画面で「更新」ボタンを押すだけで自動的に最新の ?ver= に書き換わります。


    まとめ

    ?ver= 付与の実装は、「レンダリング時」ではなく「保存時」に処理する という発想の転換がポイントでした。

    項目 旧方式(the_content) 新方式(content_save_pre)
    ページキャッシュ耐性
    レンダリング負荷 毎回発生 ゼロ
    Better Search Replaceで一括更新
    画像差し替えのみへの対応 ✓(一括置換で対応)

    特にLiteSpeed CacheやWP Super Cacheなど、サーバー側のページキャッシュを使っている環境では、新方式に切り替えないと ?ver= が期待通りに届きません。「投稿は更新したのに画像が変わらない」と悩んでいる方は、ぜひこのアプローチを試してみてください。


    関連記事

    キャッシュとストレージとセッションの違い、JavaScriptで クリアする方法 ウェブ開発をしていると「キャッシュ」「ストレージ」「セッション」という似たような言葉が頻繁に登場します。これらはすべてデータを保存する仕組みですが、それぞれ異なる特性と用途があります。今回は、これらの違いを整理し、JavaScriptでクリアする方...  続きを読む 画像のアスペクト比を自動計算・付与する | aspect-ratioが効かない原因とCLS対策 CSS aspect-ratioが効かない原因を押さえつつ、投稿保存時に実画像からアスペクト比(縦横比)を自動計算・自動付与し、SP/PCの出し分けまで手作業ゼロで維持する仕組み、CLS対策として無理なく運用するための実運用での使い方を解説します。  続きを読む