Progressive Web Apps (PWA)

Moderne, zuverlässige und ansprechende Progressive Web Apps mit Offline-Fähigkeiten erstellen

💻 PWA Manifest und Installation json

🟢 simple ⭐⭐

PWA Manifest erstellen und App-Installation aktivieren

⏱️ 15 min 🏷️ pwa, manifest, installation, app shell
Prerequisites: HTML, Web development basics
{
  "manifest.json": {
    "name": "My Progressive Web App",
    "short_name": "MyPWA",
    "description": "A modern Progressive Web Application",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#ffffff",
    "theme_color": "#3367D6",
    "orientation": "portrait-primary",
    "scope": "/",
    "lang": "en",
    "dir": "ltr",
    "categories": ["productivity", "utilities"],
    "screenshots": [
      {
        "src": "/screenshots/desktop-1.png",
        "sizes": "1280x720",
        "type": "image/png",
        "form_factor": "wide",
        "label": "Desktop view of the app"
      },
      {
        "src": "/screenshots/mobile-1.png",
        "sizes": "375x667",
        "type": "image/png",
        "form_factor": "narrow",
        "label": "Mobile view of the app"
      }
    ],
    "icons": [
      {
        "src": "/icons/icon-72x72.png",
        "sizes": "72x72",
        "type": "image/png",
        "purpose": "maskable any"
      },
      {
        "src": "/icons/icon-96x96.png",
        "sizes": "96x96",
        "type": "image/png",
        "purpose": "maskable any"
      },
      {
        "src": "/icons/icon-128x128.png",
        "sizes": "128x128",
        "type": "image/png",
        "purpose": "maskable any"
      },
      {
        "src": "/icons/icon-144x144.png",
        "sizes": "144x144",
        "type": "image/png",
        "purpose": "maskable any"
      },
      {
        "src": "/icons/icon-152x152.png",
        "sizes": "152x152",
        "type": "image/png",
        "purpose": "maskable any"
      },
      {
        "src": "/icons/icon-192x192.png",
        "sizes": "192x192",
        "type": "image/png",
        "purpose": "maskable any"
      },
      {
        "src": "/icons/icon-384x384.png",
        "sizes": "384x384",
        "type": "image/png",
        "purpose": "maskable any"
      },
      {
        "src": "/icons/icon-512x512.png",
        "sizes": "512x512",
        "type": "image/png",
        "purpose": "maskable any"
      }
    ],
    "shortcuts": [
      {
        "name": "New Task",
        "short_name": "New Task",
        "description": "Create a new task quickly",
        "url": "/new-task",
        "icons": [
          {
            "src": "/icons/shortcut-new.png",
            "sizes": "96x96"
          }
        ]
      },
      {
        "name": "Search",
        "short_name": "Search",
        "description": "Search your tasks",
        "url": "/search",
        "icons": [
          {
            "src": "/icons/shortcut-search.png",
            "sizes": "96x96"
          }
        ]
      }
    ],
    "related_applications": [
      {
        "platform": "play",
        "url": "https://play.google.com/store/apps/details?id=com.example.app",
        "id": "com.example.app"
      }
    ],
    "prefer_related_applications": false,
    "share_target": {
      "action": "/share-handler",
      "method": "POST",
      "enctype": "multipart/form-data",
      "params": {
        "title": "title",
        "text": "text",
        "url": "url",
        "files": [
          {
            "name": "images",
            "accept": ["image/*"]
          }
        ]
      }
    },
    "protocol_handlers": [
      {
        "protocol": "web+pwa",
        "url": "/handle-protocol?type=%s"
      }
    ]
  }
}

<!-- HTML head section -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Progressive Web App</title>

  <!-- Theme color -->
  <meta name="theme-color" content="#3367D6">

  <!-- Apple specific meta tags -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <meta name="apple-mobile-web-app-title" content="MyPWA">

  <!-- Apple icons -->
  <link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.png">
  <link rel="apple-touch-icon" sizes="96x96" href="/icons/icon-96x96.png">
  <link rel="apple-touch-icon" sizes="128x128" href="/icons/icon-128x128.png">
  <link rel="apple-touch-icon" sizes="144x144" href="/icons/icon-144x144.png">
  <link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png">
  <link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192x192.png">
  <link rel="apple-touch-icon" sizes="384x384" href="/icons/icon-384x384.png">
  <link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512x512.png">

  <!-- PWA manifest -->
  <link rel="manifest" href="/manifest.json">

  <!-- Splash screen images -->
  <link rel="apple-touch-startup-image"
        media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)"
        href="/screenshots/apple-launch-828x1792.png">
  <link rel="apple-touch-startup-image"
        media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)"
        href="/screenshots/apple-launch-1125x2436.png">
  <!-- Add more device-specific splash screens -->

  <!-- Styles and scripts -->
  <link rel="stylesheet" href="/styles/main.css">
</head>
<body>
  <div id="app">
    <header>
      <h1>My Progressive Web App</h1>
      <div id="install-prompt" style="display: none;">
        <button id="install-button">Install App</button>
      </div>
    </header>
    <main>
      <!-- App content here -->
    </main>
  </div>

  <script src="/scripts/app.js"></script>
