PWA vlastními silami: manifest a service worker bez frameworku
Jak z běžného webu udělat instalovatelnou PWA aplikaci s offline režimem: web app manifest, service worker, strategie cache, aktualizace a ladění.
Progresivní webová aplikace (PWA) je web, který si uživatel může nainstalovat na plochu telefonu nebo počítače, spouští se v samostatném okně a funguje i offline. Nepotřebujete k tomu framework ani obchod s aplikacemi – stačí dva soubory navíc: manifest a service worker. Podmínkou je HTTPS (výjimkou je localhost při vývoji).
1. Web App Manifest
{
"name": "Moje poznámky",
"short_name": "Poznámky",
"description": "Jednoduché poznámky, které fungují i offline.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"lang": "cs",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Uložte jako /manifest.webmanifest a připojte v HTML:
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
display: standalone– aplikace běží bez adresního řádku.short_name– zobrazí se pod ikonou, držte ho krátký.- Maskovatelná ikona (
purpose: maskable) má důležitý obsah uvnitř bezpečné zóny (středový kruh o průměru 80 % rozměru), protože Android ji ořízne do kruhu nebo jiného tvaru. Nedávejte"any maskable"do jedné ikony – v jednom z režimů by vypadala špatně. - iOS používá pro ikonu na ploše
apple-touch-icon(180 × 180 px).
2. Registrace service workeru
// main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
console.log('SW zaregistrován, scope:', reg.scope);
} catch (err) {
console.error('Registrace SW selhala:', err);
}
});
}
Service worker umístěte do kořene webu. Jeho výchozí scope je složka, ve které leží – /js/sw.js by kontroloval jen URL pod /js/.
3. Service worker s offline režimem
// sw.js
const VERSION = 'v3';
const STATIC_CACHE = `static-${VERSION}`;
const PRECACHE = [
'/',
'/offline.html',
'/style.css',
'/main.js',
'/icons/icon-192.png',
];
// instalace: stáhnout základní soubory
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC_CACHE).then(cache => cache.addAll(PRECACHE))
);
});
// aktivace: smazat staré verze cache
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== STATIC_CACHE).map(k => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== location.origin) return; // cizí domény nechat být
// HTML stránky: nejdřív síť, při výpadku cache, nakonec offline stránka
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then(res => {
const copy = res.clone();
caches.open(STATIC_CACHE).then(c => c.put(request, copy));
return res;
})
.catch(async () =>
(await caches.match(request)) ?? caches.match('/offline.html')
)
);
return;
}
// ostatní (CSS, JS, obrázky): nejdřív cache, jinak síť
event.respondWith(
caches.match(request).then(cached => cached ?? fetch(request))
);
});
event.waitUntil drží service worker naživu, dokud Promise neskončí. res.clone() je nutný, protože tělo odpovědi lze přečíst jen jednou – jednu kopii uložíme, druhou vrátíme stránce.
Strategie cache – kterou kdy
| Strategie | Vhodné pro |
|---|---|
| Cache first | assety s hashem v názvu, fonty, ikony |
| Network first | HTML, data, která mají být aktuální |
| Stale-while-revalidate | avatary, méně kritická data – vrátí cache a na pozadí stáhne novou verzi |
| Network only | POST požadavky, platby, přihlášení |
Necachujte odpovědi API s osobními daty bez rozmyslu – zůstanou v zařízení i po odhlášení, pokud cache explicitně nesmažete.
4. Aktualizace: největší past
Prohlížeč nový sw.js stáhne, když se liší alespoň o bajt. Nový worker se ale neaktivuje, dokud jsou otevřené stránky ovládané tím starým. Uživatel tak může dlouho vidět starou verzi.
Proto při každé změně cachovaných souborů zvyšte VERSION a nabídněte uživateli aktualizaci:
// sw.js
self.addEventListener('message', event => {
if (event.data === 'SKIP_WAITING') self.skipWaiting();
});
// main.js
const reg = await navigator.serviceWorker.register('/sw.js');
reg.addEventListener('updatefound', () => {
const worker = reg.installing;
worker.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
if (confirm('Je k dispozici nová verze. Načíst?')) worker.postMessage('SKIP_WAITING');
}
});
});
let reloaded = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!reloaded) { reloaded = true; location.reload(); }
});
Samotný sw.js servírujte s hlavičkou Cache-Control: no-cache, aby ho nedržela HTTP cache. Nginx:
location = /sw.js {
add_header Cache-Control "no-cache";
}
5. Vlastní tlačítko „Nainstalovat”
Prohlížeče na bázi Chromia vyvolají událost beforeinstallprompt:
let deferredPrompt;
const btn = document.getElementById('install');
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
deferredPrompt = e;
btn.hidden = false;
});
btn.addEventListener('click', async () => {
deferredPrompt.prompt();
await deferredPrompt.userChoice;
deferredPrompt = null;
btn.hidden = true;
});
Safari tuto událost nepodporuje – na iPhonu se instaluje přes Sdílet → Přidat na plochu. Pro iOS je dobré zobrazit krátkou nápovědu.
6. Ladění
V Chrome DevTools → Application:
- Manifest – kontrola chyb, náhled ikon a informace, zda je aplikace instalovatelná.
- Service workers – stav, tlačítko Update, volba Update on reload (během vývoje ji zapněte) a Offline pro test bez sítě.
- Cache storage – co přesně je v cache.
- Storage → Clear site data – čistý start, když se zamotáte.
Lighthouse už samostatnou kategorii PWA nemá (byla odstraněna v roce 2024), instalovatelnost tedy ověřujte v panelu Manifest.
Pokud nechcete psát service worker ručně, nástroj Workbox (od Googlu) nebo plugin vite-plugin-pwa vygenerují precache seznam při buildu automaticky. Psát ho jednou ručně se ale vyplatí – pak víte, co dělá.