2026年6月2日
2026年6月2日
WordPressでCacheStorageとfetch()を使って表示を速くする実装例
ブラウザの CacheStorage(Cache API)に fetch() の結果を保存しておくと、WordPress の REST API から読み込む「新着記事」「人気記事」などの部品を、2回目以降はネットワークを待たずに表示できます。ここで紹介するのは、保存済みのデータをすぐに表示し、裏で最新のデータを取り直す「stale-while-revalidate」型の実装です。サービスワーカーを使わず、通常のページの JavaScript だけで完結する形にしています。あわせて、キャッシュしてはいけないデータ、古いキャッシュの片付け、効果の確かめ方も説明します。
CacheStorage を使う前に確認したいこと
CacheStorage は、リクエストとレスポンスの組をブラウザ内に保存する仕組みで、window からもサービスワーカーからも caches として使えます。HTTPS(または localhost)の安全なコンテキストでのみ利用でき、HTTP のページでは caches が存在しないか、使えない状態になります。
もう一つ大事なのが、HTTP キャッシュとの違いです。
- HTTP キャッシュ:サーバーが返す
Cache-Controlヘッダーに従い、ブラウザが自動で保存と破棄を行います。 - CacheStorage:JavaScript が明示的に
put()したものを、明示的にdelete()するまで保持します。Cache-Controlの有効期限は自動では適用されません。
つまり、CacheStorage では「いつ古いとみなすか」「いつ消すか」を自分で書く必要があります。ページ全体の表示を速くしたいだけなら、まずはページキャッシュや CDN、適切な Cache-Control の設定を見直すほうが効果的で、手間も少なく済みます。CacheStorage が向いているのは、JavaScript で REST API から部品を組み立てる場面で、「多少古くてもよいので、すぐに何かを表示したい」データです。
キャッシュしてはいけないデータ
ブラウザに保存されたデータは、共有 PC では次の利用者からも見える可能性があります。次のようなレスポンスは保存しないでください。
- ログイン中のユーザーにしか返らない情報(下書き、会員限定の内容、個人情報)
- nonce を含むレスポンスや、管理画面用の API の結果
- カートや在庫数など、古い値を表示すると困るデータ
この記事の例では、未ログインでも取得できる公開記事の一覧だけを対象にし、リクエストに Cookie を付けない設定にしています。
実装例:新着記事ウィジェットを CacheStorage で速くする
PHP:表示枠とスクリプトの読み込み
子テーマの functions.php か mu-plugin に追加します。作業前にバックアップを取り、ステージング環境で試してから本番に反映してください。
<?php
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_script(
'my-cached-latest',
get_stylesheet_directory_uri() . '/js/cached-latest.js',
array(),
'1.0.0',
array( 'strategy' => 'defer', 'in_footer' => true )
);
} );
// [cached_latest] ショートコードで表示枠を出す
add_shortcode( 'cached_latest', function () {
$url = add_query_arg(
array(
'per_page' => 5,
'_fields' => 'id,link,title',
),
rest_url( 'wp/v2/posts' )
);
return '<ul class="cached-latest" data-endpoint="' . esc_url( $url ) . '">'
. '<li>読み込み中…</li></ul>';
} );
_fields は REST API の共通パラメーターで、必要な項目だけを返させます。レスポンスが小さくなるので、通信量とキャッシュの保存量の両方を減らせます。strategy 引数は WordPress 6.3 以降の書き方で、それより古い環境ではフッター読み込みの true を指定します。
なお、枠の高さが読み込み前後で大きく変わると、レイアウトのずれ(CLS)の原因になります。CSS で .cached-latest に min-height を指定し、件数分の高さをあらかじめ確保しておくと安定します。
JavaScript:stale-while-revalidate の実装
( function () {
const CACHE_PREFIX = 'mysite-api-';
const CACHE_NAME = CACHE_PREFIX + 'v1';
const MAX_AGE = 5 * 60 * 1000; // 5分を過ぎたら裏で取り直す
// レスポンスを取得し、保存時刻を付けて CacheStorage に入れる
async function refresh( cache, url ) {
const res = await fetch( url, { credentials: 'omit' } );
if ( ! res.ok ) {
throw new Error( 'HTTP ' + res.status );
}
const body = await res.clone().blob();
const headers = new Headers( res.headers );
headers.set( 'x-saved-at', String( Date.now() ) );
await cache.put( url, new Response( body, {
status: res.status,
statusText: res.statusText,
headers,
} ) );
return res;
}
async function cachedJson( url ) {
if ( ! ( 'caches' in window ) ) {
const res = await fetch( url, { credentials: 'omit' } );
return res.json();
}
const cache = await caches.open( CACHE_NAME );
const hit = await cache.match( url );
if ( hit ) {
const savedAt = Number( hit.headers.get( 'x-saved-at' ) || 0 );
if ( Date.now() - savedAt > MAX_AGE ) {
refresh( cache, url ).catch( () => {} ); // 失敗しても保存済みの表示は続ける
}
return hit.json();
}
const res = await refresh( cache, url );
return res.json();
}
// 古いバージョンのキャッシュを削除する
async function cleanup() {
if ( ! ( 'caches' in window ) ) return;
const keys = await caches.keys();
await Promise.all(
keys
.filter( ( key ) => key.startsWith( CACHE_PREFIX ) && key !== CACHE_NAME )
.map( ( key ) => caches.delete( key ) )
);
}
// title.rendered は HTML 文字列なので、テキストとして取り出す
const toText = ( html ) => new DOMParser().parseFromString( html, 'text/html' ).body.textContent;
async function render( list ) {
try {
const posts = await cachedJson( list.dataset.endpoint );
list.textContent = '';
for ( const post of posts ) {
const li = document.createElement( 'li' );
const a = document.createElement( 'a' );
a.href = post.link;
a.textContent = toText( post.title.rendered );
li.appendChild( a );
list.appendChild( li );
}
} catch ( err ) {
list.textContent = '';
const li = document.createElement( 'li' );
li.textContent = '記事一覧を読み込めませんでした。';
list.appendChild( li );
}
}
document.querySelectorAll( '.cached-latest[data-endpoint]' ).forEach( render );
cleanup();
} )();
コードの要点
保存時刻をヘッダーに書き込む理由
CacheStorage には「いつ保存したか」を記録する機能がありません。そこで、保存する直前にレスポンスを作り直し、独自ヘッダー x-saved-at に時刻を入れています。fetch() で受け取ったレスポンスのヘッダーは書き換えられないため、new Headers() で複製し、new Response() で新しいレスポンスを組み立てる必要があります。
clone() を忘れると本文が読めなくなる
レスポンスの本文は一度しか読めません。キャッシュ用に blob() で読んだあと、呼び出し元で json() を呼ぶと「本文はすでに読み取り済み」というエラーになります。キャッシュ側では res.clone() を読み、元のレスポンスは呼び出し元に返しています。
表示は保存済みデータ、更新は裏で
保存済みのデータがあれば、期限切れでもまずそれを表示し、refresh() は待たずに裏で走らせています。そのため、記事を公開してから一覧に反映されるまで、最大で1回分の訪問の遅れが出ます。これが困る場面では、MAX_AGE を短くするか、期限切れのときは最新データを待つ形に変えてください。
Cookie を送らない設定
credentials: 'omit' を指定すると、ログイン中のユーザーでも Cookie なしでリクエストされ、公開データだけが返ります。WordPress の REST API は、nonce を伴わない Cookie だけのリクエストを未ログインとして扱うため、通常は同じ結果になりますが、意図を明確にして、個人向けのデータを誤って保存しないための保険として指定しています。
HTML をそのまま差し込まない
title.rendered には ’ のような文字参照や、権限のあるユーザーが入れた HTML が含まれることがあります。innerHTML に入れると予期しない HTML が実行される可能性があるため、DOMParser でテキストだけを取り出し、textContent で表示しています。
キャッシュの更新と容量の管理
- 構造を変えたらバージョンを上げる:表示する項目を変えたときなどは、
CACHE_NAMEの末尾をv2に変えます。cleanup()が古い名前のキャッシュを削除します。 - URL ごとに1件保存される:
per_pageやページ番号などのクエリ文字列が違えば別の項目として保存されます。無制限にパターンを増やさないよう、キャッシュする URL を絞ってください。 - 保存容量には上限がある:ブラウザごと・サイトごとに上限があり、空き容量が少ないとブラウザが自動で削除することもあります。
navigator.storage.estimate()で目安の使用量を確認できます。キャッシュは消えても動く前提で作ります。
効果の確かめ方
Chrome の開発者ツールでは、「Application」パネルの「Cache storage」で保存内容を確認できます(表示名はバージョンによって変わることがあります。2026年10月現在)。ネットワークタブを開いた状態で2回目に表示したとき、一覧がすぐに出て、期限内なら REST API へのリクエストが発生しないことを確認します。
体感だけでなく数値で比べたい場合は、render() の前後で performance.now() を記録し、キャッシュあり・なしの差を測ります。ただし、この方法で速くなるのは JavaScript で組み立てる部品の表示であり、Lighthouse の LCP などのページ全体の指標が大きく変わるとは限りません。ページ全体の速度は、サーバー側のキャッシュや画像の最適化と組み合わせて考えてください。
まとめ
- CacheStorage は HTTPS でのみ使え、
Cache-Controlに従わないので、有効期限と削除を自分で実装します。 - 保存時刻をヘッダーに記録し、保存済みデータをすぐ表示しつつ裏で取り直す stale-while-revalidate 型が扱いやすい形です。
- 公開データだけを対象にし、
credentials: 'omit'で個人向けの情報を保存しないようにします。 - 本文は一度しか読めないため、保存用には
clone()を使います。表示はtextContentで行います。 - キャッシュ名にバージョンを付けて古いものを片付け、効果は開発者ツールと計測値で確かめます。