2026年7月20日

2026年7月20日

WordPressでApp ShellアーキテクチャのPWAを実装して表示を速くする方法

App Shellアーキテクチャは、ヘッダーやナビゲーション、CSS・JavaScriptといった「画面の骨組み(シェル)」をService Workerで端末に保存し、2回目以降の表示ではシェルを即座に出して、中身だけをネットワークから取る設計です。WordPressはページごとにサーバーでHTMLを作る仕組みなので、サイト全体をシングルページアプリにするより、「シェルの資産を先に保存し、HTMLはネットワーク優先で取る」形を基本にして、必要な一部だけを本格的なApp Shellにするのが現実的です。この記事では、その2段階の実装と、更新・解除の手順までを解説します。

WordPressで「シェル」と「中身」を分ける

最初に、何をシェルとして端末に置き、何を毎回取りに行くかを決めます。

  • シェル(端末に保存してよいもの): テーマのCSS・JavaScript、Webフォント、ロゴ、オフライン時に出すページ
  • 中身(毎回ネットワークから取るもの): 記事のHTML、コメント、ログイン状態で変わる表示、カートや決済の画面

WordPressのHTMLには、ログイン中だけ出る管理バー、フォームのnonce、記事ごとのタイトルや構造化データが含まれます。これらをシェルとして固定すると、別の人の表示が出たり、送信できないフォームが残ったりします。そのため、HTMLを丸ごと端末に保存する設計は、対象を絞った場合にだけ使います。

なお、検索エンジンのクローラーはService Workerを使わずにサーバーから直接HTMLを取得します。App Shellは「再訪問した読者の表示を速くする」ための仕組みで、SEO対策ではない点も押さえておいてください。

Service Workerをサイトのルートから配信する

Service Workerが制御できる範囲(スコープ)は、スクリプトを置いたパスで決まります。子テーマのフォルダ(/wp-content/themes/...)に置くと、記事ページを制御できません。そこで、サイトのトップのURLにクエリを付けてPHPからスクリプトを返します。PHPで出力すると、テーマのバージョンをキャッシュ名に埋め込めるため、テーマを更新したときにシェルも入れ替わります。

作業は子テーマの functions.php で行い、本番の前にステージング環境で確認してください。

<?php
// 子テーマの functions.php
add_filter( 'query_vars', function ( $vars ) {
	$vars[] = 'my_sw';
	return $vars;
} );

add_action( 'template_redirect', function () {
	if ( '1' !== get_query_var( 'my_sw' ) ) {
		return;
	}
	$config = array(
		'version'    => wp_get_theme()->get( 'Version' ) . '-' . filemtime( get_stylesheet_directory() . '/sw-template.js' ),
		'offlineUrl' => home_url( '/offline/' ),
		'precache'   => array(
			home_url( '/offline/' ),
			get_stylesheet_directory_uri() . '/assets/logo.svg',
		),
	);
	header( 'Content-Type: application/javascript; charset=utf-8' );
	header( 'Cache-Control: no-cache' );
	header( 'X-Robots-Tag: noindex' );
	echo 'const CONFIG = ' . wp_json_encode( $config ) . ";\n";
	readfile( get_stylesheet_directory() . '/sw-template.js' );
	exit;
}, 0 ); // 正規URLへのリダイレクト処理より先に応答する

add_action( 'wp_enqueue_scripts', function () {
	wp_register_script( 'my-sw-register', '', array(), null, array( 'in_footer' => true ) );
	wp_enqueue_script( 'my-sw-register' );
	wp_add_inline_script(
		'my-sw-register',
		'if ("serviceWorker" in navigator) { navigator.serviceWorker.register(' . wp_json_encode( home_url( '/?my_sw=1' ) ) . '); }'
	);
} );

/offline/ には「オフラインのため表示できません」と書いた固定ページを作っておきます。ページキャッシュ系のプラグインを使っている場合は、?my_sw=1 のURLがキャッシュされていないかも確認します。クエリ付きのURLは既定でキャッシュしない設定のものが多いですが、設定によって異なります。

シェルを保存し、HTMLはネットワーク優先で取る

sw-template.js が本体です。考え方は次の3つです。

  • インストール時にシェルを保存する(Cookieを付けずに取得し、ログイン中の表示が混ざらないようにする)
  • テーマやコアのCSS・JavaScript・フォントは、保存済みのものを即座に返し、裏で更新する
  • ページのHTMLはネットワークを優先し、つながらないときだけオフラインページを返す
// 子テーマの sw-template.js(先頭に PHP が CONFIG を出力します)
const SHELL_CACHE = 'shell-' + CONFIG.version;
const ASSET_CACHE = 'assets-' + CONFIG.version;
const BYPASS = ['/wp-admin/', '/wp-login.php', '/wp-json/', '/cart/', '/checkout/', '/my-account/'];
const ASSET_PATH = /^\/(.+\/)?wp-(content\/themes|content\/plugins|includes)\//;

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(SHELL_CACHE)
      .then((cache) => cache.addAll(CONFIG.precache.map((u) => new Request(u, { credentials: 'omit' }))))
      .then(() => self.skipWaiting())
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    const keys = await caches.keys();
    await Promise.all(keys
      .filter((k) => (k.startsWith('shell-') || k.startsWith('assets-')) && k !== SHELL_CACHE && k !== ASSET_CACHE)
      .map((k) => caches.delete(k)));
    if (self.registration.navigationPreload) {
      await self.registration.navigationPreload.enable();
    }
    await self.clients.claim();
  })());
});

