为什么我们放弃原生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_name、description这些字段支持不完整,我们升级后发现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-pwa 和 workbox-webpack-plugin 两种方案。
结论先给出:如果你的项目是标准的 Next.js 应用,且对 PWA 的定制化要求不是特别变态(比如需要非常复杂的动态缓存策略),直接用 next-pwa。但我现在的项目,因为要处理复杂的离线数据逻辑,我最终选择了 workbox-webpack-plugin 手动挡模式,虽然配置烦一点,但控制力更强。
我之前在一个电商项目里用过 next-pwa,它最大的问题是它的默认缓存策略是“激进”的。它会把 / 路径下的所有资源都缓存了,导致我们发版后,用户手机上的 PWA 经常还是旧版,必须手动清除缓存才行。排查下来发现是 next-pwa 的 CacheFirst 策略没有处理好 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 的 install 和 activate 事件查了半宿,甚至怀疑是 CDN 配置问题。
最后发现,那会儿 iOS 对 PWA 的支持还很鸡肋,尤其是 Safari 的隐身模式下,Service Worker 会直接失效,而且 beforeinstallprompt 事件根本不触发。我不得不写了一套降级方案:如果是 iOS 或者不支持 SW 的环境,直接走普通的响应式网页,只保留核心下单功能,把那些花里胡哨的离线推送先关掉。
最后数据还行,由于省去了 App 下载步骤,转化率比之前的老版 H5 提升了 15%,但 iOS 端的留存确实没安卓好看。
我的真实看法
说实话,PWA 不是万能药。
* 适合上的场景:如果你做的是工具类、内容资讯类,或者像我这样想快速验证市场、不想让用户经过“下载-安装-打开”这种高流失路径的业务,PWA 真的很香。
* 没必要上的场景:别为了用而用。如果你只是个简单的企业展示站,或者你的业务强依赖系统底层能力(比如复杂的蓝牙交互、大规模文件读写),原生开发还是更靠谱。我见过有人为了赶时髦,给个简单的表单页面硬套 Service Worker,结果缓存没清干净,用户一个月都看不到更新,这就本末倒置了。
给读者的建议
别只看文档里那些完美的 Demo。真要做项目,先拿你妈的手机(或者那种几百块的安卓机)跑一跑,看看低端机上的首屏时间。技术选型时,多想想你的用户到底是谁,他们用的是什么设备,别光顾着堆砌新特性。