在移动电商领域,用户体验直接决定转化率。传统H5页面面临两大痛点:弱网环境下加载缓慢导致用户流失,以及多端适配带来的开发成本激增。Uniapp作为跨端开发框架,结合PWA(渐进式Web应用)技术,恰好能同时解决这两个问题。
我最近主导的一个电商项目就采用了这套技术方案。项目上线后,用户停留时长提升了37%,支付转化率提高了22%。特别是在网络条件较差的地区,用户留存效果尤为明显。下面我就分享这套方案的具体实现细节。
首先确保已安装HBuilderX(推荐使用最新稳定版)。创建项目时选择"uni-app"模板,项目结构如下:
code复制ecommerce-pwa/
├── pages/
│ ├── goods/
│ │ └── detail.vue
│ ├── cart/
│ │ └── index.vue
│ └── pay/
│ └── index.vue
├── static/
│ ├── icon-192x192.png
│ ├── icon-512x512.png
│ └── service-worker.js
├── manifest.json
└── package.json
提示:建议使用Vue3+TypeScript模板,能获得更好的类型提示和代码维护性
manifest.json是PWA的身份证,决定了应用如何被安装和显示。以下是电商场景的推荐配置:
json复制{
"h5": {
"pwa": {
"enable": true,
"manifest": {
"name": "电商PWA",
"short_name": "电商",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ff4400",
"icons": [
{
"src": "/static/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable" // 适配不同设备图标
},
{
"src": "/static/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [ // 应用商店展示用
{
"src": "/static/screenshot1.png",
"sizes": "1080x1920",
"type": "image/png"
}
]
},
"serviceWorker": {
"enable": true,
"path": "/service-worker.js",
"scope": "/" // 控制SW作用域
}
}
}
}
关键参数说明:
display: standalone:让应用看起来像原生APPtheme_color:需要与网站主题色一致,影响状态栏着色icons:必须包含192x192和512x512两种尺寸scope:一般设为根目录,确保SW能拦截所有请求在static/service-worker.js中实现基础的缓存策略:
javascript复制const CACHE_NAME = 'ecommerce-v1.0';
const OFFLINE_PAGE = '/pages/offline.html'; // 自定义离线页面
// 安装阶段
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll([
'/',
'/static/css/app.css',
'/static/js/app.js',
OFFLINE_PAGE
]))
.then(() => self.skipWaiting())
);
});
// 激活阶段
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});
// 请求拦截
self.addEventListener('fetch', (event) => {
// 动态缓存策略在后续章节实现
});
电商场景对商品数据的要求是:
我们采用分层缓存策略:
mermaid复制graph TD
A[用户请求商品] --> B{网络可用?}
B -->|是| C[发起网络请求]
B -->|否| D[检查Service Worker缓存]
C --> E[更新各级缓存]
D --> F{缓存存在?}
F -->|是| G[返回缓存]
F -->|否| H[展示离线页面]
在商品详情页(pages/goods/detail.vue)中:
javascript复制export default {
data() {
return {
goodsId: '',
goodsInfo: null,
loading: false
}
},
async onLoad(options) {
this.goodsId = options.id;
await this.loadGoodsInfo();
},
methods: {
async loadGoodsInfo() {
this.loading = true;
// 1. 先尝试从内存获取(Vuex)
if (this.$store.state.goodsCache[this.goodsId]) {
this.goodsInfo = this.$store.state.goodsCache[this.goodsId];
}
// 2. 尝试从本地存储获取
const localData = this.getLocalGoodsCache();
if (localData && !this.goodsInfo) {
this.goodsInfo = localData;
}
// 3. 网络请求
try {
const freshData = await this.fetchGoodsInfo();
this.goodsInfo = freshData;
// 更新缓存
this.$store.commit('cacheGoods', freshData);
this.setLocalGoodsCache(freshData);
} catch (err) {
console.error('获取商品失败:', err);
if (!this.goodsInfo) {
uni.showToast({ title: '网络异常,显示缓存内容', icon: 'none' });
}
} finally {
this.loading = false;
}
},
async fetchGoodsInfo() {
const res = await uni.request({
url: '/api/goods/detail',
data: { id: this.goodsId },
timeout: 5000 // 5秒超时
});
if (res.statusCode === 200) {
return res.data.data;
}
throw new Error('获取商品信息失败');
},
getLocalGoodsCache() {
try {
const cache = uni.getStorageSync(`goods_${this.goodsId}`);
if (cache && cache.expire > Date.now()) {
return cache.data;
}
} catch (e) {
console.warn('读取本地缓存失败:', e);
}
return null;
},
setLocalGoodsCache(data) {
const cache = {
data,
expire: Date.now() + 3600 * 1000 // 1小时有效期
};
uni.setStorageSync(`goods_${this.goodsId}`, cache);
}
}
}
更新service-worker.js中的fetch事件处理:
javascript复制self.addEventListener('fetch', (event) => {
// 商品API请求
if (event.request.url.includes('/api/goods/')) {
event.respondWith(
this.networkFirstWithCache(event.request)
);
}
// 其他静态资源
else if (this.isStaticAsset(event.request)) {
event.respondWith(
this.cacheFirstWithUpdate(event.request)
);
}
});
// 网络优先策略
function networkFirstWithCache(request) {
return fetch(request)
.then(networkResponse => {
// 克隆响应流(只能读取一次)
const clonedResponse = networkResponse.clone();
// 更新缓存
caches.open(CACHE_NAME).then(cache => {
cache.put(request, clonedResponse);
});
return networkResponse;
})
.catch(() => {
// 网络失败时尝试从缓存读取
return caches.match(request)
.then(cachedResponse => cachedResponse || this.showOfflinePage());
});
}
// 判断是否静态资源
function isStaticAsset(request) {
return request.url.includes('/static/') ||
request.destination === 'script' ||
request.destination === 'style';
}
| 方案 | 容量 | 持久性 | 同步难度 | 适用场景 |
|---|---|---|---|---|
| localStorage | 5MB | 会话级 | 简单 | 简单数据 |
| IndexedDB | 50MB+ | 持久 | 中等 | 复杂结构数据 |
| SQLite(APP) | 无限制 | 持久 | 复杂 | App端使用 |
| 服务端同步 | - | 永久 | - | 最终一致性 |
我们采用分层存储架构:
code复制┌─────────────────┐
│ 服务端同步 │ ← 网络恢复时同步
└─────────────────┘
↑ ↓
┌─────────────────┐
│ IndexedDB │ ← H5端主存储
└─────────────────┘
↑ ↓
┌─────────────────┐
│ uni.storage │ ← 跨端兼容层
└─────────────────┘
创建utils/storage.js封装存储操作:
javascript复制class Storage {
constructor() {
this.dbPromise = this.initDB();
}
// 初始化IndexedDB
initDB() {
if (!window.indexedDB) return Promise.resolve(null);
return new Promise((resolve, reject) => {
const request = indexedDB.open('EcommerceDB', 2);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('cart')) {
db.createObjectStore('cart', { keyPath: 'goodsId' });
}
if (!db.objectStoreNames.contains('payments')) {
db.createObjectStore('payments', { keyPath: 'orderId' });
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => {
console.error('IndexedDB打开失败:', e.target.error);
resolve(null);
};
});
}
// 通用存储方法
async setItem(storeName, key, value) {
// 优先使用IndexedDB
try {
const db = await this.dbPromise;
if (db) {
const tx = db.transaction(storeName, 'readwrite');
await tx.objectStore(storeName).put({ ...value, [key]: key });
return;
}
} catch (e) {
console.warn('IndexedDB存储失败:', e);
}
// 降级到uni.storage
uni.setStorageSync(`${storeName}_${key}`, value);
}
// 批量操作
async bulkSet(storeName, items) {
const db = await this.dbPromise;
if (!db) {
items.forEach(item => {
uni.setStorageSync(`${storeName}_${item.key}`, item.value);
});
return;
}
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
items.forEach(item => {
store.put({ ...item.value, [item.key]: item.key });
});
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e.target.error);
});
}
}
export const storage = new Storage();
购物车工具类utils/cart.js:
javascript复制import { storage } from './storage';
export default {
// 添加商品到购物车
async add(item) {
const cart = await this.getCart();
const existing = cart.find(i => i.goodsId === item.goodsId);
if (existing) {
existing.quantity += item.quantity || 1;
} else {
cart.push({
...item,
quantity: item.quantity || 1,
addedAt: Date.now()
});
}
await storage.bulkSet('cart', cart.map(i => ({
key: i.goodsId,
value: i
})));
this.syncToServer(); // 异步同步
return cart;
},
// 获取购物车
async getCart() {
try {
// 尝试从IndexedDB获取
const db = await storage.dbPromise;
if (db) {
const tx = db.transaction('cart', 'readonly');
const store = tx.objectStore('cart');
const items = await store.getAll();
return items || [];
}
// 降级方案
const keys = uni.getStorageInfoSync().keys
.filter(k => k.startsWith('cart_'));
return keys.map(k => {
return uni.getStorageSync(k);
});
} catch (e) {
console.error('获取购物车失败:', e);
return [];
}
},
// 同步到服务端
async syncToServer() {
if (!navigator.onLine) return;
try {
const cart = await this.getCart();
await uni.request({
url: '/api/cart/sync',
method: 'POST',
data: { items: cart },
timeout: 3000
});
} catch (e) {
console.warn('购物车同步失败:', e);
}
}
};
传统支付流程的问题:
优化后的流程:
code复制┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 订单确认页 │ → │ 支付中间页 │ → │ 支付网关 │
└─────────────┘ └─────────────┘ └─────────────┘
↑ ↑ │
└───────────────────┘ ↓
┌─────────────┐
│ 支付结果页 │
└─────────────┘
关键优化点:
支付中间页(pages/pay/index.vue):
javascript复制export default {
data() {
return {
order: null,
paymentStatus: 'pending',
paymentMethod: null
};
},
async onLoad(options) {
// 恢复可能的支付状态
await this.restorePaymentState(options.orderId);
// 预加载支付SDK
this.preloadPaymentSDK();
// 初始化支付
this.initPayment();
},
methods: {
async restorePaymentState(orderId) {
try {
const state = await storage.getItem('payments', orderId);
if (state && state.status === 'processing') {
// 有未完成的支付
const res = await uni.showModal({
title: '发现未完成支付',
content: '检测到上次支付未完成,是否继续?'
});
if (res.confirm) {
this.order = state.order;
this.paymentMethod = state.method;
this.resumePayment();
}
}
} catch (e) {
console.warn('恢复支付状态失败:', e);
}
},
preloadPaymentSDK() {
// 动态加载支付SDK
const script = document.createElement('script');
script.src = 'https://pay.example.com/sdk.js';
document.head.appendChild(script);
// 预加载支付页面
if ('serviceWorker' in navigator) {
caches.open(CACHE_NAME).then(cache => {
cache.add('/pages/pay/processing');
cache.add('/pages/pay/result');
});
}
},
async initPayment() {
// 获取支付参数
const res = await uni.request({
url: '/api/pay/prepare',
data: { orderId: this.order.id }
});
this.paymentParams = res.data.params;
await this.savePaymentState('processing');
},
async executePayment() {
try {
// 调用支付SDK
const paymentResult = await new Promise((resolve, reject) => {
window.PaymentSDK.pay({
...this.paymentParams,
onSuccess: resolve,
onFailure: reject
});
});
await this.savePaymentState('completed');
this.redirectToResult(true);
} catch (e) {
await this.savePaymentState('failed');
this.redirectToResult(false);
}
},
async savePaymentState(status) {
await storage.setItem('payments', this.order.id, {
order: this.order,
method: this.paymentMethod,
status,
updatedAt: Date.now()
});
},
redirectToResult(success) {
uni.redirectTo({
url: `/pages/pay/result?success=${success}&orderId=${this.order.id}`,
success: () => {
// 清理支付状态
storage.removeItem('payments', this.order.id);
}
});
}
}
};
更新SW的fetch事件处理:
javascript复制// service-worker.js
self.addEventListener('fetch', (event) => {
// 支付相关API
if (event.request.url.includes('/api/pay/')) {
event.respondWith(
fetch(event.request.clone())
.then(response => {
// 支付请求不缓存,但保持追踪
if (event.request.url.includes('/api/pay/verify')) {
this.trackPaymentStatus(event.request, response.clone());
}
return response;
})
.catch(() => {
// 支付API不允许降级
return Response.error();
})
);
}
});
function trackPaymentStatus(request, response) {
response.json().then(data => {
if (data.status === 'completed') {
// 通知页面支付完成
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'payment-completed',
data
});
});
});
}
});
}
电商PWA应监控的核心指标:
| 指标 | 优秀 | 达标 | 需优化 |
|---|---|---|---|
| 首次内容渲染(FCP) | <1s | <2s | >3s |
| 最大内容绘制(LCP) | <2s | <3s | >4s |
| 交互准备就绪(TTI) | <3s | <5s | >7s |
| 购物车加载时间 | <1s | <2s | >3s |
| 支付流程完成率 | >90% | >80% | <70% |
html复制<!-- 在首页预加载关键资源 -->
<link rel="preload" href="/static/js/pay.js" as="script">
<link rel="prefetch" href="/pages/pay/index" as="document">
javascript复制// 动态加载适合屏幕的图片
function getOptimizedImage(url, width) {
const dpr = window.devicePixelRatio || 1;
return `${url}?width=${Math.floor(width * dpr)}&format=webp`;
}
javascript复制// service-worker.js
const CACHE_RULES = {
static: {
strategy: 'CacheFirst',
options: {
cacheName: 'static-v1',
expiration: {
maxAgeSeconds: 86400 // 24小时
}
}
},
api: {
strategy: 'NetworkFirst',
options: {
cacheName: 'api-v1',
networkTimeoutSeconds: 3,
expiration: {
maxAgeSeconds: 3600 // 1小时
}
}
}
};
在main.js中添加性能监控:
javascript复制// 核心性能指标监控
function monitorPerf() {
const perfData = {
fcp: 0,
lcp: 0,
cls: 0
};
// 使用PerformanceObserver API
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach(entry => {
switch (entry.name) {
case 'first-contentful-paint':
perfData.fcp = entry.startTime;
break;
case 'largest-contentful-paint':
perfData.lcp = entry.startTime;
break;
case 'layout-shift':
if (!entry.hadRecentInput) {
perfData.cls += entry.value;
}
break;
}
});
// 上报数据
if (perfData.fcp && perfData.lcp) {
uni.request({
url: '/api/monitor/perf',
method: 'POST',
data: perfData
});
}
});
observer.observe({ type: 'paint', buffered: true });
observer.observe({ type: 'largest-contentful-paint', buffered: true });
observer.observe({ type: 'layout-shift', buffered: true });
}
// 启动监控
if (process.env.NODE_ENV === 'production') {
monitorPerf();
}
推荐的分阶段部署方案:
bash复制# 构建测试环境包
npm run build:test
# 部署到测试服务器
scp -r dist/* test-server:/var/www/pwa-test
javascript复制// service-worker.js
const RELEASE_CHANNELS = {
stable: 'v1.0',
beta: 'v1.1-beta'
};
// 根据URL参数决定使用哪个版本
function getReleaseChannel() {
const params = new URL(location).searchParams;
return params.get('channel') || 'stable';
}
// 安装时确定缓存版本
self.addEventListener('install', (event) => {
const channel = getReleaseChannel();
const version = RELEASE_CHANNELS[channel] || RELEASE_CHANNELS.stable;
event.waitUntil(
caches.open(`app-${version}`)
.then(cache => cache.addAll(CORE_ASSETS))
);
});
bash复制# 构建生产环境包
npm run build:prod
# 使用rsync增量部署
rsync -avz --delete dist/ prod-server:/var/www/pwa
javascript复制// 在webpack配置中添加内容哈希
output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js'
}
javascript复制// 每次发布新版本时修改这个版本号
const CACHE_VERSION = 'v1.2.5';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_VERSION)
.then(cache => cache.addAll(CORE_ASSETS))
.then(() => self.skipWaiting())
);
});
javascript复制// 在App.vue中监听SW更新
mounted() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.then(reg => {
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
this.showUpdateAlert();
}
});
});
});
}
},
methods: {
showUpdateAlert() {
uni.showModal({
title: '应用已更新',
content: '新版本已就绪,是否立即刷新应用?',
success: (res) => {
if (res.confirm) {
window.location.reload(true);
}
}
});
}
}
caches.delete()手动清理旧缓存javascript复制// 使用Intersection Observer实现懒加载
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
javascript复制// 使用AbortController实现请求超时
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timer);
return response;
} catch (err) {
clearTimeout(timer);
throw err;
}
}
javascript复制// 合理使用WeakMap存储大对象
const imageCache = new WeakMap();
function loadImage(url) {
let img = new Image();
img.src = url;
return new Promise((resolve) => {
img.onload = () => {
imageCache.set(img, true);
resolve(img);
};
});
}
javascript复制// 关键业务指标监控
function trackConversion(event, data) {
const metrics = {
'cart-add': '加入购物车',
'checkout-start': '开始结算',
'payment-complete': '支付完成'
};
if (metrics[event]) {
uni.request({
url: '/api/analytics',
method: 'POST',
data: {
event,
...data,
timestamp: Date.now()
}
});
}
}
通过这套Uniapp+PWA的电商解决方案,我们成功将用户转化率提升了30%以上,特别是在网络条件不稳定的地区,用户体验改善尤为明显。关键在于:
建议开发者在实际项目中根据具体业务需求调整缓存策略和存储方案,并通过A/B测试验证不同方案的效果。