2026年7月15日

2026年7月15日

WordPressの記事データをIndexedDBでクライアントサイドにキャッシュする方法

WordPressの新着記事一覧などをIndexedDBに保存しておくと、再訪問時には端末内のデータで一覧をすぐに描画し、その裏でREST APIから最新の内容を取り直して差し替える、という表示ができます。この記事では、保存先としてIndexedDBを選ぶ理由、子テーマでのスクリプトの組み込み、データベースの作成・保存・古いデータの削除、表示時の安全な描画、容量や消去の注意点までを、そのまま動かせるコードとともに解説します。

IndexedDBを選ぶ理由

ブラウザに情報を保存する方法はいくつかあり、向いている用途が違います。

保存先 向いている用途 注意点
localStorage 設定値など小さな文字列 同期処理で画面の動きを止めやすく、容量も小さい
Cache Storage リクエストとレスポンスの組(HTML、画像、APIの応答そのもの) 中身を検索・部分更新する用途には向かない
IndexedDB 記事ID・タイトル・更新日時などの構造化データ APIが低水準で、コードが長くなりやすい

記事を1件ずつ追加・更新し、更新日時で並べ替え、古いものだけを消す、といった扱いをしたいなら、IndexedDBが適しています。APIの応答を丸ごと保存するだけならCache Storageのほうが簡単なので、目的に合わせて選んでください。IndexedDBはService Workerの中からも使えるため、後からオフライン対応と組み合わせることもできます。

ここで保存するのは公開済みの記事の情報だけにします。ログイン中にしか見えない下書きや会員限定の内容を端末に残すと、共用のパソコンなどで他人に見られるおそれがあるためです。

子テーマでスクリプトと表示場所を用意する

作業は子テーマの functions.php で行います。表示したい場所にショートコード [idb_latest] を置き、そのページでだけスクリプトを読み込む形にします。REST APIのURLはPHPで組み立てて渡すと、WordPressをサブディレクトリに置いている場合やパーマリンク設定が違う場合にも対応できます。

<?php
// 子テーマの functions.php
add_shortcode( 'idb_latest', function () {
	$path = get_stylesheet_directory() . '/js/idb-cache.js';
	wp_enqueue_script(
		'my-idb-cache',
		get_stylesheet_directory_uri() . '/js/idb-cache.js',
		array(),
		filemtime( $path ),
		array( 'strategy' => 'defer', 'in_footer' => true )
	);
	wp_add_inline_script( 'my-idb-cache', 'const IDB_CACHE = ' . wp_json_encode( array(
		'endpoint' => add_query_arg(
			array(
				'_fields'  => 'id,link,title,excerpt,modified_gmt',
				'per_page' => 20,
			),
			rest_url( 'wp/v2/posts' )
		),
		'schema'   => 1,
	) ) . ';', 'before' );

	return '<ul id="idb-latest" class="idb-latest" aria-live="polite"></ul>';
} );

_fields で必要な項目だけを指定すると、応答が小さくなり、保存する量も減ります。strategy を指定する書き方はWordPress 6.3以降で使えます。

一覧が空の状態から描画されるとページの高さが変わるため、.idb-latest には min-height を指定しておくと、CLS(レイアウトのずれ)を抑えられます。

IndexedDBに保存して先に描画する

処理の流れは「端末のデータで描画 → ネットワークから取得 → 描画し直す → 保存」です。ネットワークが遅くても、2回目以降は一覧がすぐに出ます。

// 子テーマの js/idb-cache.js
(() => {
  const list = document.getElementById('idb-latest');
  if (!list || typeof IDB_CACHE === 'undefined') return;

  const DB_NAME = 'site-content';
  const STORE = 'posts';
  const MAX_AGE = 24 * 60 * 60 * 1000; // 1日以上取得し直されていないデータは削除

  const toText = (html) =>
    new DOMParser().parseFromString(html || '', 'text/html').body.textContent.trim();

  const openDB = () => new Promise((resolve, reject) => {
    if (!('indexedDB' in window)) { reject(new Error('unsupported')); return; }
    const req = indexedDB.open(DB_NAME, IDB_CACHE.schema);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(STORE)) {
        const store = db.createObjectStore(STORE, { keyPath: 'id' });
        store.createIndex('savedAt', 'savedAt');
      }
    };
    req.onsuccess = () => {
      const db = req.result;
      db.onversionchange = () => db.close(); // 別タブで新しい版に上がったら閉じる
      resolve(db);
    };
    req.onerror = () => reject(req.error);
    req.onblocked = () => reject(new Error('blocked'));
  });

  const readAll = (db) => new Promise((resolve, reject) => {
    const req = db.transaction(STORE, 'readonly').objectStore(STORE).getAll();
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });

  const saveAll = (db, items) => new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, 'readwrite');
    const store = tx.objectStore(STORE);
    const now = Date.now();
    items.forEach((item) => store.put({ ...item, savedAt: now }));
    store.index('savedAt').openCursor(IDBKeyRange.upperBound(now - MAX_AGE)).onsuccess = (e) => {
      const cursor = e.target.result;
      if (cursor) { cursor.delete(); cursor.continue(); }
    };
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error);
  });

  const normalize = (p) => ({
    id: p.id,
    link: p.link,
    title: toText(p.title && p.title.rendered),
    excerpt: toText(p.excerpt && p.excerpt.rendered),
    modified: p.modified_gmt,
  });

  const render = (items) => {
    const rows = items
      .filter((item) => {
        try { return new URL(item.link).origin === location.origin; } catch (e) { return false; }
      })
      .sort((a, b) => (a.modified < b.modified ? 1 : -1))
      .slice(0, 20)
      .map((item) => {
        const li = document.createElement('li');
        const a = document.createElement('a');
        a.href = item.link;
        a.textContent = item.title;
        const p = document.createElement('p');
        p.textContent = item.excerpt;
        li.append(a, p);
        return li;
      });
    list.replaceChildren(...rows);
  };

  (async () => {
    let db = null;
    try {
      db = await openDB();
      const cached = await readAll(db);
      if (cached.length) render(cached);
    } catch (e) {
      db = null; // 使えない環境ではネットワークだけで動かす
    }

    try {
      const res = await fetch(IDB_CACHE.endpoint, { credentials: 'omit' });
      if (!res.ok) throw new Error('HTTP ' + res.status);
      const items = (await res.json()).map(normalize);
      render(items);
      if (db) await saveAll(db, items);
    } catch (e) {
      if (!list.children.length) {
        list.textContent = '記事一覧を読み込めませんでした。';
      }
    }
  })();
})();