</body>
</html>

💻 PWA Installations-Prompt Verwaltung javascript

🟡 intermediate ⭐⭐⭐

App-Installations-Prompts und Benutzererfahrung verwalten

⏱️ 20 min 🏷️ pwa, installation, user experience, ios
Prerequisites: JavaScript, PWA basics, Event handling
// PWA Install Prompt Management
class PWAInstallManager {
  constructor() {
    this.deferredPrompt = null;
    this.installButton = document.getElementById('install-button');
    this.installPrompt = document.getElementById('install-prompt');

    this.init();
  }

  init() {
    // Listen for beforeinstallprompt event
    window.addEventListener('beforeinstallprompt', (e) => {
      // Prevent the mini-infobar from appearing on mobile
      e.preventDefault();

      // Stash the event so it can be triggered later
      this.deferredPrompt = e;

      // Show install button
      this.showInstallPrompt();

      console.log('beforeinstallprompt event fired');
    });

    // Listen for app installed event
    window.addEventListener('appinstalled', () => {
      // Hide install button
      this.hideInstallPrompt();

      // Clear the deferred prompt
      this.deferredPrompt = null;

      // Show success message
      this.showInstallSuccess();

      console.log('PWA was installed');

      // Analytics: track installation
      this.trackInstallEvent();
    });

    // Check if app is already installed
    if (this.isAppInstalled()) {
      this.hideInstallPrompt();
      this.showAlreadyInstalled();
    }

    // Handle install button click
    if (this.installButton) {
      this.installButton.addEventListener('click', () => {
        this.installPWA();
      });
    }

    // Check for iOS devices (no beforeinstallprompt event)
    if (this.isIOS()) {
      this.showIOSInstallInstructions();
    }

    // Listen for standalone mode changes
    window.addEventListener('appmodechange', () => {
      this.handleAppModeChange();
    });

    // Periodically check installability
    this.checkInstallability();
  }

  showInstallPrompt() {
    if (this.installPrompt) {
      // Don't show if user has dismissed before
      const dismissed = localStorage.getItem('installPromptDismissed');
      if (!dismissed) {
        this.installPrompt.style.display = 'block';
      }
    }
  }

  hideInstallPrompt() {
    if (this.installPrompt) {
      this.installPrompt.style.display = 'none';
    }
  }

  async installPWA() {
    if (!this.deferredPrompt) {
      console.log('No deferred prompt available');
      return;
    }

    try {
      // Show the install prompt
      this.deferredPrompt.prompt();

      // Wait for the user to respond to the prompt
      const { outcome } = await this.deferredPrompt.userChoice;

      console.log('User response to the install prompt:', outcome);

      // We've used the prompt, and can't use it again, throw it away
      this.deferredPrompt = null;

      if (outcome === 'accepted') {
        console.log('User accepted the install prompt');
        // Analytics: track acceptance
        this.trackInstallEvent('accepted');
      } else {
        console.log('User dismissed the install prompt');
        // Analytics: track dismissal
        this.trackInstallEvent('dismissed');
        // Hide for a while
        this.dismissInstallPrompt();
      }
    } catch (error) {
      console.error('Error during PWA installation:', error);
    }
  }

  dismissInstallPrompt() {
    this.hideInstallPrompt();
    localStorage.setItem('installPromptDismissed', 'true');

    // Show again after 7 days
    setTimeout(() => {
      localStorage.removeItem('installPromptDismissed');
      if (this.deferredPrompt) {
        this.showInstallPrompt();
      }
    }, 7 * 24 * 60 * 60 * 1000);
  }

  showInstallSuccess() {
    this.showNotification('App installed successfully!', 'success');
  }

  showAlreadyInstalled() {
    console.log('App is already installed');
  }

  showIOSInstallInstructions() {
    // iOS doesn't support beforeinstallprompt
    // Show custom instructions for Safari
    const userAgent = navigator.userAgent.toLowerCase();
    if (userAgent.includes('safari') && !userAgent.includes('chrome')) {
      const iosPrompt = document.createElement('div');
      iosPrompt.className = 'ios-install-prompt';
      iosPrompt.innerHTML = `
        <div class="ios-install-content">
          <h3>Install this App</h3>
          <p>To install this app on your iOS device:</p>
          <ol>
            <li>Tap the Share button <span class="share-icon">⎋</span> in Safari</li>
            <li>Scroll down and tap "Add to Home Screen"</li>
            <li>Tap "Add" to confirm</li>
          </ol>
          <button class="close-btn">Got it!</button>
        </div>
      `;

      document.body.appendChild(iosPrompt);

      // Show after a delay
      setTimeout(() => {
        iosPrompt.classList.add('show');
      }, 3000);

      // Handle close button
      iosPrompt.querySelector('.close-btn').addEventListener('click', () => {
        iosPrompt.classList.remove('show');
        setTimeout(() => {
          document.body.removeChild(iosPrompt);
        }, 300);
      });

      // Auto-hide after 10 seconds
      setTimeout(() => {
        if (iosPrompt.parentNode) {
          iosPrompt.classList.remove('show');
          setTimeout(() => {
            if (iosPrompt.parentNode) {
              document.body.removeChild(iosPrompt);
            }
          }, 300);
        }
      }, 10000);
    }
  }

