Category: guide
Progressive Web App (PWA) — Manifest, Service Worker, Offline
สร้าง PWA ตั้งแต่ web app manifest ถึง service worker caching strategy และ install prompt
สารบัญ
PWA คืออะไร
Progressive Web App คือ web app ที่ทำงานเหมือน native app:
- Installable — add to home screen ได้บน mobile และ desktop
- Offline-capable — ทำงานได้แม้ไม่มีเน็ตหรือเน็ตช้า
- Reliable — load เร็ว ไม่ขึ้นกับ network condition
ไม่จำเป็นต้องทำทุกอย่าง — Progressive หมายถึงทำได้ตาม capability ของ browser
Web App Manifest
ไฟล์ JSON ที่บอก browser เกี่ยวกับ app:
// public/manifest.json
{
"name": "My Awesome App",
"short_name": "AwesomeApp",
"description": "App ที่ทำอะไรสักอย่าง",
"start_url": "/",
"display": "standalone",
"background_color": "#f1f5f9",
"theme_color": "#2563eb",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{ "src": "/screenshots/home.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" }
]
}
เชื่อม manifest กับ HTML:
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#2563eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
Service Worker
JavaScript ที่รันแยก thread จาก main page — intercept network requests ได้:
// public/sw.js
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
const PRECACHE_URLS = [
'/',
'/offline.html',
'/styles/main.css',
'/scripts/app.js',
];
// Install — precache essential assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
// Activate — ลบ cache เก่า
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys
.filter((key) => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
// Fetch — intercept requests
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(handleFetch(event.request));
});
async function handleFetch(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, response.clone());
}
return response;
} catch {
// offline fallback
const url = new URL(request.url);
if (url.pathname.startsWith('/api')) {
return new Response(JSON.stringify({ error: 'offline' }), {
headers: { 'Content-Type': 'application/json' },
});
}
return caches.match('/offline.html');
}
}
Register Service Worker
// main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('SW registered:', reg.scope);
// ตรวจ update
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateNotification();
}
});
});
} catch (err) {
console.error('SW registration failed:', err);
}
});
}
Caching Strategies
Cache First — เหมาะกับ static assets ที่ไม่เปลี่ยน:
async function cacheFirst(request) {
return (await caches.match(request)) ?? fetch(request);
}
Network First — เหมาะกับ dynamic content ที่ต้องการ fresh data:
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
return response;
} catch {
return caches.match(request);
}
}
Stale-While-Revalidate — return cache ทันที แต่ fetch ใหม่ใน background:
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
return cached ?? fetchPromise;
}
Install Prompt
let deferredPrompt = null;
const installBtn = document.getElementById('install-btn');
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
installBtn?.removeAttribute('hidden');
});
installBtn?.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`Install ${outcome}`); // "accepted" | "dismissed"
deferredPrompt = null;
installBtn.setAttribute('hidden', '');
});
window.addEventListener('appinstalled', () => {
console.log('PWA installed');
installBtn?.setAttribute('hidden', '');
});
Background Sync
Retry actions เมื่อ network กลับมา — เหมาะกับ form submit:
// ใน main thread
async function submitForm(data) {
try {
await fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) });
} catch {
// save to IndexedDB แล้วขอ sync
await saveToQueue(data);
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('form-submit');
}
}
// ใน service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'form-submit') {
event.waitUntil(flushQueue());
}
});
Push Notifications
// ขอ permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return null;
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(PUBLIC_VAPID_KEY),
});
return subscription;
}
// ใน service worker
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? 'Notification', {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: data.url },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url ?? '/'));
});
Checklist PWA
- HTTPS (service worker ต้องการ)
- Web App Manifest ครบถ้วน
- Icon 192px และ 512px (รวม maskable icon)
- Service Worker registered
- Offline fallback page
-
start_urlใน manifest ถูก scope -
theme-colormeta tag - Lighthouse PWA score ≥ 90
ตรวจสอบด้วย Lighthouse
Chrome DevTools → Lighthouse → เลือก Progressive Web App → Analyze
หรือผ่าน CLI:
npx lighthouse https://yoursite.com --only-categories=pwa --output=html
PWA กับ Astro
Astro ไม่มี service worker built-in แต่ใช้ @vite-pwa/astro integration ได้:
npm install @vite-pwa/astro -D
// astro.config.mjs
import { defineConfig } from 'astro/config';
import AstroPWA from '@vite-pwa/astro';
export default defineConfig({
integrations: [
AstroPWA({
registerType: 'autoUpdate',
manifest: {
name: 'My Astro PWA',
short_name: 'AstroPWA',
theme_color: '#2563eb',
},
workbox: {
globPatterns: ['**/*.{css,js,html,svg,png,ico,txt}'],
},
}),
],
});