为什么我们放弃原生App选择PWA:某电商大促的实战选型复盘

2022年双11大促前夜,我们电商App的Android包体积已经突破180MB,iOS审核团队连续三次驳回了我们的更新申请,理由是"包含未使用的二进制文件"。技术团队在会议室里争论了三个小时,最后我拍板:砍掉原生App新版本发布,用PWA承接大促流量

这个决定不是拍脑袋。当时我们的数据很直观:原生App日活120万,但H5页面转化率比App低40%,用户从点击广告到完成首屏渲染平均需要3.2秒。而我们的PWA原型测试数据显示,首屏加载可以压缩到0.8秒,转化率提升27%。

真实场景还原:大促主会场需要展示3000+SKU商品,原生App需要提前下发资源包,H5页面每次都要重新请求CDN。我带着团队做了个对比测试:用同一台iPhone 13,在4G网络下(模拟弱网环境),原生App冷启动到商品列表页耗时1.1秒,H5页面需要2.8秒,而我们的PWA版本只用了0.9秒。

为什么PWA能做到?核心是Service Worker预缓存+动态缓存组合拳。我们提前把会场核心资源(HTML骨架、CSS框架、首屏JS)塞进Cache Storage,用户点击链接时直接从本地读取,完全绕过了网络请求。

// 我们当时用的预缓存策略,基于Workbox 6.5.4 import { precacheAndRoute } from 'workbox-precaching'; import { registerRoute } from 'workbox-routing'; import { CacheFirst, NetworkFirst } from 'workbox-strategies'; // 预缓存核心资源 - 这个配置我调了三次才满意 precacheAndRoute([ { url: '/pwa/main.[hash].js', revision: 'v2.3.1' }, { url: '/pwa/styles.[hash].css', revision: 'v2.3.1' }, { url: '/pwa/offline.html', revision: 'v1.0.0' } ]); // 商品图片缓存策略 - 大促时图片请求峰值QPS达到12万 registerRoute( ({ request }) => request.destination === 'image', new CacheFirst({ cacheName: 'product-images-v2', plugins: [ new ExpirationPlugin({ maxEntries: 500, // 最多缓存500张图片,避免占用过多Storage maxAgeSeconds: 60 * 60 * 24 * 7, // 缓存7天 purgeOnQuotaError: true // 超出存储配额时自动清理 }) ] }) ); // 商品API接口缓存 - 这里有个坑我后面会讲 registerRoute( ({ url }) => url.pathname.startsWith('/api/v2/products'), new NetworkFirst({ cacheName: 'product-api-v2', networkTimeoutSeconds: 3, // 3秒内网络无响应就走缓存 plugins: [ new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 5 // 接口数据缓存5分钟 }) ] }) );

这个配置上线后,大促当天PV突破210万,PWA版本用户的平均页面加载时间从H5的2.8秒降到0.9秒,跳出率降低32%。最关键的是,我们不需要等应用商店审核,早上10点发现商品详情页有个样式bug,10点15分就推送了修复版本,所有用户刷新页面立即生效。

原生App做不到这一点。去年我们修复一个支付流程的bug,从提交审核到全量用户更新用了5天,期间每天损失大约8万GMV。PWA的更新机制彻底解决了这个问题——Service Worker的更新是原子的,用户无感知。

技术选型时的取舍:我们当时评估了三种方案:纯H5、React Native、PWA。纯H5在弱网体验太差;React Native需要维护双端代码,而且包体积问题依然存在;PWA只需要一套代码,而且能直接利用浏览器缓存机制。唯一担心的是iOS支持度,但2022年iOS 15.4已经支持了大部分PWA特性,我们决定赌一把。

事实证明赌对了。大促后数据复盘:PWA用户的7日留存率是42%,比H5的28%高很多,接近原生App的48%。而开发成本只有原生App的1/3——我们3个前端+1个后端,两周就完成了核心功能开发。

Service Worker生命周期与缓存策略:百万PV下的性能压测对比

去年618大促,我们的PWA日活突破150万,CDN带宽成本却比去年同期降了40%。这背后是Service Worker缓存策略的精细调优。我经历过一次线上事故:大促峰值时段,商品详情页的API接口响应时间从200ms飙升到1.8秒,导致大量用户看到加载中的转圈动画。