  isAppInstalled() {
    // Check if running in standalone mode
    return window.matchMedia('(display-mode: standalone)').matches ||
           window.navigator.standalone === true ||
           document.referrer.includes('android-app://');
  }

  isIOS() {
    return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
  }

  handleAppModeChange() {
    if (this.isAppInstalled()) {
      // App is now running in standalone mode
      document.body.classList.add('standalone-mode');

      // Enable app-specific features
      this.enableStandaloneFeatures();
    } else {
      document.body.classList.remove('standalone-mode');
    }
  }

  enableStandaloneFeatures() {
    // Enable features only available in standalone mode
    console.log('Enabling standalone mode features');

    // Example: Show back button navigation
    this.addBackButton();

    // Example: Customize status bar
    this.customizeStatusBar();
  }

  addBackButton() {
    if (document.referrer && document.referrer !== window.location.href) {
      const backButton = document.createElement('button');
      backButton.className = 'back-button';
      backButton.innerHTML = '←';
      backButton.addEventListener('click', () => {
        window.history.back();
      });

      document.body.insertBefore(backButton, document.body.firstChild);
    }
  }

  customizeStatusBar() {
    // This works on iOS Safari
    const metaTheme = document.querySelector('meta[name="theme-color"]');
    if (metaTheme && this.isIOS()) {
      metaTheme.content = '#000000'; // Dark status bar for iOS
    }
  }

  async checkInstallability() {
    // Check if PWA meets installability criteria
    const isInstallable = await this.isPWAInstallable();

    if (!isInstallable) {
      console.warn('PWA may not be installable due to missing requirements');
      this.showInstallabilityIssues();
    }
  }

  async isPWAInstallable() {
    try {
      // Check if served over HTTPS
      if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
        return false;
      }

      // Check for service worker
      if (!('serviceWorker' in navigator)) {
        return false;
      }

      // Check for manifest
      const manifest = await fetch('/manifest.json').then(r => r.json()).catch(() => null);
      if (!manifest) {
        return false;
      }

      // Check for icons
      if (!manifest.icons || manifest.icons.length === 0) {
        return false;
      }

      // Check for suitable icon size
      const hasLargeIcon = manifest.icons.some(icon =>
        icon.sizes && icon.sizes.split('x').some(size => parseInt(size) >= 192)
      );
      if (!hasLargeIcon) {
        return false;
      }

      // Check if service worker is registered
      const registration = await navigator.serviceWorker.ready;
      if (!registration) {
        return false;
      }

      return true;
    } catch (error) {
      console.error('Error checking installability:', error);
      return false;
    }
  }

  showInstallabilityIssues() {
    const issues = [];

    if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
      issues.push('Serve over HTTPS');
    }

    if (!('serviceWorker' in navigator)) {
      issues.push('Service Worker support');
    }

    // Add more checks as needed

    if (issues.length > 0) {
      console.warn('PWA installability issues:', issues);
    }
  }

  showNotification(message, type = 'info') {
    const notification = document.createElement('div');
    notification.className = `pwa-notification pwa-notification--${type}`;
    notification.textContent = message;

    document.body.appendChild(notification);

    setTimeout(() => {
      notification.classList.add('show');
    }, 100);

    setTimeout(() => {
      notification.classList.remove('show');
      setTimeout(() => {
        if (notification.parentNode) {
          document.body.removeChild(notification);
        }
      }, 300);
    }, 3000);
  }

  trackInstallEvent(action = 'prompt') {
    // Analytics tracking for install events
    if (window.gtag) {
      gtag('event', 'pwa_install', {
        'event_category': 'PWA',
        'event_label': action,
        'value': 1
      });
    }
  }
}

// Usage
document.addEventListener('DOMContentLoaded', () => {
  new PWAInstallManager();
});

💻 Offline-First PWA Architektur javascript

🟡 intermediate ⭐⭐⭐⭐

Robuste Offline-First PWA erstellen

⏱️ 35 min 🏷️ offline-first, pwa, indexeddb, sync, architecture
Prerequisites: JavaScript, IndexedDB, Service Workers, PWA concepts
// Offline-First PWA Architecture
class OfflineFirstPWA {
  constructor() {
    this.db = null;
    this.onlineStatus = navigator.onLine;
    this.pendingOperations = new Map();

    this.init();
  }

  async init() {
    // Initialize IndexedDB
    await this.initDatabase();

    // Setup network status listeners
    this.setupNetworkListeners();

    // Register service worker
    await this.registerServiceWorker();

    // Setup sync manager
    this.setupBackgroundSync();

    // Load cached data
    await this.loadOfflineData();

    console.log('Offline-First PWA initialized');
  }

