Elysia Tools

在一个清晰的工作台里浏览工具、案例、可视化和主题指南。

热门分类

专题

语言
ZHDERUPTENFRES

Elysia Tools

导航

Web 技术

渐进式Web应用 (PWA)

构建现代、可靠、引人入胜的渐进式Web应用,具备离线功能

案例

这个案例集合中的条目

代码json简单

PWA 清单和安装

创建PWA清单并启用应用安装

难度

3/10

预计时间

15 min

标签

pwa, manifest, installation, app shell

前置条件

HTML, Web development basics

json
{
  "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>
代码javascript中等

PWA 安装提示管理

处理应用安装提示和用户体验

难度

6/10

预计时间

20 min

标签

pwa, installation, user experience, ios

前置条件

JavaScript, PWA basics, Event handling

javascript
// 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();
});
代码javascript中等

离线优先PWA架构

构建强大的离线优先渐进式Web应用

难度

8/10

预计时间

35 min

标签

offline-first, pwa, indexeddb, sync, architecture

前置条件

JavaScript, IndexedDB, Service Workers, PWA concepts

javascript
// 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 &&
        typeof ServiceWorkerRegistration !== 'undefined' &&
        'sync' in 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');
}
代码javascript复杂

PWA 应用外壳架构

实施应用外壳架构以实现即时加载和可靠导航

难度

9/10

预计时间

40 min

标签

app shell, pwa, architecture, routing, performance

前置条件

JavaScript, PWA concepts, Web Components, Service Workers

javascript
// 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();
});

工具

经常与该案例搭配使用的工具

相关内容

相关案例集合

相关内容

相关专题

专题

SVG Favicon 与图标转换工作流

用 SVG、PNG、ICO、JPG 和 WebP 来源准备 favicon、应用图标和网页图标,并完成转换与优化检查。

专题

获授权 Webhook 捕获与接收端信任检查

在获授权的测试环境捕获并回放受控 Webhook 请求,分别检查投递证据和接收端 HTTPS 证书,不把证书有效误写成业务信任证明。

专题

应用字段策略校验

定义并测试由产品负责的密码与颜色代码规则,将应用自己的允许列表和范围判断与通用校验结果分开。

专题

用日志、链路、Webhook 和 JSON 载荷做可观测性调试

解析日志,解码调用链,检查 Webhook/API 证据,探索 JSON 载荷,并提取重复模式,形成可用于事故复盘的调试结论。

首页
工具3212
工作台
案例593
可视化471
文件查看器103
专题239
标签333
图像、音频与视频657

图像、音频、视频处理、转换与优化工具

数学、日期与金融647

计算器、数值计算、日期逻辑、统计与金融工具

设计与颜色298

颜色、版式、图形、视觉样式与设计辅助工具

文本与写作209

写作、清洗、格式化、提取与文本分析工具

开发与 Web193

开发者工具、网络处理、Web 调试与自动化辅助工具

转换与编码175

格式、文件、压缩包、单位与编码转换工具

潜客测验发布与联系人交接
讣告、悼词与纪念卡的家属复核及交付
本地习惯记录的备份、恢复演练与分享前检查
长文改编 X 串文与 LinkedIn 帖子的事实复核与交付
Markdown 互动课件审校、离线演示与交付

© 2026 Elysia Tools. 保留所有权利。

文档价格关于我们服务条款隐私政策Cookie 政策联系我们

Media

AVI转MP4转换器

将AVI视频文件转换为MP4格式

Media

媒体元数据转 JSON

使用 ffprobe 将音视频元数据导出为 JSON。

Media

MP4转AVI转换器

将MP4视频文件转换为AVI格式

Media

批量视频转换器

批量转换多个视频文件格式,支持 MP4、WebM、MKV、AVI、MOV、FLV,支持质量设置和批量处理

Media

视频剪辑器

精确剪辑视频片段,支持多种格式和质量设置

Media

视频转APNG

将视频片段转换为APNG(动画PNG)格式,支持透明度且质量优于GIF

Media

视频转GIF优化版

将视频转换为优化后的GIF格式,支持高级压缩设置

Media

视频转图片序列

将视频帧提取为图片序列,支持自定义帧率、质量和输出格式

消息

Apache Pulsar 示例

Apache Pulsar 示例,包括发布订阅、读取器、不同订阅类型的消费者、模式管理和分层存储,适用于企业级消息传递

媒体

二维码示例

用于测试二维码读取和生成工具的示例二维码图像

C++

Windows C++ 序列化示例

Windows C++ 数据序列化示例,包括JSON序列化/反序列化和XML解析

Web 技术

WebRTC 实时通信示例

全面的 WebRTC 示例,用于点对点音视频通信、数据通道、屏幕共享和信令服务器实现

媒体

SVG示例

可缩放矢量图形(SVG)示例,展示各种SVG功能和技术

测试

BDD with Cucumber - 行为驱动开发实践

全面的 Cucumber BDD 示例,包括功能文件、步骤定义、数据表格、钩子和高级 BDD 模式,用于协作开发