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を使うと組み立てやすくなります。
速くなったかを確認する
- Chromeのデベロッパーツールの「Application」パネルでService Workerが「activated」になり、スコープがサイトのトップになっていることを確認します。
- 「Network」パネルで再読み込みし、CSSやフォントが「(ServiceWorker)」から返っていることを確かめます。
- 回線を「Offline」にして記事を開き、オフラインページが出ることを確認します。
- 体感ではなく数値で比べるには、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は一覧画面など骨組みが固定の画面に絞り、解除用のスクリプトも用意しておきます。