  async initDatabase() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('offline-pwa-db', 1);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };

      request.onupgradeneeded = (event) => {
        const db = event.target.result;

        // Store for cached API responses
        if (!db.objectStoreNames.contains('api-cache')) {
          db.createObjectStore('api-cache', { keyPath: 'url' });
        }

        // Store for user data
        if (!db.objectStoreNames.contains('user-data')) {
          db.createObjectStore('user-data', { keyPath: 'id', autoIncrement: true });
        }

        // Store for pending operations
        if (!db.objectStoreNames.contains('pending-operations')) {
          db.createObjectStore('pending-operations', { keyPath: 'id', autoIncrement: true });
        }

        // Store for offline files
        if (!db.objectStoreNames.contains('offline-files')) {
          db.createObjectStore('offline-files', { keyPath: 'url' });
        }
      };
    });
  }

  setupNetworkListeners() {
    window.addEventListener('online', () => {
      this.onlineStatus = true;
      this.handleOnlineStatus();
      console.log('App is online');
    });

    window.addEventListener('offline', () => {
      this.onlineStatus = false;
      this.handleOfflineStatus();
      console.log('App is offline');
    });

    // Update UI based on current status
    this.updateOnlineStatusUI();
  }

  handleOnlineStatus() {
    // Show online indicator
    this.showStatus('Connected', 'online');

    // Process pending operations
    this.processPendingOperations();

    // Sync data with server
    this.syncWithServer();

    // Refresh cached data
    this.refreshStaleData();
  }

  handleOfflineStatus() {
    // Show offline indicator
    this.showStatus('Offline - Limited functionality', 'offline');

    // Switch to offline mode
    this.enableOfflineMode();

    // Warn about limitations
    this.warnOfflineLimitations();
  }

  async registerServiceWorker() {
    if ('serviceWorker' in navigator) {
      try {
        const registration = await navigator.serviceWorker.register('/offline-pwa-sw.js');

        console.log('Service Worker registered:', registration);

        // Listen for messages from service worker
        navigator.serviceWorker.addEventListener('message', (event) => {
          this.handleServiceWorkerMessage(event);
        });

        return registration;
      } catch (error) {
        console.error('Service Worker registration failed:', error);
      }
    }
  }

  setupBackgroundSync() {
    if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
      navigator.serviceWorker.ready.then((registration) => {
        registration.sync.register('offline-data-sync');
      });
    }
  }

  async loadOfflineData() {
    try {
      // Load essential user data from IndexedDB
      const userData = await this.getAllUserData();
      this.renderUserData(userData);

      // Load cached API responses
      const cachedData = await this.getCachedData();
      this.renderCachedData(cachedData);

    } catch (error) {
      console.error('Error loading offline data:', error);
    }
  }

  // API methods with offline support
  async apiRequest(url, options = {}) {
    const cacheKey = url + JSON.stringify(options);

    if (!this.onlineStatus) {
      // Offline: return cached data or create pending operation
      const cachedResponse = await this.getCachedResponse(url);

      if (cachedResponse) {
        return cachedResponse;
      }

      if (options.method && options.method !== 'GET') {
        // Create pending operation for non-GET requests
        return this.createPendingOperation(url, options);
      }

      throw new Error('No cached data available');
    }

    try {
      // Online: make actual request
      const response = await fetch(url, options);

      if (response.ok) {
        const data = await response.json();

        // Cache the response
        await this.cacheResponse(url, data);

        return data;
      }

      throw new Error('API request failed');

    } catch (error) {
      console.error('API request failed:', error);

      // Fallback to cached data
      const cachedResponse = await this.getCachedResponse(url);

      if (cachedResponse) {
        this.showStatus('Using cached data', 'warning');
        return cachedResponse;
      }

      throw error;
    }
  }

  async cacheResponse(url, data) {
    const transaction = this.db.transaction(['api-cache'], 'readwrite');
    const store = transaction.objectStore('api-cache');

    const cacheEntry = {
      url: url,
      data: data,
      timestamp: Date.now(),
      ttl: Date.now() + (5 * 60 * 1000) // 5 minutes TTL
    };

    await store.put(cacheEntry);
  }

  async getCachedResponse(url) {
    const transaction = this.db.transaction(['api-cache'], 'readonly');
    const store = transaction.objectStore('api-cache');

    const cacheEntry = await store.get(url);

    if (cacheEntry && Date.now() < cacheEntry.ttl) {
      return cacheEntry.data;
    }

    return null;
  }

  async createPendingOperation(url, options) {
    const operation = {
      id: Date.now().toString(),
      url: url,
      options: options,
      timestamp: Date.now(),
      retries: 0
    };

    const transaction = this.db.transaction(['pending-operations'], 'readwrite');
    const store = transaction.objectStore('pending-operations');

    await store.add(operation);

    this.showStatus('Operation queued for sync', 'info');

    return { success: true, message: 'Operation queued for sync', operationId: operation.id };
  }

  async processPendingOperations() {
    const transaction = this.db.transaction(['pending-operations'], 'readonly');
    const store = transaction.objectStore('pending-operations');

    const operations = await store.getAll();

    for (const operation of operations) {
      try {
        await this.retryOperation(operation);
        await this.removePendingOperation(operation.id);
      } catch (error) {
        console.error('Failed to process operation:', operation.id, error);
      }
    }
  }

  async retryOperation(operation) {
    const maxRetries = 3;

    if (operation.retries >= maxRetries) {
      throw new Error('Max retries exceeded');
    }

    try {
      const response = await fetch(operation.url, operation.options);

      if (!response.ok) {
        throw new Error('Request failed');
      }

      // Operation successful
      console.log('Operation synced successfully:', operation.id);

    } catch (error) {
      // Increment retry count
      operation.retries++;

      // Update operation in database
      const transaction = this.db.transaction(['pending-operations'], 'readwrite');
      const store = transaction.objectStore('pending-operations');
      await store.put(operation);

      throw error;
    }
  }

  async removePendingOperation(id) {
    const transaction = this.db.transaction(['pending-operations'], 'readwrite');
    const store = transaction.objectStore('pending-operations');
    await store.delete(id);
  }

  // File handling for offline support
  async cacheFile(url, file) {
    const fileData = {
      url: url,
      file: file,
      timestamp: Date.now()
    };

    const transaction = this.db.transaction(['offline-files'], 'readwrite');
    const store = transaction.objectStore('offline-files');

    await store.put(fileData);
  }

  async getCachedFile(url) {
    const transaction = this.db.transaction(['offline-files'], 'readonly');
    const store = transaction.objectStore('pending-operations');

    const fileData = await store.get(url);

    return fileData ? fileData.file : null;
  }

  // UI Methods
  updateOnlineStatusUI() {
    const statusElement = document.getElementById('online-status');
    if (statusElement) {
      statusElement.className = this.onlineStatus ? 'online' : 'offline';
      statusElement.textContent = this.onlineStatus ? 'Online' : 'Offline';
    }
  }

  showStatus(message, type = 'info') {
    const statusElement = document.getElementById('status-message');
    if (statusElement) {
      statusElement.textContent = message;
      statusElement.className = `status status--${type}`;

      setTimeout(() => {
        statusElement.textContent = '';
        statusElement.className = 'status';
      }, 3000);
    }
  }

  showNotification(title, options = {}) {
    if ('Notification' in window && Notification.permission === 'granted') {
      new Notification(title, {
        body: options.body || '',
        icon: '/icons/icon-192x192.png',
        badge: '/icons/badge-72x72.png',
        tag: options.tag || 'default',
        requireInteraction: options.requireInteraction || false,
        actions: options.actions || []
      });
    }
  }

  enableOfflineMode() {
    // Enable offline-specific features
    document.body.classList.add('offline-mode');

    // Disable online-only features
    const onlineOnlyElements = document.querySelectorAll('[data-online-only]');
    onlineOnlyElements.forEach(element => {
      element.style.display = 'none';
    });

    // Show offline indicators
    const offlineIndicators = document.querySelectorAll('[data-offline-indicator]');
    offlineIndicators.forEach(element => {
      element.style.display = 'block';
    });
  }

  warnOfflineLimitations() {
    this.showNotification('You are currently offline', {
      body: 'Some features may be unavailable until you reconnect.',
      requireInteraction: false,
      tag: 'offline-warning'
    });
  }

  // Data synchronization
  async syncWithServer() {
    try {
      // Get local changes
      const localChanges = await this.getLocalChanges();

      // Sync with server
      const syncResult = await this.sendChangesToServer(localChanges);

      if (syncResult.success) {
        // Clear local changes after successful sync
        await this.clearLocalChanges();
        this.showStatus('Data synchronized successfully', 'success');
      }

    } catch (error) {
      console.error('Sync failed:', error);
      this.showStatus('Sync failed, will retry later', 'warning');
    }
  }

  async refreshStaleData() {
    // Refresh data that might be stale
    const staleData = await this.getStaleCachedData();

    for (const item of staleData) {
      try {
        await this.apiRequest(item.url, { method: 'GET' });
      } catch (error) {
        console.error('Failed to refresh stale data:', item.url);
      }
    }
  }

  handleServiceWorkerMessage(event) {
    const { type, data } = event.data;

    switch (type) {
      case 'cache-updated':
        this.handleCacheUpdated(data);
        break;
      case 'sync-completed':
        this.handleSyncCompleted(data);
        break;
      case 'background-sync-failed':
        this.handleBackgroundSyncFailed(data);
        break;
      default:
        console.log('Unknown message from service worker:', type, data);
    }
  }

  handleCacheUpdated(data) {
    console.log('Cache updated:', data);
    this.showStatus('Content updated', 'success');
  }

  handleSyncCompleted(data) {
    console.log('Background sync completed:', data);
    this.showNotification('Sync completed', {
      body: 'Your data has been synchronized with the server.'
    });
  }

  handleBackgroundSyncFailed(data) {
    console.error('Background sync failed:', data);
    this.showStatus('Background sync failed', 'error');
  }
}

