一、PWA技术全景
本教程将基于原生HTML5技术构建一个完整的渐进式Web应用(PWA),实现原生应用般的用户体验。
核心技术:
- Service Worker离线缓存
- Web App Manifest
- 客户端存储方案
- 推送通知API
- 性能优化策略
实战案例:
- 离线优先的天气应用
- 后台同步数据
- 添加到主屏幕
- 网络状态感知
- 资源预缓存
二、项目基础架构
1. 项目目录结构
pwa-weather-app/
├── index.html # 应用入口
├── manifest.json # Web应用清单
├── service-worker.js # Service Worker脚本
├── assets/
│ ├── css/app.css # 样式文件
│ ├── js/app.js # 主JavaScript文件
│ └── images/ # 应用图标等资源
└── data/ # 本地数据存储
2. 基础HTML结构
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>天气PWA应用</title>
<meta name="theme-color" content="#2196F3">
<meta name="description" content="一个离线可用的天气应用">
<link rel="manifest" href="/manifest.json" rel="external nofollow" >
<link rel="icon" href="/assets/images/icon-192.png" rel="external nofollow" >
<link rel="stylesheet" href="/assets/css/app.css" rel="external nofollow" rel="external nofollow" >
</head>
<body>
<div class="app-container">
<header class="app-header">
<h1>天气PWA</h1>
<button id="refresh-btn" aria-label="刷新数据"></button>
</header>
<main class="weather-content">
<div class="current-weather">
<!-- 动态内容将通过JavaScript填充 -->
</div>
</main>
<div class="offline-status" hidden>
您当前处于离线状态,显示的是缓存数据
</div>
</div>
<script src="/assets/js/app.js" type="module"></script>
</body>
</html>
三、核心功能实现
1. Web应用清单(manifest.json)
{
"name": "天气PWA",
"short_name": "天气",
"description": "一个离线可用的天气应用",
"start_url": "/",
"display": "standalone",
"theme_color": "#2196F3",
"background_color": "#ffffff",
"icons": [
{
"src": "/assets/images/icon-72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/assets/images/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/images/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"related_applications": [],
"prefer_related_applications": false
}
2. Service Worker注册
// assets/js/app.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js');
console.log('ServiceWorker注册成功:', registration.scope);
// 检查更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// 新版本可用
showUpdateNotification();
}
}
});
});
} catch (error) {
console.error('ServiceWorker注册失败:', error);
}
});
}
function showUpdateNotification() {
const notification = document.createElement('div');
notification.className = 'update-notification';
notification.innerHTML = `
<p>新版本可用,点击刷新以更新应用</p>
<button id="refresh-app">刷新</button>
`;
document.body.appendChild(notification);
document.getElementById('refresh-app').addEventListener('click', () => {
window.location.reload();
});
}
四、Service Worker实现
1. 缓存策略实现
// service-worker.js
const CACHE_NAME = 'weather-app-v1';
const OFFLINE_URL = '/offline.html';
const PRECACHE_URLS = [
'/',
'/index.html',
'/assets/css/app.css',
'/assets/js/app.js',
'/assets/images/icon-192.png',
OFFLINE_URL
];
// 安装阶段 - 预缓存关键资源
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
// 激活阶段 - 清理旧缓存
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
// 请求拦截 - 缓存优先策略
self.addEventListener('fetch', event => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.catch(() => caches.match(OFFLINE_URL))
);
} else {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
}
});
2. 后台数据同步
// 注册后台同步
async function registerBackgroundSync() {
if ('SyncManager' in window) {
try {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-weather-data');
console.log('后台同步已注册');
} catch (error) {
console.error('后台同步注册失败:', error);
}
}
}
// 在Service Worker中处理同步事件
self.addEventListener('sync', event => {
if (event.tag === 'sync-weather-data') {
event.waitUntil(
syncWeatherData()
.then(() => showNotification('数据已同步'))
.catch(error => console.error('同步失败:', error))
);
}
});
async function syncWeatherData() {
const cache = await caches.open('weather-data');
const response = await fetch('https://api.weather.com/data');
await cache.put('weather-data', response.clone());
return response;
}
五、客户端存储方案
1. IndexedDB数据存储
// assets/js/db.js
const DB_NAME = 'WeatherDB';
const DB_VERSION = 1;
const STORE_NAME = 'locations';
let db;
export function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('name', 'name', { unique: false });
}
};
});
}
export function addLocation(location) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.add(location);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
export function getLocations() {
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
2. 网络状态检测
// assets/js/network.js
export function initNetworkStatus() {
const statusElement = document.querySelector('.offline-status');
function updateNetworkStatus() {
if (navigator.onLine) {
statusElement.hidden = true;
} else {
statusElement.hidden = false;
}
}
// 初始检测
updateNetworkStatus();
// 监听网络变化
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);
// 返回当前状态
return {
isOnline: () => navigator.onLine,
addListener: (callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
}
};
}
六、推送通知实现
1. 通知权限请求
// assets/js/notifications.js
export async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('当前浏览器不支持通知');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
}
export function showNotification(title, options = {}) {
if (!('Notification' in window) || Notification.permission !== 'granted') {
return;
}
const notification = new Notification(title, {
icon: '/assets/images/icon-192.png',
badge: '/assets/images/icon-72.png',
...options
});
notification.onclick = () => {
window.focus();
notification.close();
};
return notification;
}
2. 推送事件处理
// 在Service Worker中
self.addEventListener('push', event => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/assets/images/icon-192.png',
badge: '/assets/images/icon-72.png',
data: {
url: data.url
}
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
clients.matchAll({
type: 'window'
}).then(clientList => {
if (clientList.length > 0) {
return clientList[0].focus();
}
return clients.openWindow(event.notification.data.url);
})
);
});
七、性能优化策略
1. 资源预加载
<!-- 在HTML头部预加载关键资源 -->
<link rel="preload" href="/assets/css/app.css" rel="external nofollow" rel="external nofollow" as="style">
<link rel="preload" href="/assets/js/app.js" rel="external nofollow" as="script">
<link rel="preload" href="/assets/images/weather-icons.png" rel="external nofollow" as="image">
<!-- 预连接API端点 -->
<link rel="preconnect" href="https://api.weather.com" rel="external nofollow" rel="external nofollow" >
<link rel="dns-prefetch" href="https://api.weather.com" rel="external nofollow" rel="external nofollow" >
// 动态加载非关键资源
function loadLazyResources() {
const lazyScripts = [
'/assets/js/analytics.js',
'/assets/js/map.js'
];
lazyScripts.forEach(src => {
const script = document.createElement('script');
script.src = src;
document.body.appendChild(script);
});
}
// 在页面主要内容加载完成后执行
window.addEventListener('DOMContentLoaded', loadLazyResources);
2. 性能监测
// 使用Performance API监测关键指标
function measurePerformance() {
window.addEventListener('load', () => {
setTimeout(() => {
const timing = performance.timing;
const metrics = {
dns: timing.domainLookupEnd - timing.domainLookupStart,
tcp: timing.connectEnd - timing.connectStart,
ttfb: timing.responseStart - timing.requestStart,
pageLoad: timing.loadEventEnd - timing.navigationStart,
domReady: timing.domComplete - timing.domLoading
};
console.log('性能指标:', metrics);
// 发送到分析服务器
if ('sendBeacon' in navigator) {
navigator.sendBeacon('/analytics', JSON.stringify(metrics));
}
}, 0);
});
}
// 使用Web Vitals监测核心用户体验指标
import {getCLS, getFID, getLCP} from 'web-vitals';
function reportWebVitals() {
getCLS(console.log);
getFID(console.log);
getLCP(console.log);
// 或者发送到分析服务器
function sendToAnalytics(metric) {
const body = JSON.stringify(metric);
navigator.sendBeacon('/analytics', body);
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);
}
八、总结与扩展
本教程构建了一个完整的PWA应用:
- 实现了离线优先策略
- 开发了Service Worker缓存
- 配置了Web应用清单
- 添加了推送通知
- 优化了性能表现
扩展方向:
- Web组件集成
- Web Share API
- 支付请求API
- Web Assembly优化