コードの判断のポイントを補足します。

  • **credentials: 'omit' で取得する**: Cookieを付けないため、ログイン中の編集者が開いても公開済みの記事だけが返り、保存されます。
  • HTMLは文字列として取り出す: title.rendered と excerpt.rendered はHTMLや文字参照を含みます。DOMParser でテキストだけを取り出し、表示は textContent で行います。innerHTML にそのまま入れると、プラグインが出力した想定外のタグまで描画され、XSSの原因になりえます。
  • リンク先を確かめる: 保存データから描画する前に、URLが自サイトのものかを確認しています。
  • 古いデータの削除: 保存時刻のインデックスを使い、最新の一覧に1日以上含まれなかった記事を同じトランザクションで消しています。削除した記事や非公開にした記事が端末に残り続けるのを防ぐためです。
  • 使えない環境でも動く: IndexedDBが開けないときは、ネットワークだけで一覧を出します。

スキーマを変えるときの手順

保存する項目を増やしたり、インデックスを追加したりするときは、PHP側の schema の値を2、3と上げます。indexedDB.open() に前より大きい数を渡すと onupgradeneeded が呼ばれるので、そこで event.oldVersion を見て、必要な変更だけを加えます。

別のタブで古い版のページが開いたままだと、新しい版への更新が待たされることがあります。上のコードの onversionchange で古い接続を閉じているのはそのためです。

容量と消去の注意点

IndexedDBのデータは、端末の空き容量が少なくなるとブラウザによって消されることがあります。キャッシュは「消えても困らないデータ」として扱い、ネットワークから取り直せる設計にしておくのが前提です。

  • 使用量の確認: navigator.storage.estimate() で、そのサイトが使っている容量と上限の目安を取得できます。
  • 消されにくくする: navigator.storage.persist() で永続化を申請できますが、許可されるかどうかはブラウザの判断です。
  • Safariの扱い: Safariには、一定期間サイトを操作しなかった場合にスクリプトが保存したデータを消す仕組みがあります。ホーム画面に追加したWebアプリは扱いが異なるとされているため、詳しくはWebKitの公式情報を確認してください。
  • プライベートブラウズ: ウィンドウを閉じると消える、容量が小さいなど、ブラウザごとに扱いが異なります。

これらの挙動はブラウザの更新で変わることがあります(2026年10月現在)。会員向けの内容など、端末に残したくないデータを将来扱う場合は、ログアウト時に Clear-Site-Data ヘッダーで保存データを消す方法も検討してください。ただし対応状況はブラウザによって異なります。

動作を確認する

  1. ステージング環境でショートコードを置いたページを開き、一覧が表示されることを確認します。
  2. Chromeのデベロッパーツールの「Application」パネルで「IndexedDB」を開き、site-content の posts に記事が保存されていることを確かめます。
  3. 「Network」パネルで回線を遅くする設定にして再読み込みし、ネットワークの応答より先に一覧が出ることを確認します。
  4. 管理画面で記事を更新した後に再読み込みし、一度は古い内容が出て、すぐ新しい内容に差し替わることを確かめます。

低水準のAPIを直接書くのが負担なら、idb や Dexie.js といった、IndexedDBをPromiseで扱えるようにする定番のライブラリもあります。導入する場合は、テーマのフォルダに置いて wp_enqueue_script で読み込み、更新が続いているかを確認してから使ってください。

まとめ

  • IndexedDBは、記事の情報のような構造化データを追加・更新・削除しながら扱う用途に向いています。
  • 「端末のデータで先に描画し、REST APIの最新データで差し替えて保存する」流れで、再訪問時の表示を速くできます。
  • 保存するのは公開済みの情報だけにし、Cookieを付けずに取得します。
  • HTMLは DOMParser でテキストにし、textContent で描画してXSSを防ぎます。
  • 保存データはブラウザに消される前提で設計し、古いデータは保存時刻のインデックスで削除します。

お気軽にご相談ください

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