// Initialize the PWA
document.addEventListener('DOMContentLoaded', () => {
  window.pwaApp = new OfflineFirstPWA();
});

// Service Worker file (offline-pwa-sw.js)
const OFFLINE_CACHE = 'offline-cache-v1';
const STATIC_CACHE = 'static-cache-v1';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(OFFLINE_CACHE)
      .then((cache) => cache.addAll([
        '/',
        '/offline.html',
        '/manifest.json',
        '/styles/main.css',
        '/scripts/app.js'
      ]))
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        return response || fetch(event.request);
      })
      .catch(() => {
        // Return offline page for navigation requests
        if (event.request.mode === 'navigate') {
          return caches.match('/offline.html');
        }
      })
  );
});

self.addEventListener('sync', (event) => {
  if (event.tag === 'offline-data-sync') {
    event.waitUntil(
      // Process offline data sync
      processOfflineDataSync()
    );
  }
});

async function processOfflineDataSync() {
  // Implementation for background sync
  console.log('Processing offline data sync');
}

💻 PWA App Shell Architektur javascript

🔴 complex ⭐⭐⭐⭐⭐

App Shell Architektur für sofortiges Laden und zuverlässige Navigation implementieren

⏱️ 40 min 🏷️ app shell, pwa, architecture, routing, performance
Prerequisites: JavaScript, PWA concepts, Web Components, Service Workers
// PWA App Shell Architecture
class PWAShell {
  constructor() {
    this.currentRoute = null;
    this.shellElement = null;
    this.contentElement = null;
    this.routes = new Map();
    this.loadingIndicator = null;

    this.init();
  }