self.addEventListener('fetch', (event) => {
  const req = event.request;
  if (req.method !== 'GET') return;
  const url = new URL(req.url);
  if (url.origin !== self.location.origin) return;
  if (BYPASS.some((p) => url.pathname.includes(p)) || url.searchParams.has('preview')) return;

  if (req.mode === 'navigate') {
    event.respondWith(networkFirst(event));
  } else if (ASSET_PATH.test(url.pathname)) {
    event.respondWith(staleWhileRevalidate(event));
  }
});

async function networkFirst(event) {
  try {
    const preloaded = await event.preloadResponse;
    if (preloaded) return preloaded;
    return await fetch(event.request);
  } catch (e) {
    const offline = await caches.match(CONFIG.offlineUrl);
    return offline || new Response('Offline', { status: 503, headers: { 'Content-Type': 'text/plain' } });
  }
}

async function staleWhileRevalidate(event) {
  const cache = await caches.open(ASSET_CACHE);
  const cached = await cache.match(event.request);
  const network = fetch(event.request)
    .then((res) => {
      if (res.ok) cache.put(event.request, res.clone());
      return res;
    })
    .catch(() => cached);
  if (cached) {
    event.waitUntil(network);
    return cached;
  }
  return network;
}

ポイントを補足します。

  • ナビゲーションプリロード: Service Workerが起動するまでの待ち時間に、ブラウザがHTMLの取得を並行して始める仕組みです。ネットワーク優先の弱点である「Service Workerの起動分だけ遅れる」問題を小さくできます。
  • アップロード画像は対象外: ASSET_PATH をテーマ・プラグイン・wp-includes に限定しているのは、記事の画像まで保存すると端末の容量を際限なく使うためです。
  • 除外するURL: 管理画面、ログイン、REST API、プレビュー、ECサイトのカートや決済は通しません。お使いのプラグインに合わせて BYPASS を調整してください。

一部だけを本格的なApp Shellにする

「新着記事一覧」のように、骨組みが固定で中身がデータだけの画面は、本来の意味のApp Shellに向いています。たとえば /reader/ という固定ページを作り、ヘッダーとフッター、空の一覧エリアだけを出すページテンプレートを割り当てます。

  • Service Workerでは、/reader/ へのナビゲーションに限って保存済みのページを即座に返し、裏で最新版に更新します(上の staleWhileRevalidate と同じ考え方をナビゲーションに適用します)。CONFIG.precache にも /reader/ を加えます。
  • ページ側のJavaScriptは、/wp-json/wp/v2/posts?_fields=id,link,title,excerpt&per_page=10 のようにREST APIから必要な項目だけを取得し、一覧を描画します。
  • title.rendered や excerpt.rendered はHTMLを含むため、innerHTML にそのまま入れず、テキストとして取り出してから textContent で入れます。

シェルにするページには、nonceを使うフォームやログイン状態で変わる部品を置かないことが前提です。記事本文のページは通常どおりサーバーで作り、URLも従来のものを正規の場所として残します。データを端末に保存して一覧をさらに速く出す方法は、IndexedDBを使うと組み立てやすくなります。

速くなったかを確認する

  1. Chromeのデベロッパーツールの「Application」パネルでService Workerが「activated」になり、スコープがサイトのトップになっていることを確認します。
  2. 「Network」パネルで再読み込みし、CSSやフォントが「(ServiceWorker)」から返っていることを確かめます。
  3. 回線を「Offline」にして記事を開き、オフラインページが出ることを確認します。
  4. 体感ではなく数値で比べるには、Lighthouseやフィールドデータ(Chrome UX Reportなど)で、導入前後のLCPを比べます。初回訪問は速くならない点に注意してください。

開発中は「Update on reload」をオンにすると、変更がすぐ反映されます。画面の表記はChromeのバージョンで変わることがあります(2026年10月現在)。

あわせて、WordPress 6.8からはリンク先を先読みする「Speculative Loading」がコアに入っています。ページ間の移動を速くする仕組みとして、Service Workerとは別に効くため、両方の効果を分けて計測してください。

不具合が出たときに解除する方法

Service Workerは一度登録されると、読者のブラウザに残り続けます。問題が起きたときは、スクリプトを消すのではなく、同じURLから「自分を解除する」内容を配信します。URLを消してしまうと、古いService Workerが残ったままになることがあるためです。sw-template.js を一時的に次の内容に差し替えます。

self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    const keys = await caches.keys();
    await Promise.all(keys.map((k) => caches.delete(k)));
    await self.registration.unregister();
  })());
});

差し替える前に、元のファイルはバックアップしておいてください。

まとめ

  • App Shellは、骨組みを端末に置いて再訪問時の表示を速くする設計で、SEO対策ではありません。
  • WordPressでは、シェルの資産を先に保存し、HTMLはネットワーク優先で取る形を基本にします。
  • Service Workerはサイトのトップのパスから配信し、キャッシュ名にテーマのバージョンを入れて入れ替えを自動化します。
  • 管理画面・ログイン・REST API・カートなどは除外し、シェルの取得時はCookieを付けません。
  • 本格的なApp Shellは一覧画面など骨組みが固定の画面に絞り、解除用のスクリプトも用意しておきます。

お気軽にご相談ください

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