perf: Schritt 32 — Lazy Loading & Caching-Strategie
Cache-Control (server/index.js):
- Bilder/Fonts: public, max-age=2592000, immutable (30 Tage)
- HTML/JS/CSS/JSON: no-cache, must-revalidate (ETag-Revalidierung)
→ Deployment-Updates greifen sofort, unveränderte Dateien = 304 ohne Transfer
- API: no-store (kein Browser-Caching von Nutzerdaten)
Service Worker (public/sw.js → v3):
- Drei getrennte Caches: shell-v3, pages-v3, assets-v3
- App-Shell + alle Seiten-Module beim Install vorab gecacht
- Stale-While-Revalidate für App-Shell + Seiten-JS:
sofortiger Render aus Cache, Hintergrund-Update ohne Blockierung
- Cache-First für Bilder/Fonts (seltene Änderungen)
- postMessage({ type: 'SW_UPDATED' }) bei Aktivierung neuer Version
Modul-Cache + Update-Toast (public/router.js):
- moduleCache Map: dynamische imports werden einmalig gespeichert,
wiederholte Navigation braucht keinen Import-Lookup mehr
- SW_UPDATED-Handler: leert moduleCache + zeigt Update-Toast (8s)
Preconnect + Preload (public/index.html):
- <link rel="preconnect" href="https://unpkg.com">
- <link rel="dns-prefetch" href="https://openweathermap.org">
- <link rel="preload" href="/api.js" as="script">
- <link rel="preload" href="/router.js" as="script">
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,14 @@
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
|
||||
<!-- DNS-Prefetch + Preconnect für externe Ressourcen -->
|
||||
<link rel="preconnect" href="https://unpkg.com" crossorigin />
|
||||
<link rel="dns-prefetch" href="https://openweathermap.org" />
|
||||
|
||||
<!-- Preload: kritische Skripte (frühzeitig laden, bevor Parser sie findet) -->
|
||||
<link rel="preload" href="/api.js" as="script" crossorigin />
|
||||
<link rel="preload" href="/router.js" as="script" crossorigin />
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="/styles/tokens.css" />
|
||||
<link rel="stylesheet" href="/styles/reset.css" />
|
||||
|
||||
+28
-1
@@ -23,6 +23,18 @@ const ROUTES = [
|
||||
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true },
|
||||
];
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Modul-Cache: verhindert redundante dynamic imports bei Navigation
|
||||
// --------------------------------------------------------
|
||||
const moduleCache = new Map();
|
||||
|
||||
async function importPage(pagePath) {
|
||||
if (!moduleCache.has(pagePath)) {
|
||||
moduleCache.set(pagePath, await import(pagePath));
|
||||
}
|
||||
return moduleCache.get(pagePath);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Globaler App-State
|
||||
// --------------------------------------------------------
|
||||
@@ -80,7 +92,7 @@ async function renderPage(route) {
|
||||
if (loading) loading.hidden = true;
|
||||
|
||||
try {
|
||||
const module = await import(route.page + '?v=1');
|
||||
const module = await importPage(route.page);
|
||||
|
||||
if (typeof module.render !== 'function') {
|
||||
throw new Error(`Seite ${route.page} exportiert keine render()-Funktion.`);
|
||||
@@ -237,6 +249,21 @@ window.addEventListener('unhandledrejection', (e) => {
|
||||
e.preventDefault(); // Konsolenfehler unterdrücken (bereits geloggt)
|
||||
});
|
||||
|
||||
// SW-Update: neue Version im Hintergrund installiert → Toast anzeigen
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', (e) => {
|
||||
if (e.data?.type === 'SW_UPDATED') {
|
||||
// Modul-Cache leeren damit nächste Navigation frische Module lädt
|
||||
moduleCache.clear();
|
||||
showToast(
|
||||
'Update verfügbar — Seite neu laden für die neueste Version.',
|
||||
'default',
|
||||
8000
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Browser zurück/vor
|
||||
window.addEventListener('popstate', (e) => {
|
||||
navigate(e.state?.path || location.pathname, false);
|
||||
|
||||
+121
-31
@@ -1,12 +1,23 @@
|
||||
/**
|
||||
* Modul: Service Worker
|
||||
* Zweck: Offline-Fähigkeit (App-Shell-Caching), Hintergrund-Sync
|
||||
* Zweck: Offline-Fähigkeit, differenzierte Caching-Strategien, Update-Notification
|
||||
* Abhängigkeiten: keine
|
||||
*
|
||||
* Caching-Strategien:
|
||||
* APP_SHELL (HTML + kritische JS/CSS): Stale-While-Revalidate
|
||||
* → Sofortiger Render aus Cache, Update im Hintergrund
|
||||
* PAGE_MODULES (Seiten-JS): Stale-While-Revalidate
|
||||
* → Navigation bleibt schnell, neue Module werden im Hintergrund geladen
|
||||
* ASSETS (Bilder, Icons): Cache-First, 30-Tage-TTL
|
||||
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'oikos-v2';
|
||||
const SHELL_CACHE = 'oikos-shell-v3';
|
||||
const PAGES_CACHE = 'oikos-pages-v3';
|
||||
const ASSETS_CACHE = 'oikos-assets-v3';
|
||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
||||
|
||||
// App-Shell-Ressourcen, die offline verfügbar sein sollen
|
||||
// App-Shell: sofort benötigt für ersten Render
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/index.html',
|
||||
@@ -27,59 +38,138 @@ const APP_SHELL = [
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// Seiten-Module: lazy geladen, aber vorab gecacht für Offline
|
||||
const PAGE_MODULES = [
|
||||
'/pages/dashboard.js',
|
||||
'/pages/tasks.js',
|
||||
'/pages/shopping.js',
|
||||
'/pages/meals.js',
|
||||
'/pages/calendar.js',
|
||||
'/pages/notes.js',
|
||||
'/pages/contacts.js',
|
||||
'/pages/budget.js',
|
||||
'/pages/settings.js',
|
||||
'/pages/login.js',
|
||||
];
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Install: App-Shell cachen
|
||||
// Install: App-Shell + Seiten-Module vorab cachen
|
||||
// --------------------------------------------------------
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
|
||||
Promise.all([
|
||||
caches.open(SHELL_CACHE).then((c) => c.addAll(APP_SHELL)),
|
||||
caches.open(PAGES_CACHE).then((c) => c.addAll(PAGE_MODULES)),
|
||||
])
|
||||
);
|
||||
// Sofort aktivieren ohne auf bestehende Clients zu warten
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Activate: Alte Caches löschen
|
||||
// Activate: Alte Cache-Versionen löschen + Clients informieren
|
||||
// --------------------------------------------------------
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||
keys
|
||||
.filter((key) => !ALL_CACHES.includes(key))
|
||||
.map((key) => caches.delete(key))
|
||||
)
|
||||
)
|
||||
).then(() => {
|
||||
self.clients.claim();
|
||||
// Alle offenen Tabs über das Update informieren
|
||||
self.clients.matchAll({ type: 'window' }).then((clients) => {
|
||||
clients.forEach((client) => client.postMessage({ type: 'SW_UPDATED' }));
|
||||
});
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Fetch: Netzwerk-First für API, Cache-First für App-Shell
|
||||
// Fetch: Strategie je nach Request-Typ
|
||||
// --------------------------------------------------------
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// API-Requests: immer Netzwerk (kein Caching von Nutzerdaten)
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return; // Browser übernimmt
|
||||
// API: immer Netzwerk — niemals Nutzerdaten cachen
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// Nur GET cachen
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
// Bilder + Fonts: Cache-First, langer TTL
|
||||
if (isAsset(url.pathname)) {
|
||||
event.respondWith(cacheFirst(request, ASSETS_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// App-Shell: Cache-First, Fallback Netzwerk
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
// Seiten-Module (/pages/*.js): Stale-While-Revalidate
|
||||
if (url.pathname.startsWith('/pages/')) {
|
||||
event.respondWith(staleWhileRevalidate(request, PAGES_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
return fetch(request).then((response) => {
|
||||
if (response.ok && response.type === 'basic') {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => {
|
||||
// Offline-Fallback für Seiten-Navigation
|
||||
if (request.mode === 'navigate') {
|
||||
return caches.match('/index.html');
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
// App-Shell (HTML, JS, CSS): Stale-While-Revalidate
|
||||
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Strategie: Stale-While-Revalidate
|
||||
// Liefert sofort aus Cache, aktualisiert im Hintergrund.
|
||||
// Fallback auf Netzwerk wenn nicht gecacht; Fallback auf
|
||||
// index.html für Navigations-Requests (Offline-SPA).
|
||||
// --------------------------------------------------------
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
// Netzwerk-Request im Hintergrund starten
|
||||
const networkPromise = fetch(request).then((response) => {
|
||||
if (response.ok && response.type === 'basic') {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}).catch(() => null);
|
||||
|
||||
if (cached) {
|
||||
// Hintergrund-Update läuft, Cache-Version sofort zurückgeben
|
||||
networkPromise; // fire-and-forget
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Nicht im Cache → auf Netzwerk warten
|
||||
const networkResponse = await networkPromise;
|
||||
if (networkResponse) return networkResponse;
|
||||
|
||||
// Offline-Fallback: SPA-Shell für Navigation
|
||||
if (request.mode === 'navigate') {
|
||||
return caches.match('/index.html');
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Strategie: Cache-First mit TTL (für Bilder/Fonts)
|
||||
// --------------------------------------------------------
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
} catch {
|
||||
return new Response('', { status: 408 });
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// --------------------------------------------------------
|
||||
function isAsset(pathname) {
|
||||
return /\.(png|jpg|jpeg|ico|svg|webp|woff2?|gif)$/i.test(pathname);
|
||||
}
|
||||
|
||||
+24
-2
@@ -67,11 +67,33 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Statische Dateien (Frontend)
|
||||
// API-Antworten: kein Browser-Caching (Sicherheit + Aktualität)
|
||||
// --------------------------------------------------------
|
||||
app.use('/api/', (req, res, next) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
next();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Statische Dateien (Frontend) — differenzierte Caching-Strategie
|
||||
//
|
||||
// HTML + JS + CSS: no-cache (Browser revalidiert via ETag/304, kein stale Content
|
||||
// nach Deployment). Bei unverändertem File → 304 Not Modified ohne Übertragung.
|
||||
// Bilder + Icons + Fonts: 30 Tage immutable (ändern sich praktisch nie).
|
||||
// manifest.json + sw.js: no-cache (PWA-Updates sollen sofort greifen).
|
||||
// --------------------------------------------------------
|
||||
app.use(express.static(path.join(__dirname, '..', 'public'), {
|
||||
maxAge: process.env.NODE_ENV === 'production' ? '7d' : 0,
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
setHeaders(res, filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (['.png', '.jpg', '.jpeg', '.ico', '.svg', '.webp', '.woff2', '.woff'].includes(ext)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); // 30 Tage
|
||||
} else {
|
||||
// HTML, JS, CSS, JSON, manifest, sw — immer revalidieren
|
||||
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user