  init() {
    // Cache DOM elements
    this.shellElement = document.getElementById('app-shell');
    this.contentElement = document.getElementById('app-content');
    this.loadingIndicator = document.getElementById('loading-indicator');

    // Setup routing
    this.setupRouting();

    // Setup navigation
    this.setupNavigation();

    // Setup service worker communication
    this.setupServiceWorkerComms();

    // Handle initial load
    this.handleInitialLoad();

    // Setup prefetching
    this.setupPrefetching();

    // Setup back/forward navigation
    this.setupHistoryNavigation();

    console.log('PWA App Shell initialized');
  }

  setupRouting() {
    // Define app routes
    this.routes.set('/', {
      component: 'HomePage',
      prefetch: true,
      cache: true,
      preload: ['/api/user-data', '/api/recent-items']
    });

    this.routes.set('/dashboard', {
      component: 'DashboardPage',
      prefetch: true,
      cache: true,
      preload: ['/api/dashboard-stats', '/api/notifications']
    });

    this.routes.set('/profile', {
      component: 'ProfilePage',
      prefetch: false,
      cache: true,
      preload: ['/api/user-profile']
    });

    this.routes.set('/settings', {
      component: 'SettingsPage',
      prefetch: false,
      cache: true,
      preload: ['/api/settings']
    });

    this.routes.set('/offline', {
      component: 'OfflinePage',
      prefetch: false,
      cache: true
    });

    // Listen for route changes
    window.addEventListener('popstate', (event) => {
      this.navigate(window.location.pathname, false);
    });

    // Intercept link clicks
    document.addEventListener('click', (event) => {
      const link = event.target.closest('a');
      if (link && this.shouldInterceptLink(link)) {
        event.preventDefault();
        this.navigate(link.pathname);
      }
    });
  }

  shouldInterceptLink(link) {
    const href = link.getAttribute('href');

    // Only intercept internal links
    if (!href || href.startsWith('http') || href.startsWith('#')) {
      return false;
    }

    // Don't intercept if modifier keys are pressed
    if (event.ctrlKey || event.metaKey || event.shiftKey) {
      return false;
    }

    // Don't intercept if target is specified
    if (link.target) {
      return false;
    }

    return true;
  }

  async navigate(path, addToHistory = true) {
    // Don't navigate if already on this route
    if (this.currentRoute === path) {
      return;
    }

    try {
      this.showLoading(true);

      // Find route configuration
      const route = this.routes.get(path);

      if (!route) {
        // Try to find dynamic route
        const dynamicRoute = this.findDynamicRoute(path);
        if (dynamicRoute) {
          await this.loadRoute(dynamicRoute, addToHistory, path);
        } else {
          // Fallback to 404 or offline page
          await this.load404Page();
        }
        return;
      }

      await this.loadRoute(route, addToHistory, path);

    } catch (error) {
      console.error('Navigation error:', error);
      await this.handleNavigationError(path);
    } finally {
      this.showLoading(false);
    }
  }

  async loadRoute(route, addToHistory, path) {
    // Update URL
    if (addToHistory) {
      history.pushState({ path: path }, '', path);
    }

    // Preload necessary data
    if (route.preload) {
      await this.preloadData(route.preload);
    }

    // Load and render component
    const component = await this.loadComponent(route.component);
    await this.renderComponent(component);

    // Cache route content
    if (route.cache) {
      await this.cacheRouteContent(path, component);
    }

    // Update current route
    this.currentRoute = path;

    // Update navigation state
    this.updateNavigationState(path);

    // Trigger route change event
    this.dispatchRouteChangeEvent(path);

    // Track page view for analytics
    this.trackPageView(path);
  }

  async loadComponent(componentName) {
    try {
      // Check if component is already loaded
      if (window[componentName]) {
        return window[componentName];
      }

      // Load component script
      const module = await import(`/components/${componentName}.js`);
      window[componentName] = module.default;

      return module.default;

    } catch (error) {
      console.error('Failed to load component:', componentName, error);

      // Fallback to simple content
      return {
        render: () => '<div class="error">Failed to load page content</div>'
      };
    }
  }

