网页变APP的离线黑科技PWA实战:外卖订单离线暂存让地铁无信号也能流畅浏览
你有没有过这种经历?下班挤地铁,刷着外卖APP准备点餐,结果信号突然没了,页面卡成PPT,好不容易加载出来也全是空白。这时候要是能像原生APP一样,哪怕没网也能看订单、查菜单、改地址,那该多爽。
今天咱们就来聊聊这个——PWA(渐进式Web应用),它能让你的网页”变身”成APP,关键是:离线也能用。
先别急着划走,我知道你可能听过Service Worker、Manifest这些词,听起来挺吓人。但别怕,咱们今天就用做外卖订单这个真实场景,一步步把这个东西掰开揉碎讲清楚。你只需要跟着做,最后就能做出一个地铁里也能流畅点餐的网页。
先搞清楚:PWA到底是什么鬼
说人话就是:网页穿上了APP的外衣,但骨子里还是网页。
它有三个核心技能:
- 能装——用户在浏览器里点一下”添加到主屏幕”,桌面上就出现一个图标,跟普通APP一模一样
- 能离线——就算没网,之前加载过的内容照样能看
- 能推送——就像微信一样,有新消息会提醒你
这三个技能分别靠三样东西来实现:Manifest文件(身份证)、Service Worker(后台管家)、缓存策略(记忆库)。
咱们一个一个来。
第一部分:给网页办一张”身份证”——Manifest文件
Manifest文件是个JSON,告诉浏览器”我是谁、我该长什么样、图标在哪”。没有它,PWA就只是个普通的网页。
在你的网站根目录新建一个 manifest.json:
{
"name": "闪送外卖",
"short_name": "闪送",
"description": "地铁也能点外卖,离线查看订单",
"start_url": "/?home",
"display": "standalone",
"background_color": "#FF6B35",
"theme_color": "#FF6B35",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon-72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
关键参数解释:
start_url:用户点击图标后打开的页面,/?home表示带着参数打开,这样你才知道是从主屏幕启动的display: standalone:最重要!去掉浏览器的地址栏和导航栏,看起来就像个独立APPicons:至少需要192x192和512x512两个尺寸,不然手机不认识你
然后在HTML里引入它:
<link rel="manifest" href="/manifest.json">
这一步做完,你的网页就拿到了PWA的”准生证”。但光有身份证还不够,还得有个管家来管离线的事。
第二部分:请个”后台管家”——Service Worker
Service Worker是个跑在浏览器后台的脚本,它最大的本事就是拦截网络请求。
什么意思呢?比如用户打开了外卖页面,Service Worker会在后台默默把页面资源缓存起来。下次用户没网了,它就拿出缓存的资源给页面用——就像你提前把零食塞进背包,进隧道后就能吃。
先注册这个管家:
// 在 main.js 里写
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('管家上岗了!', reg))
.catch(err => console.log('管家拒聘了', err))
})
}
注意,sw.js 必须放在网站根目录,不然有作用域问题。
然后写 sw.js:
const CACHE_NAME = 'waimai-v1';
const OFFLINE_URL = '/offline.html';
// 安装阶段:预缓存关键资源
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('缓存仓库开张了');
return cache.addAll([
'/',
'/index.html',
'/styles/main.css',
'/scripts/main.js',
'/offline.html',
'/icons/icon-192.png',
'/icons/icon-512.png'
]);
})
.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 => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
// 有缓存就直接用
if (cachedResponse) {
console.log('命中缓存:', event.request.url);
return cachedResponse;
}
// 没缓存就请求网络
return fetch(event.request)
.then(networkResponse => {
// 成功拿到网络响应,顺便存一份到缓存
if (networkResponse.ok) {
const clone = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => cache.put(event.request, clone));
}
return networkResponse;
})
.catch(() => {
// 网络也失败了,返回离线页面
if (event.request.destination === 'document') {
return caches.match(OFFLINE_URL);
}
return new Response('Offline', { status: 408, statusText: 'Offline' });
});
})
);
});
这段代码的逻辑其实很简单,就像个快递驿站:
- 有新请求进来(用户打开页面)
- 先看看驿站里有没有存过(检查缓存)
- 有就直接给客户(返回缓存)
- 没有就上网取(发起网络请求)
- 取到了顺便再存一份(写入缓存)
- 上网也取不到,那就给个备用的(返回离线页面)
第三部分:核心场景——订单数据离线暂存
光缓存页面不够,真正的黑科技是让订单数据也能离线用。比如用户在外卖页面浏览了商家的菜单,选了几道菜加了购物车,然后进了地铁没信号——这时候购物车数据不能丢。
这就要用到浏览器的 localStorage 或者更高级的 IndexedDB。
方案一:用 localStorage 暂存购物车(简单粗暴)
// cart.js - 购物车模块
class CartManager {
constructor() {
this.storageKey = 'waimai_cart_v1';
}
// 加载购物车
load() {
try {
const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : [];
} catch (e) {
console.error('加载购物车失败', e);
return [];
}
}
// 保存购物车
save(items) {
try {
localStorage.setItem(this.storageKey, JSON.stringify(items));
this.notifyUI();
} catch (e) {
console.error('保存购物车失败', e);
}
}
// 加菜
addItem(item) {
const items = this.load();
const existing = items.find(i => i.id === item.id);
if (existing) {
existing.quantity += 1;
} else {
items.push({ ...item, quantity: 1 });
}
this.save(items);
}
// 减菜
removeItem(itemId) {
let items = this.load();
const index = items.findIndex(i => i.id === itemId);
if (index !== -1) {
if (items[index].quantity > 1) {
items[index].quantity -= 1;
} else {
items.splice(index, 1);
}
this.save(items);
}
}
// 清空
clear() {
localStorage.removeItem(this.storageKey);
this.notifyUI();
}
// 通知UI更新
notifyUI() {
window.dispatchEvent(new CustomEvent('cartChanged', { detail: this.load() }));
}
}
const cart = new CartManager();
方案二:用 IndexedDB 暂存(更强大,适合大量数据)
// db.js - 订单数据库模块
class OrderDB {
constructor() {
this.dbName = 'WaiMaiDB';
this.dbVersion = 2;
this.db = null;
}
// 打开数据库
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onupgradeneeded = event => {
const db = event.target.result;
// 创建订单存储
if (!db.objectStoreNames.contains('orders')) {
const store = db.createObjectStore('orders', { keyPath: 'id' });
store.createIndex('userId', 'userId', { unique: false });
store.createIndex('status', 'status', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
// 创建购物车存储
if (!db.objectStoreNames.contains('cart')) {
db.createObjectStore('cart', { keyPath: 'itemId' });
}
};
request.onsuccess = event => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = event => reject(event.target.error);
});
}
// 保存订单到离线队列
async savePendingOrder(order) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['orders'], 'readwrite');
const store = transaction.objectStore('orders');
order.status = 'pending'; // 待同步状态
order.createdAt = Date.now();
const request = store.add(order);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 获取所有待同步订单
async getPendingOrders() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['orders'], 'readonly');
const store = transaction.objectStore('orders');
const request = store.index('status').getAll('pending');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 更新订单状态(同步成功后)
async updateOrderStatus(id, status) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['orders'], 'readwrite');
const store = transaction.objectStore('orders');
const request = store.get(id);
request.onsuccess = () => {
const order = request.result;
if (order) {
order.status = status;
store.put(order);
resolve(order);
} else {
reject(new Error('订单不存在'));
}
};
request.onerror = () => reject(request.error);
});
}
// 获取购物车
async getCart() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cart'], 'readonly');
const store = transaction.objectStore('cart');
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 添加购物车商品
async addToCart(item) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cart'], 'readwrite');
const store = transaction.objectStore('cart');
const request = store.put(item);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 清空购物车
async clearCart() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cart'], 'readwrite');
const store = transaction.objectStore('cart');
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
const db = new OrderDB();
db.open().then(() => console.log('数据库初始化完成'));
用IndexedDB的好处是:localStorage只能存字符串,且大小限制约5MB,IndexedDB能存复杂对象,大小限制可达几百MB,还能存图片二进制数据。
第四部分:离线检测与状态提示
光有缓存和数据暂存还不够,还得让用户知道”你现在没网了”,不然他可能会误操作。
// network.js - 网络状态管理
class NetworkManager {
constructor() {
this.isOnline = navigator.onLine;
this.statusElement = document.getElementById('network-status');
// 监听网络变化
window.addEventListener('online', () => this.handleOnline());
window.addEventListener('offline', () => this.handleOffline());
}
handleOnline() {
this.isOnline = true;
this.showStatus('network-online');
console.log('网络已恢复,开始同步离线数据...');
this.syncPendingOrders();
}
handleOffline() {
this.isOnline = false;
this.showStatus('network-offline');
console.log('网络已断开,数据将暂存到本地');
}
showStatus(type) {
if (!this.statusElement) return;
this.statusElement.className = type;
this.statusElement.textContent = type === 'network-offline'
? '⚠️ 离线模式 — 订单已暂存'
: '✅ 网络已恢复';
}
// 网络恢复后自动同步
async syncPendingOrders() {
const pendingOrders = await db.getPendingOrders();
if (pendingOrders.length === 0) return;
console.log(`发现 ${pendingOrders.length} 条待同步订单`);
for (const order of pendingOrders) {
try {
// 调用后端API提交订单
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order.data)
});
if (response.ok) {
await db.updateOrderStatus(order.id, 'synced');
console.log(`订单 ${order.id} 同步成功`);
}
} catch (error) {
console.error(`订单 ${order.id} 同步失败`, error);
}
}
}
}
const network = new NetworkManager();
状态提示的CSS也很简单:
#network-status {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 8px 16px;
text-align: center;
font-size: 14px;
z-index: 9999;
transition: all 0.3s;
}
#network-status.network-offline {
background: #FF9800;
color: white;
}
#network-status.network-online {
background: #4CAF50;
color: white;
}
这样用户一眼就能看到当前状态,离线模式下点了下单,数据不会丢,等网络恢复会自动补发。
第五部分:完整的外卖页面实现
把所有东西串起来,做一个实际可用的外卖订单页面:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>闪送外卖</title>
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/styles/main.css">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<meta name="theme-color" content="#FF6B35">
</head>
<body>
<!-- 网络状态提示 -->
<div id="network-status"></div>
<!-- 顶部导航 -->
<header class="app-header">
<div class="logo">🍜 闪送</div>
<div class="cart-badge" id="cart-badge">0</div>
</header>
<!-- 商家信息 -->
<section class="shop-info">
<img src="/images/shop-avatar.jpg" alt="商家头像" class="shop-avatar">
<div class="shop-details">
<h1>麻辣烫老王</h1>
<p>月售 2341 单 · 评分 4.8 · 配送费 ¥3</p>
</div>
</section>
<!-- 菜单列表 -->
<section class="menu-list" id="menu-list">
<!-- 动态渲染 -->
</section>
<!-- 购物车悬浮栏 -->
<div class="cart-bar" id="cart-bar" style="display:none">
<div class="cart-info">
<span id="cart-total">¥0</span>
<span id="cart-count">0件</span>
</div>
<button class="checkout-btn" id="checkout-btn">去结算</button>
</div>
<!-- 离线提示弹窗 -->
<div class="offline-modal" id="offline-modal" style="display:none">
<div class="modal-content">
<div class="modal-icon">📶</div>
<h2>当前无网络</h2>
<p>你的订单已暂存到本地,网络恢复后会自动提交。</p>
<button id="confirm-offline">我知道了</button>
</div>
</div>
<script src="/scripts/db.js"></script>
<script src="/scripts/cart.js"></script>
<script src="/scripts/network.js"></script>
<script src="/scripts/main.js"></script>
<script>
// 注册 Service Worker(和 main.js 里的逻辑一样,这里独立写方便理解)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
</body>
</html>
核心的业务逻辑:
// main.js
const MENU_DATA = [
{ id: 1, name: '麻辣牛肉', price: 18, image: '/images/food1.jpg' },
{ id: 2, name: '红烧肉', price: 22, image: '/images/food2.jpg' },
{ id: 3, name: '酸辣土豆丝', price: 12, image: '/images/food3.jpg' },
{ id: 4, name: '番茄炒蛋', price: 14, image: '/images/food4.jpg' },
{ id: 5, name: '麻婆豆腐', price: 10, image: '/images/food5.jpg' },
{ id: 6, name: '水煮鱼', price: 38, image: '/images/food6.jpg' },
];
let cart = [];
const menuList = document.getElementById('menu-list');
const cartBar = document.getElementById('cart-bar');
const cartBadge = document.getElementById('cart-badge');
const cartTotal = document.getElementById('cart-total');
const cartCount = document.getElementById('cart-count');
const offlineModal = document.getElementById('offline-modal');
const checkoutBtn = document.getElementById('checkout-btn');
// 渲染菜单
function renderMenu() {
menuList.innerHTML = MENU_DATA.map(item => `
<div class="menu-item">
<img src="${item.image}" alt="${item.name}" class="food-img">
<div class="food-info">
<h3>${item.name}</h3>
<p class="food-price">¥${item.price}</p>
</div>
<div class="food-actions">
<button class="btn-minus" data-id="${item.id}">−</button>
<span class="food-qty" id="qty-${item.id}">0</span>
<button class="btn-plus" data-id="${item.id}">+</button>
</div>
</div>
`).join('');
// 绑定加减按钮
document.querySelectorAll('.btn-plus').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = parseInt(e.target.dataset.id);
const item = MENU_DATA.find(i => i.id === id);
cart.addItem(item);
});
});
document.querySelectorAll('.btn-minus').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = parseInt(e.target.dataset.id);
cart.removeItem(id);
});
});
}
// 更新购物车UI
function updateCartUI(items) {
const totalQty = items.reduce((sum, i) => sum + i.quantity, 0);
const totalPrice = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
cartBadge.textContent = totalQty;
cartTotal.textContent = `¥${totalPrice}`;
cartCount.textContent = `${totalQty}件`;
if (totalQty > 0) {
cartBar.style.display = 'flex';
} else {
cartBar.style.display = 'none';
}
// 更新每个商品的显示数量
items.forEach(item => {
const qtyEl = document.getElementById(`qty-${item.id}`);
if (qtyEl) qtyEl.textContent = item.quantity;
});
}
// 结算
async function checkout() {
const items = cart.load();
if (items.length === 0) return;
const order = {
id: 'ORD' + Date.now(),
userId: 'user_001',
shopId: 'shop_001',
items: items,
total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
status: 'pending',
createdAt: Date.now()
};
if (network.isOnline) {
// 有网络,直接提交
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
});
if (response.ok) {
alert('下单成功!');
cart.clear();
}
} catch (e) {
showOfflineModal(order);
}
} else {
// 无网络,暂存
showOfflineModal(order);
}
}
function showOfflineModal(order) {
// 暂存订单
db.savePendingOrder(order);
cart.clear();
offlineModal.style.display = 'flex';
}
// 初始化
renderMenu();
cart.load().forEach(item => updateQtyDisplay(item.id, item.quantity));
cart.observe(updateCartUI);
checkoutBtn.addEventListener('click', checkout);
document.getElementById('confirm-offline').addEventListener('click', () => {
offlineModal.style.display = 'none';
});
第六部分:安装引导——让用户知道可以”装”成APP
光有技术不够,还得让用户知道这玩意儿能装到桌面。加个安装引导:
// install.js
let deferredPrompt;
window.addEventListener('beforeinstallprompt', event => {
// 阻止浏览器自动弹出安装提示
event.preventDefault();
deferredPrompt = event;
showInstallButton();
});
function showInstallButton() {
const installBtn = document.getElementById('install-btn');
if (!installBtn) return;
installBtn.style.display = 'block';
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`用户选择:${outcome}`);
deferredPrompt = null;
installBtn.style.display = 'none';
showInstalledTip();
});
}
function showInstalledTip() {
// 安装成功后显示提示
const tip = document.createElement('div');
tip.className = 'install-tip';
tip.innerHTML = '🎉 已添加到桌面!下次点击图标即可启动';
document.body.appendChild(tip);
setTimeout(() => tip.remove(), 3000);
}
HTML里加个按钮占位:
<button id="install-btn" style="display:none">
📲 安装到桌面
</button>
第七部分:离线页面——没网也要体面
当用户没网打开网站时,不能给他看一个白屏,得有个离线兜底页:
<!-- offline.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线模式 - 闪送外卖</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #FFF8F0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.offline-content {
text-align: center;
max-width: 320px;
}
.offline-icon {
font-size: 80px;
margin-bottom: 20px;
}
h1 {
color: #FF6B35;
font-size: 24px;
margin-bottom: 12px;
}
p {
color: #666;
font-size: 14px;
line-height: 1.8;
margin-bottom: 24px;
}
.stored-orders {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
text-align: left;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.stored-orders h3 {
font-size: 14px;
color: #333;
margin-bottom: 12px;
}
.order-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
font-size: 13px;
}
.order-item:last-child { border-bottom: none; }
.order-status {
color: #FF9800;
font-size: 12px;
}
.retry-btn {
background: #FF6B35;
color: white;
border: none;
padding: 12px 32px;
border-radius: 24px;
font-size: 16px;
cursor: pointer;
}
.retry-btn:active { background: #e55a28; }
</style>
</head>
<body>
<div class="offline-content">
<div class="offline-icon">📡</div>
<h1>当前处于离线模式</h1>
<p>别担心,你选好的菜和订单都已安全保存在本地。<br>连接网络后将自动同步。</p>
<div class="stored-orders" id="stored-orders">
<h3>📦 暂存中的订单</h3>
<div id="orders-list">
<p style="color:#999;font-size:13px;text-align:center;padding:12px">暂无暂存订单</p>
</div>
</div>
<button class="retry-btn" onclick="window.location.reload()">
🔄 刷新重试
</button>
</div>
<script>
// 加载暂存订单
const db = new OrderDB();
db.open().then(() => {
return db.getPendingOrders();
}).then(orders => {
const list = document.getElementById('orders-list');
if (orders.length === 0) {
list.innerHTML = '<p style="color:#999;font-size:13px;text-align:center;padding:12px">暂无暂存订单</p>';
return;
}
list.innerHTML = orders.map(o => `
<div class="order-item">
<span>${o.data.items.length}道菜 · ¥${o.data.total}</span>
<span class="order-status">⏳ 等待同步</span>
</div>
`).join('');
});
</script>
</body>
</html>
这个离线页面会把暂存的订单也展示出来,让用户安心——我知道我的菜没丢。
第八部分:PWA安装后的体验
当用户从主屏幕图标启动你的PWA时,体验跟原生APP几乎一样:
- 没有浏览器地址栏和工具栏
- 全屏显示,有顶部的状态栏
- 启动速度快(因为有缓存)
- 支持推送通知
你在 manifest.json 里设置的 display: standalone 就是关键。
第九部分:常见问题和坑
坑1:Service Worker必须走HTTPS
除非是 localhost,不然你的网站必须上HTTPS,不然浏览器会拒绝注册Service Worker。国内的话,阿里云和腾讯云都有免费的SSL证书,申请一下就好。
坑2:缓存版本管理
改了一版代码,用户还是看到的旧版?因为Service Worker缓存没更新。解决办法是在 CACHE_NAME 里加版本号:
const CACHE_NAME = 'waimai-v2'; // 每次更新改版本号
或者用更智能的策略——缓存HTML但不缓存带hash的JS/CSS文件。
坑3:缓存的HTML页面里有动态数据怎么办?
比如订单列表是实时请求的,不能缓存整个页面。这时候要用运行时缓存策略,只缓存接口返回的数据,不缓存HTML页面:
// 在 sw.js 的 fetch 事件里区分对待
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// API请求用网络优先+缓存策略
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// 页面用缓存优先策略
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
});
这样API数据走网络,页面走缓存,两不误。
第十部分:实战效果——地铁里的外卖体验
现在想象一下这个场景:
你下班进地铁,打开手机里的”闪送”图标(桌面那个橙色图标),点了进去。外面是隧道,手机没信号。
但页面照样打开了,菜单列表清晰可见,你选了三道菜加入购物车。点击结算,弹出提示”当前无网络,订单已暂存”,你确认了。
出了地铁,信号恢复,后台自动把订单提交到了服务器。你打开订单页面,看到状态从”待同步”变成了”已接单”。
整个过程一气呵成,用户完全感知不到中间发生了什么。
总结一下
PWA做离线外卖的核心就三步:
- Manifest文件让网页能被安装到桌面
- Service Worker负责缓存资源和拦截请求,离线时拿出缓存
- IndexedDB负责暂存用户数据(订单、购物车),网络恢复后自动同步
技术栈其实不复杂,核心代码加起来不到200行。真正值钱的是这个思路——用网页的成本,做出接近原生APP的体验。
现在各大厂的APP里,这个技术早就用烂了。美团、饿了么的H5页面其实都有PWA的影子。你学会了,以后不管是做外卖、做电商、做内容APP,离线的思路都是通用的。
有什么想深入的地方,随时问我。
