我之前写过一篇 Uniapp 接 PWA 的基础篇,主要解决 Service Worker 注册、manifest 配置、HTTPS 部署这些底子问题。这篇是同一系列的 02,直接落到电商场景,聊三个最常被问到的点:商品缓存怎么设计才不会让用户看到过期价格、购物车怎么持久化才能在杀进程和断网之后不丢、支付流程怎么优化才能避免跳转丢单和回调丢失。
先给个结论:这三个点不是并列关系。商品缓存是体验层,购物车持久化是数据层,支付流程优化是资金安全层。每往下一层,容错要求翻一倍。缓存写错了顶多显示一次旧价格,购物车写错了用户骂两句,支付写错了是要赔钱的。所以后面我对三者的处理态度完全不一样。
这篇适合已经用 Uniapp 做 H5 电商、想给现有项目加 PWA 能力的前端同学,也适合没上 PWA 但在评估收益的团队。前提是你对uni.request、manifest.json、Service Worker 有基本概念,不需要精通,跟着代码看就行。
1. 先把"为什么做"讲清楚:电商场景下 PWA 的收益边界
1.1 一次真实的流失点观察
我做过的 H5 商城项目里,最典型的用户路径是这样:商品列表进详情,加购物车,再去结算。这条路径里有两个非常明显的流失点。
第一个是商品详情页加载慢。用户从信息流点进来,如果图片和接口数据都要重新拉,弱网环境下首屏经常要 3 到 5 秒。而详情页恰恰是整个浏览路径里流量最大、用户耐心最低的页面。第二个是加购之后跳转。很多用户在购物车和商品详情之间反复横跳对比,每次返回列表都要重新请求,购物车里的临时勾选状态还可能因为一次页面刷新直接清空——这个体验非常伤。
PWA 能改掉的就是这两件事:用本地缓存让重复访问的页面秒开,用本地持久化让购物车和浏览状态在页面刷新或掉线后仍然存在。注意我说的是"改掉",不是"根治"。PWA 解决不了支付不会失败、库存不会超卖,那些是后端和资金侧的事。
1.2 Uniapp 项目里 PWA 到底接管了哪几层
Uniapp 编译成 H5 之后,本质就是一个 Vue 单页应用。PWA 在这里做的事一共三层:
- 静态资源层:JS、CSS、图片这些构建产物,由 Service Worker 用 Cache Storage 缓存,实现二次访问不下载。
- 接口数据层:商品列表、详情、分类这类只读接口,按策略缓存到本地。这是体验优化的核心。
- 本地业务数据层:购物车、用户勾选状态、待支付订单,写进 IndexedDB 或 localStorage,配合服务端做同步。
这三层的信任级别不一样。静态资源可以放心用 cache-first,接口数据要根据时效性选策略,购物车不能只存在内存里。至于支付、下单这类写操作,永远走网络,不做任何 SW 缓存。
1.3 别把 PWA 当成 App 的替代品
这块我必须泼一盆冷水。很多人看 PWA 的宣传,以为装上之后就有了原生 App 的全部能力,包括后台运行、保活、持续定位。不对的。
H5 里的 Service Worker 生命周期受浏览器控制,页面关闭后 SW 可能还在,但能干的事非常有限。你没法在用户完全关闭浏览器之后定时唤醒,也没法像原生 App 那样做后台定位。Uniapp 项目里如果要做plus.geolocation.watchPosition那种后台定位监测,那是 App 端的能力,跟 H5 PWA 无关。
所以立项之前先划清边界:PWA 管体验,管不了系统级能力。电商场景里它最值得做的就三件事——页面秒开、购物车不丢、支付后状态及时纠正。后文全部围绕这三件事展开。
2. 商品缓存:把详情页从"每次拉取"变成"秒开 + 按需更新"
2.1 先确定缓存边界:哪些数据能缓、哪些绝不能缓
商品缓存第一步不是写代码,是给接口分类。我一般按时效性把电商接口分成四类:
| 数据类型 | 典型接口 | 缓存策略 | 说明 |
|---|---|---|---|
| 静态资源 | JS/CSS/图片 | cache-first | 文件名带 hash,天然版本化 |
| 弱时效数据 | 商品详情、评价列表 | network-first / stale-while-revalidate | 可接受几分钟延迟 |
| 强时效数据 | 价格、库存、优惠券 | 不缓存 或 极短 TTL | 直接关系用户是否下单 |
| 用户私有数据 | 购物车、订单、地址 | 本地持久化 + 服务端同步 | 不经过 SW 缓存 |
很多电商项目出事故,就是把价格和库存也当成普通商品数据缓存了。价格变动、库存扣减之后用户看到的还是旧值,下单时被后端驳回,体验比不缓存更差。我后面专门有一节讲这个事故。
2.2 Service Worker 静态资源缓存:用对外版本号做整体淘汰
先说最基础的部分。Uniapp Vite 工程里,public/manifest.webmanifest和public/sw.js会在构建后原样输出到 h5 产物根目录。manifest 长这样:
{ "name": "XX商城", "short_name": "商城", "start_url": "/?source=pwa", "display": "standalone", "background_color": "#ffffff", "theme_color": "#ff5000", "icons": [ { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" } ] }index.html里加上对应 link:
<link rel="manifest" href="/manifest.webmanifest" /> <meta name="theme-color" content="#ff5000" /> <link rel="apple-touch-icon" href="/icons/192.png" />Service Worker 的核心逻辑,我建议用"版本号 + 运行时缓存"的组合。因为 Uniapp 构建出来的 JS/CSS 文件名带 hash,只要版本发版,文件名就变了,天然免疫旧缓存。没必要把所有文件一个个列进 install 清单,那样反而容易把首装时间拖长。
// public/sw.js const CACHE_VERSION = 'shop-static-v3'; const STATIC_CACHE = `${CACHE_VERSION}-assets`; const DATA_CACHE = 'shop-data-v1'; self.addEventListener('install', (event) => { // 只预缓存入口 html,其余文件靠运行时缓存 event.waitUntil( caches.open(STATIC_CACHE) .then((cache) => cache.addAll(['/', '/index.html'])) .then(() => self.skipWaiting()) ); }); self.addEventListener('activate', (event) => { event.waitUntil( caches.keys() .then((keys) => Promise.all( keys .filter((key) => !key.includes(CACHE_VERSION) && key !== DATA_CACHE) .map((key) => caches.delete(key)) )) .then(() => self.clients.claim()) ); }); self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); if (event.request.method !== 'GET') return; if (url.origin !== location.origin) return; // 带 hash 的构建产物:cache-first if (url.pathname.includes('/static/')) { event.respondWith( caches.match(event.request).then((cached) => { if (cached) return cached; return fetch(event.request).then((res) => { const clone = res.clone(); caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone)); return res; }); }) ); return; } // 商品详情接口:network-first if (url.pathname.match(/\/api\/product\/detail\/\d+/)) { event.respondWith( fetch(event.request) .then((res) => { if (res.ok) { const clone = res.clone(); caches.open(DATA_CACHE).then((cache) => cache.put(event.request, clone)); } return res; }) .catch(() => caches.match(event.request)) ); return; } });这里有个关键点:skipWaiting和clients.claim必须一起用。否则发版后用户虽然装上了新 SW,但旧页面还在被旧 SW 控制,要等第二次打开才生效。电商场景用户可不会等第二次,配置好这两个 API 能少一半缓存投诉。
另外提醒一句:SW 的注册路径决定了 scope。如果商城部署在域名子目录(比如/shop/),sw.js必须放在/shop/sw.js,注册时navigator.serviceWorker.register('/shop/sw.js'),否则作用域不覆盖页面路径。这个坑非常隐蔽,部署完发现 SW 装了但从来没生效,先查这一条。
2.3 商品详情 API 的缓存封装:Network-first 策略的取舍
静态资源用 cache-first 没问题,但商品详情接口我强烈建议 network-first。原因很简单:详情页里除了商品介绍,还有价格、库存、促销标记这些强时效字段。网络优先能保证大多数情况下用户拿到的是新数据,同时断网时还能从缓存兜底展示,告诉用户"当前为离线数据"即可。
SW 里做 network-first 的代码上面已经有了,但它管的是"页面发起的 fetch 请求"。Uniapp H5 里uni.request底层是 XHR,XHR 请求默认不会走 SW 的 fetch 事件,这是个很容易被忽略的事实。所以真正的接口缓存,要在业务层再包一层。
我的做法是在utils/cacheRequest.js里做统一封装,内存缓存和本地缓存两层配合:
// utils/cacheRequest.js const memCache = new Map(); /** * 带缓存的请求封装,只建议用于 GET 只读接口 * @param {Object} options uni.request 参数 * @param {Object} cacheOptions { ttl: 毫秒, force: 是否强制刷新 } */ export function requestWithCache(options, cacheOptions = {}) { const { url, data, method = 'GET' } = options; const { ttl = 5 * 60 * 1000, force = false } = cacheOptions; if (method !== 'GET') { return new Promise((resolve, reject) => { uni.request({ ...options, success: resolve, fail: reject }); }); } const key = `${url}:${JSON.stringify(data || {})}`; if (!force) { const hit = memCache.get(key); if (hit && Date.now() - hit.time < ttl) { return Promise.resolve(hit.data); } // 顺便查一下本地缓存 const localHit = uni.getStorageSync(key); if (localHit && Date.now() - localHit.time < ttl) { memCache.set(key, localHit); return Promise.resolve(localHit.data); } } return new Promise((resolve, reject) => { uni.request({ ...options, success: (res) => { if (res.statusCode === 200) { const wrapped = { time: Date.now(), data: res.data }; memCache.set(key, wrapped); try { // 注意:这里要按接口的字段时效性决定是否写本地 uni.setStorageSync(key, wrapped); } catch (e) { // localStorage 满时静默失败 } resolve(res.data); } else { reject(res); } }, fail: reject, }); }); }这层封装解决的是"同一个页面内、短时间内的重复请求",比如用户从购物车反复进详情,返回再进入时命中内存缓存,体验上接近原生。本地缓存是给"页面刷新之后"兜底的。TTL 根据接口类型定:商品详情我给 3 分钟,首页聚合接口给 30 秒,价格库存类接口不走这个封装。
2.4 价格、库存这类强时效字段的缓存标记与兜底刷新
强时效数据我的方案是:接口不缓存,但页面状态缓存。什么意思?商品详情页从网络拿到最新数据之后,把整个渲染状态快照存进内存或 sessionStorage。用户返回列表再点进同一个商品时,先用旧快照立刻渲染,同时后台发起网络请求刷新,数据到达后替换。这样用户看到的是上一帧的旧价格,但旧价格存在时间极短,体验上几乎无感。
落到代码就是"渲染 + 刷新"的经典模式:
// pages/product/detail.vue export default { data() { return { detail: null, refreshing: false }; }, onLoad(query) { this.productId = query.id; // 先用缓存渲染 const snapshot = uni.getStorageSync(`product_snapshot_${query.id}`); if (snapshot) { this.detail = snapshot.detail; } }, onShow() { this.fetchDetail(true); }, methods: { async fetchDetail(force) { this.refreshing = true; try { const data = await requestWithCache( { url: `/api/product/detail/${this.productId}`, method: 'GET' }, { ttl: 0, force } ); this.detail = data; uni.setStorageSync(`product_snapshot_${this.productId}`, { detail: data, time: Date.now(), }); } finally { this.refreshing = false; } }, }, };关键在onShow里强制刷新。用户从支付页返回、从微信跳转返回时,页面会触发onShow,这时候必须拿到最新价格库存,不能继续用缓存。这条规则对所有涉及金额的页面都成立:购物车、结算页、订单详情,一律 onShow 强刷。
3. 购物车持久化:从 localStorage 到 IndexedDB 再到服务端合并
3.1 购物车为什么要持久化,以及 localStorage 的局限
购物车要是只存在 Vuex 或组件的 data 里,刷新就没,页面切后台被系统回收也没。这在手机浏览器里非常常见——用户切去聊个天,回来发现加购的五个商品全没了。所以购物车必须持久化,这是底线。
那直接用uni.setStorageSync行不行?小规模购物车可以,但有两个硬伤:一是 localStorage 容量上限大概 5MB,商品一多、优惠信息再多塞一点就容易爆;二是它是同步 API,主线程频繁读写会卡顿,低端机上能明显感觉到。购物车数据结构本来就是个数组,还要附带每个商品的 sku 快照、促销标记、勾选状态,纯字符串存储既不灵活又低效。
IndexedDB 的容量大得多,异步读写不阻塞渲染,还能按 skuId 做索引,适合购物车这种需要频繁增删改查的数据。
3.2 一个够用的 IndexedDB 封装(兼容 H5 和小程序)
注意,Uniapp 编译到小程序端时是没有 IndexedDB 的。所以封装要加上能力检测,PC 和 H5 端用 IndexedDB,小程序端退回uni.setStorageSync。下面是够用版的封装:
// utils/db.js const DB_NAME = 'shop'; const DB_VERSION = 1; const STORE_CART = 'cart'; let _dbPromise = null; function openDB() { if (_dbPromise) return _dbPromise; _dbPromise = new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined') { reject(new Error('INDEXEDDB_NOT_SUPPORTED')); return; } const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(STORE_CART)) { db.createObjectStore(STORE_CART, { keyPath: 'skuId' }); } }; req.onsuccess = (e) => resolve(e.target.result); req.onerror = () => reject(req.error); }); return _dbPromise; } export async function getCartFromDB() { try { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_CART, 'readonly'); const req = tx.objectStore(STORE_CART).getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); }); } catch (e) { // 降级到 uni storage return uni.getStorageSync('cart_local') || []; } } export async function saveCartToDB(cart) { try { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_CART, 'readwrite'); const store = tx.objectStore(STORE_CART); cart.forEach((item) => store.put(item)); tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); }); } catch (e) { uni.setStorageSync('cart_local', cart); return Promise.resolve(); } }使用上要遵循一个原则:读可以等拿到 DB 数据,写必须异步不阻塞。页面初始化时getCartFromDB()拿数据渲染,用户在页面上点了加购或勾选,先更新内存中的购物车(Vuex/Pinia),渲染立刻响应,再调saveCartToDB()落库。用户永远先看到结果,持久化在后台完成。
3.3 本地购物车与服务端购物车的合并时机
购物车持久化只解决本地问题,登录之后还有服务端购物车。合并时机我见过三种做法:登录成功时合并、每次拉取购物车接口时合并、结算前合并。我推荐"登录成功时合并 + 每次同步前比对版本号"。
合并算法其实不复杂,核心是按skuId做并集,同 sku 数量取"本地和服务端中较大值",但需要处理用户删除过的商品。所以本地每条购物车记录要带一个deleted标记,不能真删,同步时告诉服务端"这个 sku 我要删"。否则很容易出现:用户本地删了一个商品,服务端购物车里还有,合并时又被加回来了,用户会觉得"我明明删了,怎么又出现"。
// store/cart.js async function mergeCart(localCart, serverCart) { const map = new Map(); localCart.forEach((item) => { item.source = 'local'; map.set(item.skuId, item); }); serverCart.forEach((item) => { if (map.has(item.skuId)) { // 已存在:数量取大,标记来源 const local = map.get(item.skuId); map.set(item.skuId, { ...local, count: Math.max(local.count, item.count), selected: local.selected && item.selected, }); } else { map.set(item.skuId, { ...item, source: 'server' }); } }); // 过滤本地已删除的 return Array.from(map.values()).filter((item) => !item.deleted); }合并完成后,本地和服务端的购物车都以合并结果为准,各自落库。这里有个体验细节:合并出来的商品,服务端可能要重新算价格(会员价、促销价),所以合并动作结束后要主动拉一次服务端购物车覆盖本地缓存,保证价格字段是服务端权威值。
3.4 增量同步:防抖、队列、失败重试
购物车每次增删改都立即同步到服务端,接口压力大,还容易把用户的一次连续操作拆成十几条请求。我的做法是防抖 + 队列:
- 用户操作后重置 1.5 秒计时器,期间没有新操作才触发一次同步。
- 同步前把整个购物车全量提交给服务端。购物车本来就是用户私有数据,全量提交比逐条 diff 简单可靠,后端处理也快。
- 同步失败进入重试队列,用指数退避:3 秒、9 秒、27 秒,最多五次。五次都不成功就标记
syncFailed,购物车右上角显示一个小提示"未同步",用户手动下拉或点击重试。 - 断网期间产生的操作全部进本地队列,网络恢复后按顺序补发。
还有一类特殊操作要单独处理:把商品从购物车移除。删除动作最好同步在服务端,不要只改本地。否则用户换个设备登录,购物车里的尸体又回来了。
4. 支付流程优化:断网点、回调幂等与结果通知的闭环
4.1 先画一遍支付链路,找出最痛的两个环节
H5 电商的支付链路一般是这样:从购物车或详情进入结算页 → 创建订单(预下单)→ 后端生成支付参数 → 前端跳转微信/支付宝收银台 → 用户在第三方 App 完成支付 → 第三方回调后端通知支付结果 → 前端轮询或等待返回 → 展示成功页。
这里最痛的是两个环节:一是用户从收银台跳回商城页的那一刻,前端怎么知道支付到底成没成功;二是回调因为网络抖动延迟甚至丢失,后端没收到通知,订单已经扣款但前端一直显示"待支付"。
还有个容易被 PWA 放大的问题:当用户以"已安装 PWA 独立窗口模式"打开商城时,从独立窗口跳到微信收银台再返回,浏览器的页面生命周期和普通标签页不一样,返回后页面可能被冻结或者重新加载。如果前端只依赖返回 URL 和页面onLoad里的参数,会出现返回订单列表还是旧状态的情况。
4.2 预下单与统一收银台的改造
前端不要自己去拼支付参数。正确的做法是后端创建订单时返回一个"统一收银台跳转地址",前端拿到地址后直接window.location.href跳转。这样做的好处是:支付渠道的适配(微信 H5、微信 JSAPI、支付宝手机网站)全部收敛在后端,前端只认一个payUrl字段。
在 Uniapp H5 里注意,uni.requestPayment是 App 端和小程序端的 API,H5 端不能用它调起微信支付。H5 环境要么在微信内置浏览器里走 JSAPI,调WeixinJSBridge.invoke('getBrandWCPayRequest', ...);要么在外部浏览器走微信 H5 支付,由后端返回mweb_url跳转。支付宝则用alipayurl跳转收银台。这些分歧正好被统一收银台方案抹掉。
// api/order.js export async function createOrder(params) { const res = await new Promise((resolve, reject) => { uni.request({ url: '/api/order/precreate', method: 'POST', data: params, success: resolve, fail: reject, }); }); if (res.statusCode !== 200) { throw new Error('创建订单失败'); } return res.data; // { orderId, payUrl, expireAt } }跳转之前,把订单信息写入本地"待确认订单"列表。这个列表是支付优化的核心基础设施,我后面细说。
4.3 支付结果轮询 + 页面恢复时的状态校正
跳去收银台之后,前端要做两件事:轮询订单状态,以及页面onShow恢复时主动校正。
轮询我建议 2 秒一次,最多 10 次,也就是 20 秒。这个时间窗口对大多数 H5 支付够用,再长也没意义,不如让用户回来后自己看到结果。轮询接口必须是只读接口,但不能走 SW 缓存,得强制过网络。
function pollOrderStatus(orderId) { return new Promise((resolve, reject) => { let times = 0; const timer = setInterval(async () => { try { const res = await new Promise((resolve2, reject2) => { uni.request({ url: `/api/order/${orderId}`, method: 'GET', success: resolve2, fail: reject2, }); }); if (res.data.status === 'PAID') { clearInterval(timer); resolve(res.data); } else if (res.data.status === 'CLOSED') { clearInterval(timer); reject(new Error('订单已关闭')); } else if (++times >= 10) { clearInterval(timer); // 不直接报失败,把判断交给页面恢复逻辑 resolve(null); } } catch (e) { clearInterval(timer); reject(e); } }, 2000); }); }轮询超时返回null之后,前端不能武断提示"支付失败"。正确姿态是:"订单状态确认中,稍后自动刷新"。然后把当前订单写进"待确认"列表,等onShow触发再查一次。用户从收银台跳回来,页面onShow一定会触发;就算页面被系统杀掉重开,只要"待确认"列表持久化了,应用启动时照样可以拉起对这些订单的核查。
这里要提一个很实用的落点:所有涉及支付结果回跳的页面,不要只在onLoad里处理一次,要在onShow里统一处理。因为onLoad只在页面首次创建时执行一次,而支付回跳可能发生在页面已经存在的情况下。onShow每次切入都会执行,天然适合做状态校正。
4.4 回调幂等:重复通知不能重复加积分或发货
支付回调幂等是后端的事,但前端必须理解它,否则很容易在联调时误判。场景是这样的:用户支付成功后,第三方支付平台可能因为网络问题重发回调,也可能前端轮询拿到结果和后端回调几乎同时到达,导致后端重复处理一笔订单。
后端处理的共识是:用订单号和支付流水号联合唯一键,在事务里判断订单状态,只有"待支付 → 已支付"这个状态切换才能执行后续动作。后续的加积分、发优惠券、通知仓库发货,全部由订单状态变更事件驱动,而不是直接挂在下单或回调函数里。
前端对应的注意事项是:页面展示"支付成功"时要防重入。轮询拿到 PAID、回调通知也到达、用户手滑点了几次刷新,这几个动作叠加,前端要保证成功页只跳一次。可以在本地存一个paidOrderId集合,展示成功页前检查,已经展示过就不再重复跳转。
另一个容易被忽略的点:支付相关接口包括创建订单、查询订单、支付结果确认,在 Service Worker 里一律放行,不走任何缓存。订单状态是强一致的,缓存任何一帧都可能让用户看到"待支付"而实际已经扣款。我把这条写进了 SW 的 fetch 拦截规则里,/api/order前缀的请求直接return,不respondWith。
5. 真机实测里的坑:缓存错乱、苹果 Safari 与低端机
5.1 商品缓存导致的价格/库存错乱事故
这个事故我有切身体会。早期版本我把商品详情整个接口都交给 SW network-first 缓存,自认为逻辑没问题。上线第二天就有用户反馈:商详页显示"有货",点进结算却说"库存不足";还有用户看到 A 商品的价格,下单时金额却是另一个数。
定位下来是三层问题叠加:第一层是接口返回里包含了价格和库存,被整个缓存了;第二层是 SW 缓存没有 TTL,断网 fallback 之后,即使网络恢复,旧缓存可能被误命中;第三层是页面里"渲染旧快照"和"请求新数据"之间的竞态,后端新数据到了,旧快照还没来得及清理。
修复方案就是前面写的三件套:价格库存字段不进缓存、接口数据只保留 3 分钟快照、涉及金额的页面onShow强制刷新。另外 SW 里对商品详情接口的缓存响应加了Cache-Control: max-age=180的响应头,让浏览器层面的 HTTP 缓存和 SW 缓存策略保持一致,避免两层缓存互相打架。
5.2 iOS Safari 上 Service Worker 的更新陷阱
iOS 的 Safari 对 Service Worker 支持不算晚,但更新策略非常激进,或者说保守得让人头疼。实际遇到的情况是:发版之后,Android Chrome 上用户刷新两次就能用到新 SW,iOS Safari 上经常要杀掉标签页重新打开才生效,甚至要等好几天。
解决方案是组合拳:SW 里加skipWaiting(),activate 里加clients.claim(),页面侧监听registration.update()或者周期性检查。另外在首页放一个静默更新检测,发现新 SW 已激活就弹一条轻提示"发现新版本,点击刷新",比让用户懵着强。
还有一个 iOS 特有的细节:Safari 的隐私模式下,SW 和 IndexedDB 都可能不工作。购物车降级逻辑在这里必须兜底,不然用户在无痕模式加购的体验会直接崩掉。
5.3 低端 Android 上的 IndexedDB 兼容问题
低端 Android 机(尤其国产 ROM 的旧 WebView 内核)上,IndexedDB 偶发打不开,集中在两种场景:系统浏览器版本过老、或用户开启了某种省电/清理模式把站点数据禁用了。表现是indexedDB.open永远停在 pending 状态,既不 success 也不 error,前端代码会卡死在等待回调里。
处理办法是给 open 操作加超时,5 秒内没有成功就强制走 localStorage 降级。前面db.js里的封装其实漏了这层保护,实际项目里必须补上。降级之后 localStorage 容量有限,购物车数据要做精简,只存 skuId、数量、名称、价格四个核心字段,促销文案和图片这些大字段不落本地。
5.4 打包体积与小程序侧的取舍
最后说一个跟缓存关系不大、但做 Uniapp 电商几乎都会碰到的问题:同一套代码 H5 和微信小程序双端打包时,小程序主包体积被限制在 2MB 以内,很容易出现source size 2612kb exceed max limit 2mb的报错。PWA 侧的 H5 构建没有这个限制,但如果你把 H5 需要的 SW 注册、IndexedDB 封装、离线占位页等逻辑也写进了小程序包,体积压力会更大。
我的建议是:PWA 相关模块单独抽目录,H5 构建时通过条件编译引入,小程序端完全不打包这部分。像utils/db.js、utils/sw-register.js这类文件,统一放在src/pwa/下,H5 入口才 import。Uniapp 的条件编译写法可以按平台控制文件加载,这比在代码里到处#ifdef H5干净得多。
6. 接下来还能怎么玩:离线下单草稿与 Web Push
三个核心问题聊完了,最后留几个扩展点,都是我自己项目里验证过、值得做的方向。
第一个是"离线加购"的完整闭环。目前购物车持久化能做到断网时加购、勾选、删减不丢,但结算动作还是必须在线的。下一步可以做"下单草稿":用户断网时可以把已选商品、优惠券、地址整理成草稿存本地,网络恢复后一键提交,避免用户重新选一遍。这个功能对弱网地区的用户提升非常明显。
第二个是 Web Push。PWA 安装后,可以申请 Web Push 权限推送营销和订单状态通知。电商场景里最合适的切入点不是发广告,而是发"支付超时提醒"和"发货状态变化",这两个场景用户不反感,转化也高。注意 Web Push 需要额外配置 VAPID 密钥,服务端要配合改造。
第三个是支付成功后的"结果兜底页"。我在真实用户行为里发现,很多用户支付完根本不会停留看成功页,直接切走。等他们再回来时,如果商城给了"待确认订单列表"并在启动时自动核查,体验会好很多。这就是我在支付章节反复强调的本地说"待确认订单"机制,把它做成独立页面,价值不比支付页本身小。
做电商 PWA 这一年多,我最深的体会是:PWA 的每个能力都不是孤立存在的,缓存、持久化、支付链路优化是相互咬合的一套系统。商品缓存做得好,用户浏览快;购物车持久化做得好,用户不怕加购;支付闭环做得好,用户愿意下单。三者串起来,才是完整的电商体验。别指望一次全做完,先从购物车持久化开始,收益最直接,改动也最小。