  async renderComponent(component) {
    if (typeof component.render === 'function') {
      const html = await component.render();

      // Animate content change
      await this.animateContentChange(() => {
        this.contentElement.innerHTML = html;

        // Initialize component if needed
        if (component.init) {
          component.init();
        }
      });

    } else {
      throw new Error('Component does not have a render method');
    }
  }

  async animateContentChange(callback) {
    // Fade out current content
    this.contentElement.classList.add('fade-out');

    // Wait for animation
    await new Promise(resolve => {
      setTimeout(resolve, 200);
    });

    // Update content
    callback();

    // Fade in new content
    this.contentElement.classList.remove('fade-out');
    this.contentElement.classList.add('fade-in');

    // Wait for animation
    await new Promise(resolve => {
      setTimeout(resolve => 200);
    });

    this.contentElement.classList.remove('fade-in');
  }

  async preloadData(urls) {
    const promises = urls.map(url => this.preloadDataUrl(url));

    try {
      await Promise.allSettled(promises);
    } catch (error) {
      console.error('Error preloading data:', error);
    }
  }

  async preloadDataUrl(url) {
    try {
      // Check cache first
      const cachedResponse = await this.getCachedData(url);
      if (cachedResponse) {
        return cachedResponse;
      }

      // Fetch and cache
      const response = await fetch(url);
      const data = await response.json();

      await this.cacheData(url, data);

      return data;

    } catch (error) {
      console.error('Error preloading data URL:', url, error);
    }
  }

  async cacheData(url, data) {
    try {
      const cache = await caches.open('api-data-cache-v1');
      const response = new Response(JSON.stringify(data), {
        headers: { 'Content-Type': 'application/json' }
      });

      await cache.put(url, response);

    } catch (error) {
      console.error('Error caching data:', error);
    }
  }

  async getCachedData(url) {
    try {
      const cache = await caches.open('api-data-cache-v1');
      const response = await cache.match(url);

      if (response) {
        return await response.json();
      }

      return null;

    } catch (error) {
      console.error('Error getting cached data:', error);
      return null;
    }
  }

  setupNavigation() {
    const navItems = document.querySelectorAll('[data-route]');

    navItems.forEach(item => {
      const route = item.getAttribute('data-route');

      // Set initial state
      if (window.location.pathname === route) {
        item.classList.add('active');
      }

      // Add click handler
      item.addEventListener('click', (event) => {
        event.preventDefault();
        this.navigate(route);
      });
    });

    // Setup mobile navigation
    this.setupMobileNavigation();
  }

  setupMobileNavigation() {
    const menuToggle = document.getElementById('menu-toggle');
    const mobileNav = document.getElementById('mobile-navigation');

    if (menuToggle && mobileNav) {
      menuToggle.addEventListener('click', () => {
        mobileNav.classList.toggle('open');
        document.body.classList.toggle('nav-open');
      });

      // Close nav when clicking outside
      document.addEventListener('click', (event) => {
        if (!mobileNav.contains(event.target) && !menuToggle.contains(event.target)) {
          mobileNav.classList.remove('open');
          document.body.classList.remove('nav-open');
        }
      });
    }
  }

  updateNavigationState(path) {
    // Update active navigation items
    const navItems = document.querySelectorAll('[data-route]');

    navItems.forEach(item => {
      const itemRoute = item.getAttribute('data-route');

      if (path === itemRoute) {
        item.classList.add('active');
      } else {
        item.classList.remove('active');
      }
    });

    // Update page title
    const route = this.routes.get(path) || this.findDynamicRoute(path);
    if (route && route.title) {
      document.title = route.title;
    }

    // Update meta description
    if (route && route.description) {
      const metaDescription = document.querySelector('meta[name="description"]');
      if (metaDescription) {
        metaDescription.content = route.description;
      }
    }
  }

  setupPrefetching() {
    // Prefetch resources on hover
    document.addEventListener('mouseover', (event) => {
      const link = event.target.closest('a[data-route]');

      if (link) {
        const route = link.getAttribute('data-route');
        const routeConfig = this.routes.get(route);

        if (routeConfig && routeConfig.prefetch && !this.isRoutePrefetched(route)) {
          this.prefetchRoute(routeConfig);
          this.markRouteAsPrefetched(route);
        }
      }
    });

    // Prefetch resources on page load for critical routes
    this.prefetchCriticalRoutes();
  }

  async prefetchRoute(route) {
    try {
      // Prefetch component
      await import(`/components/${route.component}.js`);

      // Prefetch data
      if (route.preload) {
        await this.preloadData(route.preload);
      }

    } catch (error) {
      console.error('Error prefetching route:', error);
    }
  }

  prefetchCriticalRoutes() {
    // Prefetch dashboard and other critical routes after initial load
    setTimeout(() => {
      this.routes.forEach((route, path) => {
        if (route.prefetch && path !== this.currentRoute) {
          this.prefetchRoute(route);
        }
      });
    }, 2000);
  }

  isRoutePrefetched(route) {
    return localStorage.getItem('prefetched-' + route) === 'true';
  }

  markRouteAsPrefetched(route) {
    localStorage.setItem('prefetched-' + route, 'true');
  }

