渐进式Web应用(PWA)详解
渐进式Web应用(Progressive Web App)是一种使用现代Web技术构建的应用程序,提供类似原生应用的用户体验。PWA结合了Web和原生应用的最佳特性,可以在任何设备上运行,并提供离线功能、推送通知和安装体验。
核心价值
PWA = Web技术 + 原生体验 + 渐进增强 + 跨平台
- 🌐 Web技术基础:使用HTML、CSS、JavaScript构建
- 📱 原生应用体验:全屏显示、启动画面、应用图标
- 🔄 离线功能:Service Workers实现离线缓存和同步
- 🔔 推送通知:实时消息推送,提升用户参与度
- 📦 可安装性:可添加到主屏幕,无需应用商店
- ⚡ 渐进增强:在支持的浏览器上提供更好的体验
1. PWA核心概念
1.1 PWA特性概览
PWA具有以下核心特性,使其能够提供接近原生应用的体验:
1.2 PWA技术栈
| 技术组件 | 作用 | 必需性 | 浏览器支持 |
|---|---|---|---|
| Web应用清单 | 定义应用元数据和安装行为 | 必需 | 广泛支持 |
| Service Workers | 离线缓存、后台同步、推送通知 | 必需 | 现代浏览器 |
| HTTPS | 安全上下文要求 | 必需 | 所有浏览器 |
| 响应式设计 | 适配不同设备 | 推荐 | 所有浏览器 |
| App Shell架构 | 快速加载的应用外壳 | 推荐 | 所有浏览器 |
2. Service Workers详解
2.1 Service Workers基础
Service Workers是PWA的核心技术,运行在后台的JavaScript脚本,充当Web应用和网络之间的代理。
- 注册与生命周期
- 缓存策略
- 后台同步
Service Worker注册与生命周期
Service Worker注册
javascript
1// main.js - 主应用文件2if ('serviceWorker' in navigator) {3 window.addEventListener('load', async () => {4 try {5 const registration = await navigator.serviceWorker.register('/sw.js', {6 scope: '/' // Service Worker的作用域7 });8 9 console.log('Service Worker注册成功:', registration.scope);10 11 // 监听Service Worker状态变化12 registration.addEventListener('updatefound', () => {13 const newWorker = registration.installing;14 console.log('发现新的Service Worker');15 16 newWorker.addEventListener('statechange', () => {17 console.log('Service Worker状态:', newWorker.state);18 19 if (newWorker.state === 'installed') {20 if (navigator.serviceWorker.controller) {21 // 有新版本可用22 showUpdateAvailableNotification();23 } else {24 // 首次安装完成25 showCachedNotification();26 }27 }28 });29 });30 31 // 监听Service Worker控制器变化32 navigator.serviceWorker.addEventListener('controllerchange', () => {33 console.log('Service Worker控制器已更新');34 window.location.reload();35 });36 37 } catch (error) {38 console.error('Service Worker注册失败:', error);39 }40 });41}4243// 显示更新可用通知44function showUpdateAvailableNotification() {45 const notification = document.createElement('div');46 notification.className = 'update-notification';47 notification.innerHTML = `48 <div class="notification-content">49 <p>新版本可用!</p>50 <button onclick="updateServiceWorker()">更新</button>51 <button onclick="dismissNotification()">稍后</button>52 </div>53 `;54 document.body.appendChild(notification);55}5657// 更新Service Worker58async function updateServiceWorker() {59 const registration = await navigator.serviceWorker.getRegistration();60 if (registration && registration.waiting) {61 // 向等待中的Service Worker发送消息62 registration.waiting.postMessage({ type: 'SKIP_WAITING' });63 }64}6566// Service Worker生命周期管理67class ServiceWorkerManager {68 constructor() {69 this.registration = null;70 this.isUpdateAvailable = false;71 }72 73 async init() {74 if (!('serviceWorker' in navigator)) {75 console.warn('Service Worker不被支持');76 return;77 }78 79 try {80 this.registration = await navigator.serviceWorker.register('/sw.js');81 this.setupEventListeners();82 console.log('Service Worker管理器初始化成功');83 } catch (error) {84 console.error('Service Worker注册失败:', error);85 }86 }87 88 setupEventListeners() {89 // 监听更新90 this.registration.addEventListener('updatefound', () => {91 const newWorker = this.registration.installing;92 this.trackInstalling(newWorker);93 });94 95 // 监听消息96 navigator.serviceWorker.addEventListener('message', (event) => {97 this.handleMessage(event.data);98 });99 100 // 监听控制器变化101 navigator.serviceWorker.addEventListener('controllerchange', () => {102 window.location.reload();103 });104 }105 106 trackInstalling(worker) {107 worker.addEventListener('statechange', () => {108 if (worker.state === 'installed') {109 this.isUpdateAvailable = true;110 this.notifyUpdateAvailable();111 }112 });113 }114 115 notifyUpdateAvailable() {116 // 触发自定义事件117 window.dispatchEvent(new CustomEvent('sw-update-available'));118 }119 120 async skipWaiting() {121 if (this.registration && this.registration.waiting) {122 this.registration.waiting.postMessage({ type: 'SKIP_WAITING' });123 }124 }125 126 handleMessage(data) {127 switch (data.type) {128 case 'SW_UPDATED':129 console.log('Service Worker已更新');130 break;131 case 'CACHE_UPDATED':132 console.log('缓存已更新');133 break;134 }135 }136}137138// 使用Service Worker管理器139const swManager = new ServiceWorkerManager();140swManager.init();141142// 监听更新事件143window.addEventListener('sw-update-available', () => {144 showUpdateAvailableNotification();145});Service Worker缓存策略
Service Worker缓存实现
javascript
1// sw.js - Service Worker文件2const CACHE_NAME = 'pwa-cache-v1';3const STATIC_CACHE = 'static-cache-v1';4const DYNAMIC_CACHE = 'dynamic-cache-v1';56// 需要缓存的静态资源7const STATIC_ASSETS = [8 '/',9 '/index.html',10 '/manifest.json',11 '/css/styles.css',12 '/js/app.js',13 '/images/icon-192.png',14 '/images/icon-512.png',15 '/offline.html'16];1718// 安装事件 - 缓存静态资源19self.addEventListener('install', (event) => {20 console.log('Service Worker: 安装中...');21 22 event.waitUntil(23 caches.open(STATIC_CACHE)24 .then((cache) => {25 console.log('Service Worker: 缓存静态资源');26 return cache.addAll(STATIC_ASSETS);27 })28 .then(() => {29 console.log('Service Worker: 安装完成');30 return self.skipWaiting(); // 立即激活新的Service Worker31 })32 );33});3435// 激活事件 - 清理旧缓存36self.addEventListener('activate', (event) => {37 console.log('Service Worker: 激活中...');38 39 event.waitUntil(40 caches.keys()41 .then((cacheNames) => {42 return Promise.all(43 cacheNames.map((cacheName) => {44 if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {45 console.log('Service Worker: 删除旧缓存', cacheName);46 return caches.delete(cacheName);47 }48 })49 );50 })51 .then(() => {52 console.log('Service Worker: 激活完成');53 return self.clients.claim(); // 立即控制所有客户端54 })55 );56});5758// 拦截网络请求59self.addEventListener('fetch', (event) => {60 event.respondWith(61 handleFetch(event.request)62 );63});6465// 请求处理策略66async function handleFetch(request) {67 const url = new URL(request.url);68 69 // 静态资源:缓存优先策略70 if (STATIC_ASSETS.includes(url.pathname)) {71 return cacheFirst(request);72 }73 74 // API请求:网络优先策略75 if (url.pathname.startsWith('/api/')) {76 return networkFirst(request);77 }78 79 // 图片资源:缓存优先策略80 if (request.destination === 'image') {81 return cacheFirst(request);82 }83 84 // HTML页面:网络优先策略85 if (request.mode === 'navigate') {86 return networkFirst(request, '/offline.html');87 }88 89 // 其他资源:网络优先策略90 return networkFirst(request);91}9293// 缓存优先策略94async function cacheFirst(request) {95 try {96 const cachedResponse = await caches.match(request);97 if (cachedResponse) {98 return cachedResponse;99 }100 101 const networkResponse = await fetch(request);102 103 // 缓存成功的响应104 if (networkResponse.ok) {105 const cache = await caches.open(DYNAMIC_CACHE);106 cache.put(request, networkResponse.clone());107 }108 109 return networkResponse;110 } catch (error) {111 console.error('缓存优先策略失败:', error);112 return new Response('离线状态', { status: 503 });113 }114}115116// 网络优先策略117async function networkFirst(request, fallbackUrl = null) {118 try {119 const networkResponse = await fetch(request);120 121 // 缓存成功的响应122 if (networkResponse.ok) {123 const cache = await caches.open(DYNAMIC_CACHE);124 cache.put(request, networkResponse.clone());125 }126 127 return networkResponse;128 } catch (error) {129 console.log('网络请求失败,尝试缓存:', request.url);130 131 const cachedResponse = await caches.match(request);132 if (cachedResponse) {133 return cachedResponse;134 }135 136 // 返回离线页面137 if (fallbackUrl) {138 const fallbackResponse = await caches.match(fallbackUrl);139 if (fallbackResponse) {140 return fallbackResponse;141 }142 }143 144 return new Response('离线状态', { status: 503 });145 }146}147148// 仅网络策略(用于关键API)149async function networkOnly(request) {150 return fetch(request);151}152153// 仅缓存策略(用于静态资源)154async function cacheOnly(request) {155 return caches.match(request);156}157158// 最快响应策略(同时请求网络和缓存)159async function staleWhileRevalidate(request) {160 const cachedResponse = caches.match(request);161 const networkResponse = fetch(request).then((response) => {162 if (response.ok) {163 const cache = caches.open(DYNAMIC_CACHE);164 cache.then((c) => c.put(request, response.clone()));165 }166 return response;167 });168 169 return cachedResponse || networkResponse;170}171172// 缓存管理工具173class CacheManager {174 static async clearOldCaches(currentCaches) {175 const cacheNames = await caches.keys();176 const deletePromises = cacheNames177 .filter(name => !currentCaches.includes(name))178 .map(name => caches.delete(name));179 180 return Promise.all(deletePromises);181 }182 183 static async getCacheSize(cacheName) {184 const cache = await caches.open(cacheName);185 const keys = await cache.keys();186 let totalSize = 0;187 188 for (const key of keys) {189 const response = await cache.match(key);190 if (response) {191 const blob = await response.blob();192 totalSize += blob.size;193 }194 }195 196 return totalSize;197 }198 199 static async limitCacheSize(cacheName, maxSize) {200 const cache = await caches.open(cacheName);201 const keys = await cache.keys();202 203 while (await this.getCacheSize(cacheName) > maxSize && keys.length > 0) {204 const oldestKey = keys.shift();205 await cache.delete(oldestKey);206 }207 }208}209210// 消息处理211self.addEventListener('message', (event) => {212 const { type, payload } = event.data;213 214 switch (type) {215 case 'SKIP_WAITING':216 self.skipWaiting();217 break;218 219 case 'GET_CACHE_SIZE':220 CacheManager.getCacheSize(payload.cacheName)221 .then(size => {222 event.ports[0].postMessage({ size });223 });224 break;225 226 case 'CLEAR_CACHE':227 caches.delete(payload.cacheName)228 .then(() => {229 event.ports[0].postMessage({ success: true });230 });231 break;232 }233});后台同步实现
后台同步功能
javascript
1// sw.js - 后台同步功能2// 后台同步事件3self.addEventListener('sync', (event) => {4 console.log('后台同步事件:', event.tag);5 6 switch (event.tag) {7 case 'background-sync':8 event.waitUntil(doBackgroundSync());9 break;10 case 'send-messages':11 event.waitUntil(sendPendingMessages());12 break;13 case 'upload-data':14 event.waitUntil(uploadPendingData());15 break;16 }17});1819// 执行后台同步20async function doBackgroundSync() {21 try {22 console.log('执行后台同步...');23 24 // 同步离线数据25 await syncOfflineData();26 27 // 发送待发送的消息28 await sendPendingMessages();29 30 // 上传待上传的文件31 await uploadPendingFiles();32 33 console.log('后台同步完成');34 35 // 通知客户端同步完成36 const clients = await self.clients.matchAll();37 clients.forEach(client => {38 client.postMessage({39 type: 'SYNC_COMPLETE',40 timestamp: Date.now()41 });42 });43 44 } catch (error) {45 console.error('后台同步失败:', error);46 throw error; // 重新抛出错误,让浏览器重试47 }48}4950// 同步离线数据51async function syncOfflineData() {52 const db = await openIndexedDB();53 const pendingData = await getPendingData(db);54 55 for (const data of pendingData) {56 try {57 const response = await fetch('/api/sync', {58 method: 'POST',59 headers: { 'Content-Type': 'application/json' },60 body: JSON.stringify(data)61 });62 63 if (response.ok) {64 await removePendingData(db, data.id);65 console.log('数据同步成功:', data.id);66 }67 } catch (error) {68 console.error('数据同步失败:', data.id, error);69 }70 }71}7273// 发送待发送的消息74async function sendPendingMessages() {75 const db = await openIndexedDB();76 const pendingMessages = await getPendingMessages(db);77 78 for (const message of pendingMessages) {79 try {80 const response = await fetch('/api/messages', {81 method: 'POST',82 headers: { 'Content-Type': 'application/json' },83 body: JSON.stringify(message)84 });85 86 if (response.ok) {87 await removePendingMessage(db, message.id);88 console.log('消息发送成功:', message.id);89 }90 } catch (error) {91 console.error('消息发送失败:', message.id, error);92 }93 }94}9596// IndexedDB操作97function openIndexedDB() {98 return new Promise((resolve, reject) => {99 const request = indexedDB.open('PWADatabase', 1);100 101 request.onerror = () => reject(request.error);102 request.onsuccess = () => resolve(request.result);103 104 request.onupgradeneeded = (event) => {105 const db = event.target.result;106 107 // 创建存储对象108 if (!db.objectStoreNames.contains('pendingData')) {109 const store = db.createObjectStore('pendingData', { keyPath: 'id' });110 store.createIndex('timestamp', 'timestamp', { unique: false });111 }112 113 if (!db.objectStoreNames.contains('pendingMessages')) {114 const store = db.createObjectStore('pendingMessages', { keyPath: 'id' });115 store.createIndex('timestamp', 'timestamp', { unique: false });116 }117 };118 });119}120121// 获取待同步数据122function getPendingData(db) {123 return new Promise((resolve, reject) => {124 const transaction = db.transaction(['pendingData'], 'readonly');125 const store = transaction.objectStore('pendingData');126 const request = store.getAll();127 128 request.onerror = () => reject(request.error);129 request.onsuccess = () => resolve(request.result);130 });131}132133// 主应用中的后台同步管理134class BackgroundSyncManager {135 constructor() {136 this.isOnline = navigator.onLine;137 this.setupEventListeners();138 }139 140 setupEventListeners() {141 // 监听网络状态变化142 window.addEventListener('online', () => {143 this.isOnline = true;144 this.triggerSync();145 });146 147 window.addEventListener('offline', () => {148 this.isOnline = false;149 });150 151 // 监听Service Worker消息152 navigator.serviceWorker.addEventListener('message', (event) => {153 if (event.data.type === 'SYNC_COMPLETE') {154 this.handleSyncComplete(event.data);155 }156 });157 }158 159 // 添加数据到同步队列160 async addToSyncQueue(data) {161 const db = await this.openDB();162 const transaction = db.transaction(['pendingData'], 'readwrite');163 const store = transaction.objectStore('pendingData');164 165 const syncData = {166 id: Date.now() + Math.random(),167 ...data,168 timestamp: Date.now()169 };170 171 await store.add(syncData);172 173 // 如果在线,立即尝试同步174 if (this.isOnline) {175 this.triggerSync();176 }177 }178 179 // 触发后台同步180 async triggerSync() {181 if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {182 const registration = await navigator.serviceWorker.ready;183 await registration.sync.register('background-sync');184 }185 }186 187 // 处理同步完成188 handleSyncComplete(data) {189 console.log('后台同步完成:', data.timestamp);190 191 // 触发自定义事件192 window.dispatchEvent(new CustomEvent('sync-complete', {193 detail: data194 }));195 }196 197 async openDB() {198 return new Promise((resolve, reject) => {199 const request = indexedDB.open('PWADatabase', 1);200 request.onerror = () => reject(request.error);201 request.onsuccess = () => resolve(request.result);202 });203 }204}205206// 使用示例207const syncManager = new BackgroundSyncManager();208209// 添加数据到同步队列210document.getElementById('saveButton').addEventListener('click', async () => {211 const formData = {212 name: document.getElementById('name').value,213 email: document.getElementById('email').value,214 message: document.getElementById('message').value215 };216 217 try {218 if (navigator.onLine) {219 // 在线时直接发送220 const response = await fetch('/api/data', {221 method: 'POST',222 headers: { 'Content-Type': 'application/json' },223 body: JSON.stringify(formData)224 });225 226 if (response.ok) {227 showMessage('数据保存成功');228 } else {229 throw new Error('保存失败');230 }231 } else {232 // 离线时添加到同步队列233 await syncManager.addToSyncQueue(formData);234 showMessage('数据已保存到离线队列,将在网络恢复时同步');235 }236 } catch (error) {237 // 保存失败时也添加到同步队列238 await syncManager.addToSyncQueue(formData);239 showMessage('数据已保存到离线队列');240 }241});242243// 监听同步完成事件244window.addEventListener('sync-complete', (event) => {245 showMessage('离线数据同步完成');246});3. Web应用清单(Manifest)
3.1 清单文件配置
Web应用清单是一个JSON文件,定义了PWA的元数据和行为。
- 基础清单
- 高级配置
- 清单验证
基础清单配置
manifest.json
json
1{2 "name": "我的PWA应用",3 "short_name": "PWA应用",4 "description": "一个功能完整的渐进式Web应用示例",5 "start_url": "/",6 "scope": "/",7 "display": "standalone",8 "orientation": "portrait-primary",9 "theme_color": "#2196F3",10 "background_color": "#ffffff",11 "lang": "zh-CN",12 "dir": "ltr",13 14 "icons": [15 {16 "src": "/images/icon-72.png",17 "sizes": "72x72",18 "type": "image/png",19 "purpose": "any"20 },21 {22 "src": "/images/icon-96.png",23 "sizes": "96x96",24 "type": "image/png",25 "purpose": "any"26 },27 {28 "src": "/images/icon-128.png",29 "sizes": "128x128",30 "type": "image/png",31 "purpose": "any"32 },33 {34 "src": "/images/icon-144.png",35 "sizes": "144x144",36 "type": "image/png",37 "purpose": "any"38 },39 {40 "src": "/images/icon-152.png",41 "sizes": "152x152",42 "type": "image/png",43 "purpose": "any"44 },45 {46 "src": "/images/icon-192.png",47 "sizes": "192x192",48 "type": "image/png",49 "purpose": "any maskable"50 },51 {52 "src": "/images/icon-384.png",53 "sizes": "384x384",54 "type": "image/png",55 "purpose": "any"56 },57 {58 "src": "/images/icon-512.png",59 "sizes": "512x512",60 "type": "image/png",61 "purpose": "any maskable"62 }63 ],64 65 "categories": ["productivity", "utilities"],66 "screenshots": [67 {68 "src": "/images/screenshot-mobile.png",69 "sizes": "640x1136",70 "type": "image/png",71 "form_factor": "narrow"72 },73 {74 "src": "/images/screenshot-desktop.png",75 "sizes": "1280x720",76 "type": "image/png",77 "form_factor": "wide"78 }79 ]80}高级清单配置
高级manifest.json配置
json
1{2 "name": "高级PWA应用",3 "short_name": "高级PWA",4 "description": "具有高级功能的渐进式Web应用",5 "start_url": "/?utm_source=pwa&utm_medium=homescreen",6 "scope": "/",7 "id": "/",8 "display": "standalone",9 "display_override": ["window-controls-overlay", "minimal-ui"],10 "orientation": "any",11 "theme_color": "#1976D2",12 "background_color": "#fafafa",13 "lang": "zh-CN",14 "dir": "ltr",15 16 "icons": [17 {18 "src": "/images/icon-192.png",19 "sizes": "192x192",20 "type": "image/png",21 "purpose": "any"22 },23 {24 "src": "/images/icon-192-maskable.png",25 "sizes": "192x192",26 "type": "image/png",27 "purpose": "maskable"28 },29 {30 "src": "/images/icon-512.png",31 "sizes": "512x512",32 "type": "image/png",33 "purpose": "any"34 },35 {36 "src": "/images/icon-512-maskable.png",37 "sizes": "512x512",38 "type": "image/png",39 "purpose": "maskable"40 }41 ],42 43 "shortcuts": [44 {45 "name": "新建文档",46 "short_name": "新建",47 "description": "快速创建新文档",48 "url": "/new-document",49 "icons": [50 {51 "src": "/images/shortcut-new.png",52 "sizes": "96x96",53 "type": "image/png"54 }55 ]56 },57 {58 "name": "最近文档",59 "short_name": "最近",60 "description": "查看最近编辑的文档",61 "url": "/recent-documents",62 "icons": [63 {64 "src": "/images/shortcut-recent.png",65 "sizes": "96x96",66 "type": "image/png"67 }68 ]69 }70 ],71 72 "categories": ["productivity", "business", "utilities"],73 "iarc_rating_id": "e84b072d-71b3-4d3e-86ae-31a8ce4e53b7",74 75 "prefer_related_applications": false,76 "related_applications": [77 {78 "platform": "play",79 "url": "https://play.google.com/store/apps/details?id=com.example.app",80 "id": "com.example.app"81 },82 {83 "platform": "itunes",84 "url": "https://apps.apple.com/app/example-app/id123456789"85 }86 ],87 88 "protocol_handlers": [89 {90 "protocol": "mailto",91 "url": "/compose?to=%s"92 },93 {94 "protocol": "web+pwa",95 "url": "/handle-protocol?url=%s"96 }97 ],98 99 "file_handlers": [100 {101 "action": "/open-file",102 "accept": {103 "text/plain": [".txt"],104 "application/json": [".json"],105 "image/*": [".png", ".jpg", ".jpeg", ".gif"]106 }107 }108 ],109 110 "share_target": {111 "action": "/share",112 "method": "POST",113 "enctype": "multipart/form-data",114 "params": {115 "title": "title",116 "text": "text",117 "url": "url",118 "files": [119 {120 "name": "files",121 "accept": ["image/*", "text/plain"]122 }123 ]124 }125 },126 127 "launch_handler": {128 "client_mode": "navigate-existing"129 },130 131 "edge_side_panel": {132 "preferred_width": 400133 }134}清单文件验证与优化
清单文件验证工具
javascript
1class ManifestValidator {2 constructor(manifest) {3 this.manifest = manifest;4 this.errors = [];5 this.warnings = [];6 }7 8 validate() {9 this.validateRequired();10 this.validateIcons();11 this.validateDisplay();12 this.validateUrls();13 this.validateColors();14 this.validateScreenshots();15 16 return {17 isValid: this.errors.length === 0,18 errors: this.errors,19 warnings: this.warnings20 };21 }22 23 validateRequired() {24 const required = ['name', 'start_url', 'display', 'icons'];25 26 required.forEach(field => {27 if (!this.manifest[field]) {28 this.errors.push(`缺少必需字段: ${field}`);29 }30 });31 32 if (!this.manifest.short_name && this.manifest.name.length > 12) {33 this.warnings.push('建议提供short_name,因为name太长');34 }35 }36 37 validateIcons() {38 if (!this.manifest.icons || this.manifest.icons.length === 0) {39 this.errors.push('至少需要一个图标');40 return;41 }42 43 const requiredSizes = ['192x192', '512x512'];44 const availableSizes = this.manifest.icons.map(icon => icon.sizes);45 46 requiredSizes.forEach(size => {47 if (!availableSizes.includes(size)) {48 this.errors.push(`缺少${size}尺寸的图标`);49 }50 });51 52 // 检查maskable图标53 const hasMaskable = this.manifest.icons.some(icon => 54 icon.purpose && icon.purpose.includes('maskable')55 );56 57 if (!hasMaskable) {58 this.warnings.push('建议提供maskable图标以支持自适应图标');59 }60 61 // 验证图标URL62 this.manifest.icons.forEach((icon, index) => {63 if (!icon.src) {64 this.errors.push(`图标${index}缺少src属性`);65 }66 67 if (!icon.sizes) {68 this.warnings.push(`图标${index}缺少sizes属性`);69 }70 71 if (!icon.type) {72 this.warnings.push(`图标${index}缺少type属性`);73 }74 });75 }76 77 validateDisplay() {78 const validDisplayModes = [79 'fullscreen', 'standalone', 'minimal-ui', 'browser'80 ];81 82 if (!validDisplayModes.includes(this.manifest.display)) {83 this.errors.push(`无效的display值: ${this.manifest.display}`);84 }85 }86 87 validateUrls() {88 const urlFields = ['start_url', 'scope'];89 90 urlFields.forEach(field => {91 if (this.manifest[field]) {92 try {93 new URL(this.manifest[field], window.location.origin);94 } catch (error) {95 this.errors.push(`${field}不是有效的URL: ${this.manifest[field]}`);96 }97 }98 });99 100 // 验证scope包含start_url101 if (this.manifest.scope && this.manifest.start_url) {102 const scope = new URL(this.manifest.scope, window.location.origin);103 const startUrl = new URL(this.manifest.start_url, window.location.origin);104 105 if (!startUrl.pathname.startsWith(scope.pathname)) {106 this.warnings.push('start_url应该在scope范围内');107 }108 }109 }110 111 validateColors() {112 const colorFields = ['theme_color', 'background_color'];113 114 colorFields.forEach(field => {115 if (this.manifest[field]) {116 if (!this.isValidColor(this.manifest[field])) {117 this.errors.push(`${field}不是有效的颜色值: ${this.manifest[field]}`);118 }119 }120 });121 }122 123 validateScreenshots() {124 if (this.manifest.screenshots) {125 this.manifest.screenshots.forEach((screenshot, index) => {126 if (!screenshot.src) {127 this.errors.push(`截图${index}缺少src属性`);128 }129 130 if (!screenshot.sizes) {131 this.warnings.push(`截图${index}缺少sizes属性`);132 }133 134 if (!screenshot.type) {135 this.warnings.push(`截图${index}缺少type属性`);136 }137 });138 }139 }140 141 isValidColor(color) {142 const style = new Option().style;143 style.color = color;144 return style.color !== '';145 }146}147148// 清单文件生成器149class ManifestGenerator {150 constructor() {151 this.manifest = {152 name: '',153 short_name: '',154 description: '',155 start_url: '/',156 scope: '/',157 display: 'standalone',158 theme_color: '#000000',159 background_color: '#ffffff',160 icons: []161 };162 }163 164 setBasicInfo(name, shortName, description) {165 this.manifest.name = name;166 this.manifest.short_name = shortName;167 this.manifest.description = description;168 return this;169 }170 171 setUrls(startUrl, scope) {172 this.manifest.start_url = startUrl;173 this.manifest.scope = scope;174 return this;175 }176 177 setDisplay(display, orientation = 'any') {178 this.manifest.display = display;179 this.manifest.orientation = orientation;180 return this;181 }182 183 setColors(themeColor, backgroundColor) {184 this.manifest.theme_color = themeColor;185 this.manifest.background_color = backgroundColor;186 return this;187 }188 189 addIcon(src, sizes, type = 'image/png', purpose = 'any') {190 this.manifest.icons.push({191 src,192 sizes,193 type,194 purpose195 });196 return this;197 }198 199 addShortcut(name, url, description, iconSrc) {200 if (!this.manifest.shortcuts) {201 this.manifest.shortcuts = [];202 }203 204 this.manifest.shortcuts.push({205 name,206 url,207 description,208 icons: iconSrc ? [{209 src: iconSrc,210 sizes: '96x96',211 type: 'image/png'212 }] : []213 });214 return this;215 }216 217 generate() {218 return JSON.stringify(this.manifest, null, 2);219 }220}221222// 使用示例223const generator = new ManifestGenerator()224 .setBasicInfo('我的应用', '应用', '这是一个PWA应用')225 .setUrls('/', '/')226 .setDisplay('standalone', 'portrait')227 .setColors('#2196F3', '#ffffff')228 .addIcon('/icon-192.png', '192x192')229 .addIcon('/icon-512.png', '512x512')230 .addShortcut('新建', '/new', '创建新内容', '/shortcut-new.png');231232const manifestJson = generator.generate();233console.log(manifestJson);234235// 验证生成的清单236const validator = new ManifestValidator(JSON.parse(manifestJson));237const result = validator.validate();238console.log('验证结果:', result);
评论