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 には &#8217; のような文字参照や、権限のあるユーザーが入れた 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 で行います。
  • キャッシュ名にバージョンを付けて古いものを片付け、効果は開発者ツールと計測値で確かめます。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