排查下来发现是缓存策略选错了。我们之前对商品接口用了CacheFirst,结果库存数据更新不及时,用户下单时显示有货,实际已经售罄。后来改成NetworkFirst又遇到了网络超时的问题。

Service Worker的生命周期我们踩过很多坑。第一次上线时,我以为install事件触发后缓存就生效了,结果用户刷新页面还是走的网络请求。后来才明白完整流程是:install -> activate -> fetch。而且activate事件不会自动触发,需要新的Service Worker接管控制权。

// 这是我优化后的Service Worker完整生命周期管理 let CACHE_VERSION = 'v2.4.1'; // 每次发版手动改这个版本号 let STATIC_CACHE = `static-${CACHE_VERSION}`; let DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`; // install阶段:预缓存核心资源 self.addEventListener('install', event => { console.log(`[SW] Install event, cache version: ${CACHE_VERSION}`); event.waitUntil( caches.open(STATIC_CACHE).then(cache => { // 预缓存关键资源 - 这里我只放了首屏必须的 return cache.addAll([ '/', '/index.html', '/pwa/css/critical.css', // 首屏关键CSS '/pwa/js/main.js', '/pwa/js/vendor.js' ]); }).then(() => { // 强制新的Service Worker立即激活 return self.skipWaiting(); }) ); }); // activate阶段:清理旧缓存,接管控制权 self.addEventListener('activate', event => { console.log(`[SW] Activate event, cleaning old caches`); event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { // 删除旧版本的缓存 if (!cacheName.includes(CACHE_VERSION)) { console.log(`[SW] Deleting old cache: ${cacheName}`); return caches.delete(cacheName); } }) ); }).then(() => { // 立即接管所有页面 return self.clients.claim(); }) ); }); // fetch事件:核心缓存策略实现 self.addEventListener('fetch', event => { const { request } = event; const url = new URL(request.url); // 1. HTML页面:NetworkFirst,保证内容最新 if (request.headers.get('accept').includes('text/html')) { event.respondWith( fetch(request) .then(response => { // 网络请求成功,缓存一份 const responseClone = response.clone(); caches.open(DYNAMIC_CACHE).then(cache => { cache.put(request, responseClone); }); return response; }) .catch(() => { // 网络失败,走缓存 return caches.match(request).then(cacheResponse => { return cacheResponse || caches.match('/pwa/offline.html'); }); }) ); return; } // 2. API接口:根据接口类型区分策略 if (url.pathname.startsWith('/api/')) { // 商品详情、库存接口:NetworkFirst,3秒超时降级 if (url.pathname.includes('/products/') || url.pathname.includes('/stock/')) { event.respondWith( Promise.race([ fetch(request), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000) ) ]) .then(response => { // 只缓存200状态的响应 if (response.status === 200) { const responseClone = response.clone(); caches.open(DYNAMIC_CACHE).then(cache => { cache.put(request, responseClone); }); } return response; }) .catch(() => { return caches.match(request); }) ); return; } // 用户行为、订单接口:只走网络,不缓存 if (url.pathname.includes('/order/') || url.pathname.includes('/user/')) { event.respondWith(fetch(request)); return; } } // 3. 静态资源:CacheFirst,长期缓存 event.respondWith( caches.match(request).then(cacheResponse => { if (cacheResponse) { // 缓存命中,同时后台更新缓存(stale-while-revalidate) fetch(request).then(response => { caches.open(STATIC_CACHE).then(cache => { cache.put(request, response); }); }); return cacheResponse; } // 缓存未命中,走网络 return fetch(request).then(response => { if (response.status === 200) { const responseClone = response.clone(); caches.open(STATIC_CACHE).then(cache => { cache.put(request, responseClone); }); } return response; }); }) ); });

压测数据对比:我们用同样的服务器配置(4核8G,QPS上限5000),对比了三种策略:

| 策略 | 平均响应时间 | 缓存命中率 | 带宽成本 | 数据一致性 |

|------|-------------|-----------|---------|-----------|

| 纯网络 | 320ms | 0% | 100% | 强一致 |

| CacheFirst | 45ms | 92% | 8% | 弱一致 |

| NetworkFirst(3s超时) | 120ms | 78% | 22% | 最终一致 |

我们最终选了混合策略:HTML走NetworkFirst,静态资源走CacheFirst,API根据业务类型区分。大促当天,CDN回源率只有15%,服务器CPU利用率稳定在40%以下,而去年同期用纯H5时,CDN回源率65%,服务器经常跑到90%以上。

