Szybka odpowiedź
Progressive Web App (PWA) to aplikacja webowa działająca jak natywna aplikacja mobilna, z możliwością pracy offline dzięki Service Workers. Service Worker to skrypt JavaScript działający w tle, przechwytujący żądania sieciowe i zarządzający cache'em. Kluczowe elementy to: manifest.json definiujący wygląd aplikacji, strategia cachowania zasobów, oraz implementacja background sync dla synchronizacji danych po powrocie połączenia.
Wprowadzenie
W dobie rosnących oczekiwań użytkowników, aplikacje webowe muszą działać niezawodnie - nawet bez połączenia internetowego. Progressive Web Apps (PWA) oferują rozwiązanie tego problemu, łącząc najlepsze cechy aplikacji webowych i natywnych. Możliwość pracy offline to już nie luksus, ale standard oczekiwany przez użytkowników.
W tym przewodniku pokażemy kompleksową implementację PWA z trybem offline, od podstaw Service Workers po zaawansowane strategie cachowania. Szczególny nacisk położymy na praktyczne przykłady w Next.js oraz najlepsze praktyki na lata 2024-2025.
Architektura PWA i tryb offline
Kluczowe komponenty
PWA z trybem offline opiera się na trzech filarach:
- Service Worker - skrypt JavaScript działający jako proxy między aplikacją a siecią
- Cache API - przechowywanie zasobów lokalnie w przeglądarce
- Manifest - plik JSON definiujący wygląd i zachowanie aplikacji
Jak działa Service Worker
Service Worker to serce funkcjonalności offline. Działa jako warstwa pośrednicząca między aplikacją a siecią:
// Cykl życia Service Worker
// 1. Rejestracja
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => console.log('SW zarejestrowany:', registration))
.catch(error => console.log('Błąd rejestracji SW:', error));
});
}
// 2. Instalacja
self.addEventListener('install', event => {
console.log('Service Worker instalowany');
// Precaching krytycznych zasobów
});
// 3. Aktywacja
self.addEventListener('activate', event => {
console.log('Service Worker aktywowany');
// Czyszczenie starych cache'y
});
// 4. Przechwytywanie żądań
self.addEventListener('fetch', event => {
console.log('Przechwycono żądanie:', event.request.url);
// Logika obsługi żądań
});
Implementacja podstawowego Service Worker
Krok 1: Rejestracja Service Worker
// app.js lub index.js
if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service Worker zarejestrowany:', registration.scope);
// Sprawdzanie aktualizacji
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Nowa wersja dostępna
showUpdateNotification();
}
});
});
} catch (error) {
console.error('Błąd rejestracji Service Worker:', error);
}
});
}
Krok 2: Implementacja Service Worker
// public/sw.js
const CACHE_NAME = 'zagor-pwa-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/offline.html',
// Dodaj wszystkie krytyczne zasoby
];
// Instalacja i precaching
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Cache otwarty');
return cache.addAll(urlsToCache);
})
.then(() => self.skipWaiting()) // Natychmiastowa aktywacja
);
});
// Aktywacja i czyszczenie starych cache'y
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (!cacheWhitelist.includes(cacheName)) {
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim()) // Przejęcie kontroli
);
});
// Strategie cachowania
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Cache hit - zwróć z cache
if (response) {
return response;
}
// Klonuj żądanie
const fetchRequest = event.request.clone();
return fetch(fetchRequest).then(response => {
// Sprawdź czy odpowiedź jest prawidłowa
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Klonuj odpowiedź
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
});
})
.catch(() => {
// Offline fallback
return caches.match('/offline.html');
})
);
});
Strategie cachowania
1. Cache First (Cache, falling back to network)
// Idealna dla zasobów statycznych
async function cacheFirst(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached) {
return cached;
}
try {
const response = await fetch(request);
await cache.put(request, response.clone());
return response;
} catch (error) {
// Obsługa błędu
return new Response('Offline', { status: 503 });
}
}
2. Network First (Network, falling back to cache)
// Idealna dla dynamicznych treści
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
await cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await caches.match(request);
return cached || new Response('Offline', { status: 503 });
}
}
3. Stale While Revalidate
// Zwraca z cache, ale aktualizuje w tle
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then(response => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
Wybór strategii w Service Worker
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// Różne strategie dla różnych typów zasobów
if (url.pathname.startsWith('/api/')) {
// API - Network First
event.respondWith(networkFirst(request));
} else if (request.destination === 'image') {
// Obrazy - Cache First
event.respondWith(cacheFirst(request));
} else if (request.mode === 'navigate') {
// Nawigacja - Stale While Revalidate
event.respondWith(staleWhileRevalidate(request));
} else {
// Domyślnie - Cache First
event.respondWith(cacheFirst(request));
}
});
Implementacja PWA w Next.js
Instalacja i konfiguracja
# Instalacja niezbędnych pakietów
npm install next-pwa
npm install --save-dev @types/node
Konfiguracja next.config.js
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
buildExcludes: [/middleware-manifest.json$/],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60 // 1 rok
}
}
},
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60 // 1 dzień
}
}
},
{
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'images',
expiration: {
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 dni
}
}
}
]
})
module.exports = withPWA({
reactStrictMode: true,
swcMinify: true,
// Inne konfiguracje Next.js
})
Manifest aplikacji
// public/manifest.json
{
"name": "Zagor Digital PWA",
"short_name": "Zagor PWA",
"description": "Profesjonalne strony internetowe z trybem offline",
"theme_color": "#FF6B35",
"background_color": "#ffffff",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"start_url": "/?source=pwa",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Dodanie meta tagów w _document.tsx
// src/pages/_document.tsx lub src/app/layout.tsx
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="pl">
<Head>
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
<meta name="theme-color" content="#FF6B35" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Zagor PWA" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="msapplication-TileColor" content="#FF6B35" />
<meta name="msapplication-tap-highlight" content="no" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
Zaawansowane funkcje offline
Background Sync
// Rejestracja Background Sync
async function registerBackgroundSync() {
const registration = await navigator.serviceWorker.ready;
try {
await registration.sync.register('sync-data');
console.log('Background sync zarejestrowany');
} catch (error) {
console.log('Background sync nie jest wspierany');
}
}
// W Service Worker
self.addEventListener('sync', event => {
if (event.tag === 'sync-data') {
event.waitUntil(syncData());
}
});
async function syncData() {
// Pobierz dane z IndexedDB
const pendingRequests = await getPendingRequests();
for (const request of pendingRequests) {
try {
await fetch(request.url, request.options);
await removePendingRequest(request.id);
} catch (error) {
console.error('Błąd synchronizacji:', error);
}
}
}
Offline Analytics
// Śledzenie zdarzeń offline
class OfflineAnalytics {
constructor() {
this.queue = [];
this.db = null;
this.initDB();
}
async initDB() {
const request = indexedDB.open('analytics', 1);
request.onerror = () => console.error('Błąd IndexedDB');
request.onsuccess = event => {
this.db = event.target.result;
};
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains('events')) {
db.createObjectStore('events', { keyPath: 'id', autoIncrement: true });
}
};
}
async trackEvent(category, action, label, value) {
const event = {
category,
action,
label,
value,
timestamp: Date.now(),
offline: !navigator.onLine
};
if (navigator.onLine) {
await this.sendToAnalytics(event);
} else {
await this.queueEvent(event);
}
}
async queueEvent(event) {
const transaction = this.db.transaction(['events'], 'readwrite');
const store = transaction.objectStore('events');
await store.add(event);
}
async syncEvents() {
const transaction = this.db.transaction(['events'], 'readonly');
const store = transaction.objectStore('events');
const events = await store.getAll();
for (const event of events) {
await this.sendToAnalytics(event);
await this.removeEvent(event.id);
}
}
}
Optymalizacja wydajności PWA
1. Inteligentne precaching
// Precaching tylko krytycznych zasobów
const criticalAssets = [
'/',
'/offline.html',
'/css/critical.css',
'/js/app.js'
];
// Lazy loading pozostałych zasobów
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
// Najpierw krytyczne zasoby
return cache.addAll(criticalAssets).then(() => {
// Następnie pozostałe w tle
return cache.addAll(nonCriticalAssets).catch(() => {
// Nie blokuj instalacji jeśli nie-krytyczne zasoby zawiodą
});
});
})
);
});
2. Cache versioning i update
// Strategia wersjonowania cache
const CACHE_VERSION = 'v2';
const CACHE_PREFIX = 'zagor-pwa-';
const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;
// Automatyczne czyszczenie starych wersji
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(cacheName => cacheName.startsWith(CACHE_PREFIX))
.filter(cacheName => cacheName !== CACHE_NAME)
.map(cacheName => caches.delete(cacheName))
);
})
);
});
3. Adaptive loading
// Dostosowanie strategii do warunków sieci
async function adaptiveStrategy(request) {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection) {
// Wolne połączenie - priorytet cache
if (connection.effectiveType === '2g' || connection.saveData) {
return cacheFirst(request);
}
// Szybkie połączenie - priorytet sieci
if (connection.effectiveType === '4g') {
return networkFirst(request);
}
}
// Domyślnie - stale while revalidate
return staleWhileRevalidate(request);
}
Bezpieczeństwo PWA
Najlepsze praktyki bezpieczeństwa
| Praktyka | Implementacja |
|---|---|
| HTTPS Enforcement | Wymuszaj HTTPS dla wszystkich zasobów PWA |
| Content Security Policy | Ustaw CSP headers dla Service Workers |
| Sanityzacja danych | Waliduj cache przed użyciem |
| Ograniczenia CORS | Kontroluj dostęp do zewnętrznych API |
| Token expiration | Automatyczne odświeżanie tokenów w cache |
Przykład bezpiecznego Service Worker
// Bezpieczna obsługa wrażliwych danych
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Nie cachuj wrażliwych endpointów
const sensitiveRoutes = ['/api/user', '/api/payment', '/api/auth'];
if (sensitiveRoutes.some(route => url.pathname.startsWith(route))) {
event.respondWith(fetch(event.request));
return;
}
// Sprawdź pochodzenie żądania
if (url.origin !== self.location.origin) {
// Zewnętrzne żądanie - zastosuj politykę CORS
event.respondWith(handleCORSRequest(event.request));
return;
}
// Normalna obsługa dla bezpiecznych zasobów
event.respondWith(handleRequest(event.request));
});
Testowanie trybu offline
1. Chrome DevTools
// Symulacja offline w konsoli
// 1. Otwórz DevTools (F12)
// 2. Zakładka Network
// 3. Zaznacz "Offline"
// 4. Odśwież stronę
// Programowe testowanie
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
// Sprawdź stan
console.log('SW Active:', registration.active);
// Wymuś update
registration.update();
});
}
2. Lighthouse PWA Audit
# Uruchom audit PWA
npx lighthouse https://localhost:3000 --view
# Lub w Chrome DevTools
# Zakładka Lighthouse > Generate report
3. Testy jednostkowe Service Worker
// __tests__/service-worker.test.js
import { mockServiceWorker } from 'msw';
describe('Service Worker', () => {
beforeEach(() => {
global.caches = {
open: jest.fn(),
match: jest.fn()
};
});
test('should cache critical assets on install', async () => {
const cache = {
addAll: jest.fn()
};
global.caches.open.mockResolvedValue(cache);
// Trigger install event
await serviceWorker.install();
expect(cache.addAll).toHaveBeenCalledWith(
expect.arrayContaining(['/index.html', '/offline.html'])
);
});
});
Monitorowanie i analityka
Metryki PWA
// Monitorowanie wydajności cache
class CacheMetrics {
constructor() {
this.hits = 0;
this.misses = 0;
this.errors = 0;
}
async trackCachePerformance(request) {
const startTime = performance.now();
try {
const cachedResponse = await caches.match(request);
const endTime = performance.now();
if (cachedResponse) {
this.hits++;
this.logMetric('cache_hit', endTime - startTime);
} else {
this.misses++;
this.logMetric('cache_miss', endTime - startTime);
}
return cachedResponse;
} catch (error) {
this.errors++;
this.logMetric('cache_error', 0);
throw error;
}
}
getCacheHitRate() {
const total = this.hits + this.misses;
return total > 0 ? (this.hits / total) * 100 : 0;
}
}
Najczęściej zadawane pytania
Czy PWA działa na iOS?
Tak, ale z pewnymi ograniczeniami. iOS wspiera Service Workers od wersji 11.3, ale nie wszystkie API są dostępne (np. instalacja z Safari jest ograniczona).
Jak duży może być cache PWA?
Limity różnią się między przeglądarkami. Chrome pozwala na około 6% dostępnego miejsca na dysku, Firefox podobnie. Zawsze monitoruj użycie quota API.
Czy PWA zastąpi aplikacje natywne?
PWA są świetne dla większości przypadków użycia, ale aplikacje natywne nadal mają przewagę w dostępie do niektórych API systemowych i wydajności.
Jak zaktualizować Service Worker?
Service Worker automatycznie sprawdza aktualizacje przy każdej nawigacji. Możesz też wymusić update przez registration.update().
Czy mogę używać PWA bez HTTPS?
Nie w produkcji. Service Workers wymagają HTTPS ze względów bezpieczeństwa. Wyjątek to localhost podczas developmentu.
Jak debugować Service Worker?
Użyj Chrome DevTools > Application > Service Workers. Możesz też użyć chrome://inspect/#service-workers.
Czy PWA wpływa na SEO?
PWA może poprawić SEO poprzez lepszą wydajność i user experience. Google preferuje szybkie, responsywne strony.
Podsumowanie i kolejne kroki
Progressive Web Apps z trybem offline to przyszłość aplikacji webowych. Kluczowe elementy sukcesu to przemyślana strategia cachowania, bezpieczeństwo i monitoring wydajności. Service Workers dają nam potężne narzędzia do tworzenia aplikacji działających niezależnie od połączenia internetowego.
Następne kroki:
- Zainstaluj next-pwa w swoim projekcie
- Skonfiguruj podstawowy Service Worker
- Zdefiniuj strategie cachowania dla różnych typów zasobów
- Przetestuj tryb offline w różnych scenariuszach
- Monitoruj metryki i optymalizuj
Potrzebujesz pomocy we wdrożeniu PWA dla swojej firmy? Skontaktuj się z nami - zespół Zagor Digital pomoże stworzyć aplikację, która działa zawsze i wszędzie.
Ostatnia aktualizacja: 26 stycznia 2025