読書管理サービス ReadNest を PWA 化しました。素の PHP + Apache(前段に nginx)という古典的な構成にあとから PWA レイヤーをかぶせる作業です。
ライブラリは使わず、素の Service Worker のみで実装しています。かっこよく「依存を増やしたくなかったから」と書きたいところですが、正直に告白すると PWA を書き始めた時点で workbox の存在を知りませんでした。あとから知って「これ使えば数十行で書けたのか…」と脱力しつつ、結果的に Service Worker の中身が手触りでわかったので、これはこれで良しとしています。
同じく素で書こうとしている人、あるいは workbox を使う前に中身を一度理解したい人の参考になれば。
そもそも PWA / Service Worker / workbox って?
サクッとおさらいから。
- PWA (Progressive Web App): Web サイトをアプリのように扱える仕組みの総称。ホーム画面に追加できる、オフラインでも開ける、URL バーが消えて全画面起動できる、など。必須要件は3つ — HTTPS、Web App Manifest、Service Worker。
-
Web App Manifest:
manifest.jsonという JSON ファイル。アプリ名・アイコン・テーマカラー・起動時の表示モード(standalone 等)を宣言する。 - Service Worker (SW): ブラウザが裏で動かす JavaScript。ネットワークリクエストを横取りしてキャッシュ判断したり、オフライン時の応答を返したりできる。PWA の「オフラインで動く」「2回目以降の起動が速い」を支える中核。
- workbox: Google が提供する Service Worker ヘルパーライブラリ。キャッシュ戦略を宣言的に書けるようになる。今回は知らずに書き始めたので使っていない。
前提・成果物
- 既存サイト: PHP + Apache、前段に nginx、HTTPS済み、レスポンシブ対応済み
- 触らない方針: 既存ルーティング・認証・CSS
- 追加する方針: PWA レイヤーを後付け(manifest.json + sw.js + offline.html + アイコン)
- ライブラリ: なし
| ファイル | 役割 |
|---|---|
/manifest.json |
Web App Manifest |
/sw.js |
Service Worker(4戦略キャッシュ) |
/offline.html |
オフライン時フォールバック |
/img/icons/*.png |
PWA アイコン群 |
/template/.../t_base.php |
meta タグ、SW登録、UX調整 |
Service Worker の戦略
ライブラリなしで書くと、戦略の使い分けはこうなりました。
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 表紙画像(Amazon / Google Books)
if (BOOK_IMAGE_HOSTS.includes(url.hostname)) {
event.respondWith(staleWhileRevalidate(...));
return;
}
// API / Ajax
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ajax/')) {
event.respondWith(networkFirst(...));
return;
}
// HTML ナビゲーション
if (event.request.mode === 'navigate') {
event.respondWith(navigationHandler(event.request));
return;
}
// 静的アセット
if (isStaticAsset(url.pathname)) {
event.respondWith(cacheFirst(...));
return;
}
});
| 対象 | 戦略 |
|---|---|
| CSS / JS / フォント等 | Cache First |
| 本の表紙画像 | Stale While Revalidate |
| API レスポンス | Network First → cache fallback |
| HTML ナビゲーション | Network First → 3秒タイムアウトでキャッシュ |
ちなみに workbox なら以下のような数行で同等のことが書けるようです(後で知った)。
import {registerRoute} from 'workbox-routing';
import {StaleWhileRevalidate, NetworkFirst, CacheFirst} from 'workbox-strategies';
registerRoute(({url}) => BOOK_IMAGE_HOSTS.includes(url.hostname),
new StaleWhileRevalidate());
registerRoute(({url}) => url.pathname.startsWith('/api/'),
new NetworkFirst());
「うわ、短い」と思いつつ、素で書いたおかげで内部のキャッシュライフサイクルが手触りでわかったので、まあヨシ。
ここからが本題、ハマったポイントです。
罠1: /icons/ が nginx で別パスにマッピングされていた
manifest.json の icons を /icons/icon-192.png 等で定義し、リポジトリ直下に /icons/ ディレクトリを作ってデプロイ。しかし本番では全部 404 でした。
$ curl -I https://readnest.jp/icons/icon-192.png
HTTP/1.1 404 Not Found
manifest.json 自体は 200 で返るのに、参照されているアイコンが全滅。Chrome は manifest 内のアイコンが全て解決できないと、manifest 自体を invalid 扱いします。DevTools Application タブの Manifest セクションには堂々とこう表示される:
No manifest detected
A manifest defines how your app appears on phone's home screens and what the app looks like on launch.
manifest.json は配信できているのに、なぜか「manifest が見つからない」と言われるので原因特定にひと手間かかります。
ローカルでは動くのに本番でこける。原因は nginx の location 設定で /icons/ が他のパスにマップされていたことでした(過去の名残らしい)。
対策: アイコン一式を /img/icons/ に移して、manifest と HTML 内の参照を一括書き換え。リポジトリ直下に新しいディレクトリを切らず、配信が確認できているパス配下を使うのが安全です。
罠2: SW が manifest.json をキャッシュして更新が反映されない
/icons/ を /img/icons/ に修正してデプロイしたのに、まだアイコンが 404。DevTools の Console には依然として古いパスの 404 が並びます。
GET https://readnest.jp/icons/icon-192.png 404 (Not Found)
Error while trying to use the following icon from the Manifest:
https://readnest.jp/icons/icon-192.png (Download error or resource isn't a valid image)
サーバーの manifest.json は新しい値(/img/icons/...)を返している。なのにブラウザは古いパスを見ている。
$ curl -s https://readnest.jp/manifest.json | grep src
"src": "/img/icons/icon-192.png"
"src": "/img/icons/icon-512.png"
"src": "/img/icons/icon-512-maskable.png"
犯人は自分が書いた Service Worker でした。
function isStaticAsset(pathname) {
return /\.(css|js|woff2?|ttf|otf|eot)$/i.test(pathname)
|| pathname.startsWith('/css/')
|| pathname.startsWith('/js/')
// ...
|| pathname === '/manifest.json' // ← これ
|| pathname === '/favicon.ico';
}
manifest.json を「静的アセット」扱いで Cache First に入れていたため、SW が初回に拾った旧 manifest を半永久的に返し続けていました。
対策: SW で /manifest.json だけは絶対にキャッシュしない(ブラウザに任せる)。
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// manifest.json は SW で触らずブラウザ任せ
if (url.pathname === '/manifest.json') return;
// ...
});
PWA インストール検証は manifest の内容をリアルタイム確認するので、ここはキャッシュしてはダメでした。
教訓: SW を書く時、PWA 構成ファイル(manifest, sw.js 自身)は SW のキャッシュ対象から除外する。
罠3: iOS PWA でバーコードリーダーが起動しない
ReadNest にはバーコードでISBNを読み取る機能があります。元は QuaggaJS で動いていたものに、Android Chrome / Edge 向けにネイティブ BarcodeDetector API を組み込み、優先順位を Native → ZXing → Quagga に変更。
if (await NativeBarcodeScanner.isSupported()) {
currentScanner = new NativeBarcodeScanner();
} else if (typeof ZXing !== 'undefined') {
currentScanner = new ZXingBarcodeScanner(); // ← iOS はここに来る
} else {
currentScanner = new BarcodeScanner(); // Quagga
}
しかし、iOS PWA でカメラ権限プロンプトが出ず、画面が固まる。
調査の結果、ZXing が初期化時に呼んでいるのは enumerateDevices() でした。iOS Safari では、enumerateDevices() は getUserMedia を一度も呼んでいない状態だと、カメラの label を返さない(または空配列を返す) という仕様。ZXing はそれを「カメラなし」とみなして silent fail。権限プロンプトを出す getUserMedia まで到達しないため、ユーザー視点では「ボタン押しても何も起きない」。
一方 QuaggaJS は init で直接 getUserMedia を呼ぶ実装で、確実にプロンプトが出ます。
対策: 優先順位を Native → Quagga → ZXing に再修正。
if (await NativeBarcodeScanner.isSupported()) {
// Android Chrome / Edge: 最高速・最高精度
} else if (typeof Quagga !== 'undefined') {
// iOS PWA / Firefox: getUserMedia を明示的に呼ぶ実装が必須
} else if (typeof ZXing !== 'undefined') {
// 最後の保険
}
教訓: iOS PWA でカメラを使うライブラリを選ぶ時は、内部で getUserMedia を最初に呼ぶ実装かどうかを確認する。enumerateDevices() 先行型は信頼できない。
罠4: PWA standalone モードでは画面遷移の進捗が完全に見えない
ホーム画面のアイコンから起動すると URL バーが消えます。すっきりしていて気持ちいいんですが、副作用として ブラウザのプログレス表示も消えます。
リンクをタップしても、現在のページがそのまま残って、新ページの描画が始まるまで「無反応」に見える。重いページに移動すると、ユーザーは「タップが反応してない?」と思ってもう一度タップしてしまう。
対策: 自前で画面上端にスリムなプログレスバーを出す。
document.addEventListener('click', function(e) {
if (e.defaultPrevented || e.button !== 0) return;
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
var link = e.target.closest && e.target.closest('a[href]');
if (!link || link.target === '_blank') return;
var href = link.getAttribute('href');
if (!href || href.charAt(0) === '#') return;
if (/^(javascript|mailto|tel|sms):/i.test(href)) return;
var url = new URL(href, location.href);
if (url.origin !== location.origin) return;
showProgress(); // 100ms 遅延後に上端3pxのバーを表示
});
window.addEventListener('pageshow', hideProgress); // bfcache 復帰
ポイント:
- 100ms 遅延を入れて瞬時に終わる遷移ではバーを出さない(チカチカ防止)
-
bfcache 復帰時 (
pageshow) に確実に隠す。これがないと戻ったページで延々バーが残る - standalone モード時は
top: env(safe-area-inset-top)でノッチを避ける
新ページが描画されると DOM ごと差し変わるのでバーは自動的に消えます。状態管理がいらないのが楽。
地味だけどモバイル UX に効いた小技
PWA 化の本筋ではないものの、standalone 起動の体験を整えるために入れた CSS が地味に効きました。
/* タップ時の青フラッシュ除去 */
html { -webkit-tap-highlight-color: transparent; }
@media (display-mode: standalone) {
/* Android Chrome の引っ張り更新を抑止 */
body { overscroll-behavior-y: contain; }
/* iOS ノッチ対応 */
header.sticky { padding-top: env(safe-area-inset-top); }
footer { padding-bottom: max(env(safe-area-inset-bottom), 1rem); }
body {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
そして viewport meta に viewport-fit=cover を入れないと env(safe-area-inset-*) が0扱いになるので注意。
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
SW 更新を確実にユーザーに届ける
controllerchange ベースのリロード戦略にしました。
// 新しい SW がアクティブになったらリロード
navigator.serviceWorker.addEventListener('controllerchange', function() {
if (refreshing) return;
refreshing = true;
window.location.reload();
});
// 更新検知時にトーストを出す
registration.addEventListener('updatefound', function() {
var newWorker = registration.installing;
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateToast(newWorker); // 「新しいバージョンが利用可能です [更新]」
}
});
});
// トーストの「更新」ボタン
function onClickUpdate() {
worker.postMessage({ type: 'SKIP_WAITING' });
// 実リロードは controllerchange ハンドラで実行される
}
これと併せて、.htaccess で sw.js と manifest.json には Cache-Control: no-cache を明示。
<FilesMatch "^(sw\.js|manifest\.json)$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Service-Worker-Allowed "/"
</FilesMatch>
まとめ
ライブラリなしで PWA 化するのは思ったより難しくないものの、本番固有の罠に何度かハマりました。振り返ると、ハマりポイントは大きく3カテゴリ:
-
本番サーバー設定がローカルと違う系 —
/icons/のマッピング - キャッシュレイヤーが多重化する系 — SW が manifest をキャッシュ
- モバイル特有の API の罠 — iOS の getUserMedia 先行要件
これから素の PWA を書く人へのチェックリスト:
- manifest.json を SW のキャッシュ対象から除外する
- SW 自身も同様(ブラウザの SW 更新サイクルを邪魔しない)
-
アイコンパスは本番で先に到達性を
curl -Iで確認 -
iOS でカメラ系を使うなら
getUserMedia先行ライブラリを選ぶ - standalone モードでナビゲーション時のフィードバックを自前で用意
- viewport-fit=cover + safe-area-inset で iOS ノッチ対応
- SW VERSION 文字列を持って activate で旧キャッシュを掃除する仕組み
そして来週の自分への手紙:
次に Service Worker を書く時は、まず
npm view workbox-swを見ろ。