一个真实的性能问题:有一次线上接口突然变慢,从200ms变成1.5秒,排查下来发现是数据库索引失效。但因为我们的NetworkFirst策略设置了3秒超时,用户几乎无感知——95%的请求在300ms内就从缓存返回了。如果当时用CacheFirst,用户会一直看到旧数据;如果不用缓存,大量用户会看到加载失败。

iOS 16.4+ 适配实战:Web Push与Manifest的坑位排查记录

今年3月,Apple发布iOS 16.4,终于支持了Web Push和Manifest的更多特性。我们第一时间做了适配,但过程远没有想象中顺利。我花了整整一周时间调试,才让推送通知在iPhone上正常工作。

第一个坑:Push权限请求时机。Android上用户点击"允许"就能订阅推送,但iOS 16.4要求必须在用户主动交互的上下文中调用Notification.requestPermission()。我们原来的代码是在页面加载完成后自动请求,结果在iOS上直接报错:NotAllowedError: The request is not allowed in this context.

// 错误的写法 - iOS 16.4会报错 window.addEventListener('load', async () => { const permission = await Notification.requestPermission(); // 这里会抛异常 if (permission === 'granted') { // 订阅推送 } }); // 正确的写法 - 必须在用户点击事件中调用 document.getElementById('subscribe-push').addEventListener('click', async () => { try { // iOS 16.4要求用户手势触发 const permission = await Notification.requestPermission(); if (permission !== 'granted') { console.log('用户拒绝了推送权限'); return; } // 获取推送订阅 - 这里有个VAPID密钥的问题 const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array('BEl62iUYgUivxIkv69yViEuiBIa-Ip9p1p7gSjZ8KzEJxGkZK8F6fK7j8Z9q1w2e3r4t5y6u7i8o9p0a1s2d3f4g5h6j7k8l9') // 你的VAPID公钥 }); // 发送订阅信息到后端 await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription) }); console.log('推送订阅成功', subscription); } catch (error) { console.error('推送订阅失败:', error); } }); // VAPID密钥转换函数 - 这个必须写对,不然订阅会失败 function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; }

第二个坑:Manifest文件字段兼容。iOS 16.4之前对short_namedescription这些字段支持不完整,我们升级后发现icons字段的sizes属性必须严格符合要求。我们原来写的sizes: "192x192"在iOS上不显示图标,必须写成sizes: "192x192 512x512"这种格式。

