渐进式Web应用 (PWA)
构建现代、可靠、引人入胜的渐进式Web应用,具备离线功能
💻 PWA 清单和安装 json
🟢 simple
⭐⭐
创建PWA清单并启用应用安装
⏱️ 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 安装提示管理 javascript
🟡 intermediate
⭐⭐⭐
处理应用安装提示和用户体验
⏱️ 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();
});
💻 离线优先PWA架构 javascript
🟡 intermediate
⭐⭐⭐⭐
构建强大的离线优先渐进式Web应用
⏱️ 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 应用外壳架构 javascript
🔴 complex
⭐⭐⭐⭐⭐
实施应用外壳架构以实现即时加载和可靠导航
⏱️ 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();
});