  setupHistoryNavigation() {
    // Handle browser back/forward buttons
    window.addEventListener('popstate', (event) => {
      if (event.state && event.state.path) {
        this.navigate(event.state.path, false);
      }
    });

    // Save initial state
    history.replaceState({ path: window.location.pathname }, '');
  }

  handleInitialLoad() {
    // Handle initial page load
    this.currentRoute = window.location.pathname;
    this.updateNavigationState(this.currentRoute);

    // Show content if it exists
    if (this.contentElement.children.length === 0) {
      this.navigate(this.currentRoute, false);
    }
  }

  async cacheRouteContent(path, component) {
    try {
      // Cache component for offline use
      const content = component.render ? component.render() : '';

      const cache = await caches.open('route-cache-v1');
      const response = new Response(content, {
        headers: { 'Content-Type': 'text/html' }
      });

      await cache.put(path, response);

    } catch (error) {
      console.error('Error caching route content:', error);
    }
  }

  findDynamicRoute(path) {
    // Simple dynamic route matching
    for (const [routePath, config] of this.routes.entries()) {
      if (routePath.includes(':')) {
        const pattern = new RegExp('^' + routePath.replace(/:[^/]+/g, '[^/]+') + '$');
        if (pattern.test(path)) {
          return config;
        }
      }
    }
    return null;
  }

  async load404Page() {
    const component = await this.loadComponent('Error404Page');
    component.path = window.location.pathname;
    await this.renderComponent(component);
  }

  async handleNavigationError(path) {
    console.error('Navigation failed for path:', path);

    // Try to load offline page
    try {
      const component = await this.loadComponent('OfflinePage');
      await this.renderComponent(component);
    } catch (error) {
      // Ultimate fallback
      this.contentElement.innerHTML = `
        <div class="error-page">
          <h1>Navigation Error</h1>
          <p>Unable to load the requested page.</p>
          <button onclick="window.location.reload()">Try Again</button>
        </div>
      `;
    }
  }

  setupServiceWorkerComms() {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.addEventListener('message', (event) => {
        const { type, data } = event.data;

        switch (type) {
          case 'cache-updated':
            this.handleCacheUpdate(data);
            break;
          case 'route-available':
            this.handleRouteAvailable(data);
            break;
        }
      });
    }
  }

  handleCacheUpdate(data) {
    // Show update notification if current route was updated
    if (data.url === window.location.pathname) {
      this.showUpdateNotification();
    }
  }

  handleRouteAvailable(data) {
    // Prefetch newly available route
    if (this.routes.has(data.path)) {
      this.prefetchRoute(this.routes.get(data.path));
    }
  }

  showUpdateNotification() {
    const notification = document.createElement('div');
    notification.className = 'update-notification';
    notification.innerHTML = `
      <span>Content updated</span>
      <button onclick="window.location.reload()">Refresh</button>
    `;

    document.body.appendChild(notification);

    setTimeout(() => {
      notification.classList.add('show');
    }, 100);

    setTimeout(() => {
      notification.classList.remove('show');
      setTimeout(() => {
        if (notification.parentNode) {
          document.body.removeChild(notification);
        }
      }, 300);
    }, 5000);
  }

  showLoading(show) {
    if (this.loadingIndicator) {
      this.loadingIndicator.style.display = show ? 'block' : 'none';
    }
  }

  dispatchRouteChangeEvent(path) {
    const event = new CustomEvent('routechange', {
      detail: { path: path }
    });
    window.dispatchEvent(event);
  }

  trackPageView(path) {
    // Analytics tracking
    if (window.gtag) {
      gtag('config', 'GA_MEASUREMENT_ID', {
        page_path: path
      });
    }

    // Track route change for debugging
    console.log('Page view:', path);
  }
}

// Component examples
// HomePage.js
export default {
  render: async () => `
    <div class="home-page">
      <h1>Welcome to My PWA</h1>
      <p>This is a Progressive Web Application with offline capabilities.</p>
      <nav class="quick-actions">
        <a href="/dashboard" data-route="/dashboard">Dashboard</a>
        <a href="/profile" data-route="/profile">Profile</a>
      </nav>
    </div>
  `,

  init: function() {
    console.log('Home page initialized');
  }
};

// DashboardPage.js
export default {
  render: async () => {
    const userData = await window.pwaApp.apiRequest('/api/user-data');

    return `
      <div class="dashboard-page">
        <h1>Dashboard</h1>
        <div class="stats">
          <div class="stat">
            <span class="stat-value">${userData.visits || 0}</span>
            <span class="stat-label">Visits</span>
          </div>
          <div class="stat">
            <span class="stat-value">${userData.tasks || 0}</span>
            <span class="stat-label">Tasks</span>
          </div>
        </div>
      </div>
    `;
  },

  init: function() {
    this.refreshInterval = setInterval(() => {
      window.pwaApp.apiRequest('/api/refresh-stats');
    }, 30000);
  },

  cleanup: function() {
    if (this.refreshInterval) {
      clearInterval(this.refreshInterval);
    }
  }
};

// Initialize the app shell
document.addEventListener('DOMContentLoaded', () => {
  window.appShell = new PWAShell();
});