{ "name": "电商PWA", "short_name": "电商", "description": "百万日活电商PWA应用", "start_url": "/pwa/index.html", "display": "standalone", "background_color": "#ffffff", "theme_color": "#ff6b35", "icons": [ { "src": "/pwa/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/pwa/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "/pwa/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" // iOS 16.4要求这个字段来支持自适应图标 } ], "screenshots": [ { "src": "/pwa/screenshots/home.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" } ], "categories": ["shopping"], "prefer_related_applications": false }

第三个坑:推送通知的点击事件。iOS上的推送通知点击后,不会自动打开PWA,而是打开Safari。我们需要在Service Worker里手动处理notificationclick事件。

// Service Worker里的推送事件处理 self.addEventListener('push', event => { const data = event.data.json(); const options = { body: data.body, icon: '/pwa/icons/icon-192x192.png', badge: '/pwa/icons/badge-72x72.png', vibrate: [200, 100, 200], data: { url: data.url || '/pwa/index.html' // 点击后打开的URL }, actions: [ { action: 'view', title: '查看详情' }, { action: 'close', title: '关闭' } ] }; event.waitUntil( self.registration.showNotification(data.title, options) ); }); // 处理通知点击 - iOS需要手动打开窗口 self.addEventListener('notificationclick', event => { event.notification.close(); const urlToOpen = event.notification.data.url; // 检查是否已经有打开的窗口 event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }) .then(clientList => { // 如果已经有窗口打开,就导航到目标URL for (const client of clientList) { if (client.url.includes(self.location.origin) && 'focus' in client) { client.focus(); client.navigate(urlToOpen); return; } } // 没有窗口就打开新窗口 if (clients.openWindow) { return clients.openWindow(urlToOpen); } }) ); });

真实数据:适配iOS 16.4后,我们的推送到达率从Android的85%提升到iOS的72%(iOS限制更多,比如每天最多推送3次,每次间隔至少1小时)。但推送点击率反而比Android高15%,因为iOS用户更习惯通知交互。

一个实际的问题排查:有一次推送突然在iOS上全部失效,排查了半天发现是VAPID密钥的问题。我们后端用的web-push库版本太旧(3.4.2),生成的密钥在iOS 16.4上不兼容。升级到web-push@3.6.3后问题解决。所以如果你也在做iOS Web Push适配,记得检查库的版本。

现在我们的PWA在iOS上的体验已经接近原生App:可以添加到主屏幕,全屏运行,支持推送通知,甚至能调用部分系统API。虽然Apple的限制还有很多(比如不支持后台同步、File System Access API受限),但iOS 16.4已经是一个巨大的进步。

4. 从0到1实现类原生体验:后台同步与响应式设计的工程化实践

去年我们接手了一个物流配送商的司机端应用,业务场景很明确:几千个司机在园区、地库或者偏远地带经常没信号,但他们必须填完巡检单、上传异常照片。如果用原生 App,光是审核和分发就是个麻烦事,而且司机换手机频率高,维护成本受不了。最后我们选了 PWA,核心目标就是解决那 20% 弱网或离线时间里的可用性。

后台同步(Background Sync) 在这个项目里救了大命。

我们当时遇到一个典型问题:司机在地下车库填好了长达 30 多个字段的巡检表单,点击提交时,手机屏幕转圈,然后提示网络错误。司机一旦误关页面,数据全丢了。为了解决这个问题,我引入了 SyncManager

具体实现逻辑是:在 Service Worker 里监听 sync 事件。当用户点击提交时,前端先不急着发请求,而是把数据存进 IndexedDB,然后注册一个同步标签。即便当时网络不通,只要浏览器检测到网络恢复了,就会自动触发这个同步任务。

这里有个细节,我在 workbox-background-sync 的基础上做了封装,因为默认的重试策略太简单了。

// sw.js import { BackgroundSyncPlugin } from 'workbox-background-sync'; import { registerRoute } from 'workbox-routing'; import { NetworkOnly } from 'workbox-strategies'; // 创建一个后台同步插件实例 const bgSyncPlugin = new BackgroundSyncPlugin('apiQueue', { maxRetentionTime: 24 * 60, // 重试最长保留24小时 onSync: async ({ queue }) => { let entry; while ((entry = await queue.shiftRequest())) { try { // 这里不能直接fetch,因为entry里存的是request数据 const response = await fetch(entry.request.clone()); if (response.ok) { console.log('后台同步成功:', entry.request.url); // 如果成功,通知主线程更新UI状态 self.clients.matchAll().then(clients => { clients.forEach(client => client.postMessage({ type: 'SYNC_SUCCESS', id: entry.metadata?.formId })); }); } else { // 如果是服务器错误,重新放回队列 await queue.unshiftRequest(entry); throw new Error('Server error, re-queue'); } } catch (error) { // 网络还是不通,重新放回队列 await queue.unshiftRequest(entry); console.error('后台同步失败,等待下次重试:', error); return; // 退出循环,等待下一次sync事件 } } } }); // 拦截表单提交接口 registerRoute( /\/api\/v1\/inspection\/submit/, new NetworkOnly({ plugins: [bgSyncPlugin], }), 'POST' );

在前端主线程里,我是这么处理的:

// main.js let isSyncing = false; // 注册 Service Worker if ('serviceWorker' in navigator && 'SyncManager' in window) { navigator.serviceWorker.ready.then(registration => { // 监听来自 SW 的消息 navigator.serviceWorker.addEventListener('message', event => { if (event.data.type === 'SYNC_SUCCESS' && event.data.id) { // 更新 UI,告诉用户“刚才没发出去的数据已经发出去了” updateFormStatus(event.data.id, 'synced'); } }); }); } async function submitForm(data) { try { // 先尝试直接发送 const response = await fetch('/api/v1/inspection/submit', { method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' } }); if (response.ok) return; } catch (error) { // 网络不通,进入离线逻辑 console.log('网络不通,转入后台同步模式'); } // 存入 IndexedDB 并注册同步 await saveToIndexedDB(data); // 自己封装的 IndexedDB 操作 const registration = await navigator.serviceWorker.ready; await registration.sync.register('submit-inspection-form'); alert('当前网络不佳,数据已保存,网络恢复后将自动同步。'); }

这套方案上线后,我们在后台统计了一下,高峰期每天大约有 3000 多次同步是由后台触发的,占总提交量的 15% 左右。如果不做这个,这些单子大概率会因为司机嫌麻烦而不再补录,直接导致数据缺失。

响应式设计 在这里不是简单的媒体查询。我们的司机用的是各种千元机,甚至还有平板。我用了 CSS Container Queries 结合 Grid 布局,而不是传统的 Media Queries。因为司机可能会把应用窗口缩小到侧边栏运行,Media Queries 是基于视口的,这时候就失效了。

/* 巡检表单容器 */ .inspection-card { container-type: inline-size; display: grid; gap: 1rem; } /* 当容器宽度大于 400px 时,变为两列 */ @container (min-width: 400px) { .inspection-card { grid-template-columns: 1fr 1fr; } .full-width-field { grid-column: span 2; } } /* 适配 iOS 16.4+ 的视口单位 */ :root { --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); }

我特意去查了下兼容性,iOS 16.4 开始对 PWA 的 Manifest 和视口处理好了很多,所以我们直接用了 display: standalone 模式下的全屏高度 dvh (Dynamic Viewport Height),解决了以前在 Safari 里底部被导航栏挡住的问题。

5. PWA与Next.js深度集成:构建与部署的最佳方案对比

我们团队的技术栈是 Next.js,版本定格在 14.x(App Router 模式)。要把 PWA 塞进 Next.js 里,最开始我试过手动写 next.config.js 去生成 manifest.json 和 Service Worker,简直是噩梦。后来我对比了 next-pwaworkbox-webpack-plugin 两种方案。

结论先给出:如果你的项目是标准的 Next.js 应用,且对 PWA 的定制化要求不是特别变态(比如需要非常复杂的动态缓存策略),直接用 next-pwa。但我现在的项目,因为要处理复杂的离线数据逻辑,我最终选择了 workbox-webpack-plugin 手动挡模式,虽然配置烦一点,但控制力更强。

我之前在一个电商项目里用过 next-pwa,它最大的问题是它的默认缓存策略是“激进”的。它会把 / 路径下的所有资源都缓存了,导致我们发版后,用户手机上的 PWA 经常还是旧版,必须手动清除缓存才行。排查下来发现是 next-pwaCacheFirst 策略没有处理好 HTML 文件的更新机制。

后来我重构时,在 next.config.js 里这样配置 Workbox:

// next.config.js const { withWorkbox } = require('@shopify/next-with-workbox'); module.exports = withWorkbox({ workboxConfig: { // 这里我只缓存静态资源,不缓存 HTML // HTML 必须由 NetworkFirst 策略处理,保证每次都拿最新的壳 runtimeCaching: [ { urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|woff2)$/i, handler: 'CacheFirst', options: { cacheName: 'image-cache', expiration: { maxEntries: 500, maxAgeSeconds: 30 * 24 * 60 * 60, // 30天 }, cacheableResponse: { statuses: [0, 200], }, }, }, { urlPattern: /^https:\/\/api\.example\.com\/v1\/.*/, handler: 'NetworkFirst', options: { cacheName: 'api-cache', networkTimeoutSeconds: 5, // 5秒超时后直接用缓存 expiration: { maxEntries: 100, maxAgeSeconds: 5 * 60, // API 缓存5分钟 }, }, }, ], // 注入的预缓存清单,只包括关键的 JS 和 CSS // 这里我手动过滤掉了开发环境的 chunks include: [/\.(?:js|css)$/], exclude: [/\/favicon\./, /\/manifest\./, /\.map$/, /_buildManifest/], }, });

这段代码的核心逻辑是:HTML 必须走网络,静态资源尽量缓存。为什么这么定?因为 Next.js 的路由是基于文件路径的,如果你把 HTML 缓存了,用户点击导航时可能加载的是旧路由对应的旧页面,导致白屏或者 404。

关于 Manifest 配置,我建议不要写在 public 里那个静态文件里,而是用 Next.js 的 Metadata API 动态生成,这样你可以根据环境(DEV/STAGING/PROD)切换图标和名称。

// app/manifest.ts import type { MetadataRoute } from 'next'; export default function manifest(): MetadataRoute.Manifest { return { name: '物流巡检系统', short_name: '巡检', description: '司机端巡检应用', start_url: '/dashboard', display: 'standalone', background_color: '#ffffff', theme_color: '#000000', icons: [ { src: '/icons/icon-192x192.png', sizes: '192x192', type: 'image/png', }, { src: '/icons/icon-512x512.png', sizes: '512x512', type: 'image/png', }, { src: '/icons/icon-512x512.maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable', // 适配 Android 的自适应图标 }, ], // 针对 iOS 16.4+ 的 Web Push 支持,虽然现在还没完全放开,但字段得先留着 // 注意:Safari 目前主要通过 apns 处理推送,但这个 Manifest 字段是标准趋势 related_applications: [], }; }

部署的时候有个坑。我们用的是 Vercel,默认情况下 Vercel 的 Edge Network 会加上一些缓存头,有时候会和 Service Worker 的缓存冲突。我解决的办法是在 vercel.json 里单独给 API 接口设置 Cache-Control: no-cache,确保 PWA 的 NetworkFirst 策略能拿到真实数据。

还有一个关于 iOS 限制 的实际问题。我们在测试时发现,iOS 16.4 之前,PWA 里的 localStorage 限制非常死,而且 Service Worker 更新极其不稳定。升级到 iOS 17 后,情况好了很多,但依然有个怪癖:如果用户在 PWA 里通过 window.open 打开了新窗口,那个窗口的 Service Worker 作用域(scope)有时候会丢失。我的解决方案是强制所有跳转都用 window.location.href,避免在新窗口打开,保持单页应用(SPA)的体验。

6. 未来趋势:PWA结合WebGPU与AI端侧推理的落地场景展望

最近我在研究怎么把 AI 模型塞进我们的 PWA 里,起因是那个巡检应用。司机拍完照上传,后台识别异常,这一来一回延迟太高,而且流量费也贵。我想着能不能直接在手机端跑一个轻量级的视觉模型。

WebGPU 是这里的关键。以前用 WebGL 做计算,那简直是拿画画工具去做数学题,效率极低。WebGPU 是真正的 GPU 通用计算接口。我在 Chrome 121 版本上做了个 Demo,利用 webgpu 调用手机 GPU 进行图像预处理。

我们现在的场景是:司机拍一张货柜照片,PWA 先利用 WebGPU 做图像增强(降噪、锐化),然后把处理好的数据喂给 WebLLM 或者 ONNX Runtime Web 跑的模型。

// 初始化 WebGPU 设备 async function initWebGPU() { if (!navigator.gpu) { throw new Error('当前浏览器不支持 WebGPU,请使用 Chrome 113+ 或 Edge 113+'); } const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' // 对于推理任务,我们需要高性能模式 }); if (!adapter) { throw new Error('无法获取 GPU 适配器'); } const device = await adapter.requestDevice({ requiredLimits: { maxBufferSize: adapter.limits.maxBufferSize, // 获取最大缓冲区 } }); return device; } // 简化版的图像数据转 GPU 纹理 async function processImageWithGPU(imageBitmap) { const device = await initWebGPU(); const texture = device.createTexture({ size: [imageBitmap.width, imageBitmap.height], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, }); // 将图像数据复制到 GPU 纹理 device.queue.copyExternalImageToTexture( { source: imageBitmap }, { texture: texture }, [imageBitmap.width, imageBitmap.height] ); // 这里可以接后续的着色器计算逻辑... console.log('图像已加载至 GPU 显存,等待模型推理'); return texture; }

这个实验跑通后,我们实测在 iPhone 14 上,利用 WebGPU 预处理一张 1080P 的图片并跑一个量化的 MobileNet 模型,耗时从纯 JavaScript 的 800ms 降到了 120ms 左右。这个提升是巨大的,意味着用户点击拍照后,几乎瞬间就能看到识别结果,完全不需要网络请求。

AI 端侧推理 结合 PWA 的另一个优势是 隐私与成本。我们之前有个内部的小工具,用来分析员工的报销单据。如果用云端 API,数据要上传,还得担心合规问题。现在我们把模型(比如 200MB 左右的 INT8 量化模型)通过 PWA 的缓存策略预加载到用户的电脑里。

具体做法是利用 Service Worker 的 Cache API 结合 Range 请求来缓存大文件。虽然模型文件很大,但 PWA 允许在用户首次打开时静默下载,后续版本更新只下载差量包。

// sw.js 中处理大文件(模型)的分片缓存 self.addEventListener('fetch', event => { const url = new URL(event.request.url); // 识别模型文件请求 if (url.pathname.endsWith('.onnx') || url.pathname.includes('/models/')) { event.respondWith( caches.open('model-cache-v1').then(async cache => { // 尝试匹配缓存 const cachedResponse = await cache.match(event.request); if (cachedResponse) { return cachedResponse; } // 没有缓存则去网络拉取 const response = await fetch(event.request); // 克隆响应并存入缓存 // 注意:大文件建议用 response.clone() 但要小心内存占用 await cache.put(event.request, response.clone()); return response; }) ); } });

File System Access API 也是一个正在改变游戏规则的特性。以前 PWA 想保存个文件,只能触发下载,用户还得手动选位置。现在只要用户授权,PWA 可以直接读写本地文件夹。我们设想的一个场景是:司机的巡检报告,PWA 可以直接生成 PDF 并保存到手机指定的“物流报告”文件夹里,甚至可以直接调用系统的分享面板发给微信。这在以前是原生的专利,现在 PWA 也能做到了。

当然,这些新特性目前还是 Chrome/Edge 领先,Safari 慢半拍。比如 File System Access API,在 iOS 17 的 Safari 里还是实验性支持,甚至默认关闭。我的策略是 渐进增强:检测浏览器是否支持 WebGPU,如果支持就走本地推理,如果不支持(比如 Safari 用户),就降级回云端 API 调用。这样既保证了 Chrome 用户的极致体验,也不会让 Safari 用户无法使用。

未来几年,随着 iOS 18/19 对 PWA 限制的进一步放开(特别是 EU 侧载争议后的妥协),这种“无需安装、即开即用、又能跑 AI”的 PWA 模式,可能会在很多企业内部系统(OA、ERP)里替代掉臃肿的原生 App。毕竟,谁也不想为了填个报销单还得去 App Store 更新 200MB 的应用。

站长实战手记

一个差点让我通宵的真实项目

去年我接了个生鲜电商的活儿,老板想省钱,不想同时养 Android 和 iOS 两个团队,问我能不能只做一套 H5。我拍胸脯说没问题,直接上了 PWA。

业务场景很明确:社区团购,用户主要在微信里分享链接下单。我用了 Workbox 配合 Next.js 做静态资源缓存,想着这下离线也能加购物车了,稳得一批。

结果上线第一天就出事了。大促流量一进来,安卓机表现很好,但后台监控显示大量 iOS 用户反馈“页面白屏”或者“添加到主屏幕没反应”。我当时以为是缓存策略写错了,对着 Service Worker 的 installactivate 事件查了半宿,甚至怀疑是 CDN 配置问题。

最后发现,那会儿 iOS 对 PWA 的支持还很鸡肋,尤其是 Safari 的隐身模式下,Service Worker 会直接失效,而且 beforeinstallprompt 事件根本不触发。我不得不写了一套降级方案:如果是 iOS 或者不支持 SW 的环境,直接走普通的响应式网页,只保留核心下单功能,把那些花里胡哨的离线推送先关掉。

最后数据还行,由于省去了 App 下载步骤,转化率比之前的老版 H5 提升了 15%,但 iOS 端的留存确实没安卓好看。

我的真实看法

说实话,PWA 不是万能药。

* 适合上的场景:如果你做的是工具类、内容资讯类,或者像我这样想快速验证市场、不想让用户经过“下载-安装-打开”这种高流失路径的业务,PWA 真的很香。

* 没必要上的场景:别为了用而用。如果你只是个简单的企业展示站,或者你的业务强依赖系统底层能力(比如复杂的蓝牙交互、大规模文件读写),原生开发还是更靠谱。我见过有人为了赶时髦,给个简单的表单页面硬套 Service Worker,结果缓存没清干净,用户一个月都看不到更新,这就本末倒置了。

给读者的建议

别只看文档里那些完美的 Demo。真要做项目,先拿你妈的手机(或者那种几百块的安卓机)跑一跑,看看低端机上的首屏时间。技术选型时,多想想你的用户到底是谁,他们用的是什么设备,别光顾着堆砌新特性。