ข้ามไปเนื้อหาหลัก

Category: guide

Progressive Web App (PWA) — Manifest, Service Worker, Offline

สร้าง PWA ตั้งแต่ web app manifest ถึง service worker caching strategy และ install prompt

· อ่านประมาณ 4 นาที

สารบัญ

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-color meta 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}'],
      },
    }),
  ],
});