餐厅点餐系统用HTML5离线缓存实现无网络也能下单结账 医生诊所挂号不卡顿 外卖骑单手顺订单不丢失 一文讲清技术原理与实施步骤
一、先从一个尴尬场景说起
想象一下,你是一家连锁火锅店的店长。某天晚高峰,店里顾客爆满,网络突然断了——不是路由器坏了,是运营商光缆被挖断了。
这时候,收银台的小妹慌了:
“老板,系统登不上去了,单点不了,结账也结不了!”
顾客已经开始排队等位了,店里乱成一团。
同样的场景,可能发生在:
- 医院:挂号窗口网络卡顿,病人等了半小时,窗口电脑直接白屏
- 外卖站:骑手在地下室接单,信号满格但网络延迟极高,订单显示”请求超时”
这些问题的本质是一样的:应用严重依赖网络,一旦断网,业务直接瘫痪。
好消息是,HTML5早就给了我们一把钥匙——Service Worker + Cache API,能让Web应用变成”离线也能跑”的超级应用。
二、技术原理:Service Worker到底是什么?
先别被名字吓到。Service Worker本质上是一个跑在浏览器后台的JavaScript代理。
它干的事儿,你可以这样理解:
┌─────────────────────────────────────────────────────────────┐
│ 你的浏览器 │
│ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ 用户界面 │ ────▶ │ Service Worker│ ────▶ │ 服务器 │ │
│ │ (App) │ │ (网络代理) │ │ (云端) │ │
│ └──────────┘ └──────┬───────┘ └──────────┘ │
│ │ │
│ ┌─────▼─────┐ │
│ │ 本地缓存 │ │
│ │ (Cache) │ │
│ └───────────┘ │
└─────────────────────────────────────────────────────────────┘
它有几个关键能力:
- 拦截网络请求:在请求到达服务器之前,先看看缓存里有没有,有就直接返回
- 后台同步:断网时的操作可以先存起来,网络恢复后再自动提交
- 推送通知:即使页面关了,也能收到消息推送
三、核心实现:从零搭建离线缓存系统
3.1 注册Service Worker
首先需要创建一个专门存放Service Worker代码的文件:
// service-worker.js
const CACHE_NAME = 'app-cache-v1.2.3';
const OFFLINE_URL = '/offline.html';
// 缓存的核心资源(静态文件)
const STATIC_ASSETS = [
'/',
'/index.html',
'/css/app.css',
'/js/app.js',
'/js/service-worker-register.js',
'/images/logo.png',
'/offline.html'
];
// 安装阶段:预缓存关键资源
self.addEventListener('install', event => {
console.log('[Service Worker] 安装中...');
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
console.log('[Service Worker] 缓存静态资源');
return cache.addAll(STATIC_ASSETS);
}).then(() => {
// 安装完成后立即激活
return self.skipWaiting();
})
);
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', event => {
console.log('[Service Worker] 激活中...');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
}).then(() => {
return self.clients.claim(); // 立即接管所有页面
})
);
});
3.2 智能请求拦截策略
这一步是精髓所在。不是简单粗暴地”有缓存就用缓存”,而是要根据数据类型智能决策:
// 路由策略(Request Routing)
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// ─── 策略1:关键页面 → 网络优先,缓存兜底 ───
if (request.destination === 'document') {
event.respondWith(
fetch(request)
.then(networkResponse => {
// 网络成功,更新缓存
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(request, responseClone);
});
return networkResponse;
})
.catch(() => {
// 网络失败,返回缓存版本或离线页
return caches.match(OFFLINE_URL);
})
);
return;
}
// ─── 策略2:数据API → 网络优先,失败则读缓存 ───
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request)
.then(response => {
// 成功:存入缓存,并立即返回
const responseClone = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(request, responseClone);
});
return response;
})
.catch(() => {
// 失败:返回缓存版本(如果有的话)
return caches.match(request);
})
);
return;
}
// ─── 策略3:静态资源 → 缓存优先,回退网络 ───
event.respondWith(
caches.match(request)
.then(cachedResponse => {
if (cachedResponse) {
// 缓存命中,同时后台更新缓存
const fetchPromise = fetch(request).then(networkResponse => {
caches.open(CACHE_NAME).then(cache => {
cache.put(request, networkResponse.clone());
});
return networkResponse;
}).catch(() => cachedResponse);
return cachedResponse; // 先返回缓存
}
return fetch(request); // 没有缓存,直接请求网络
})
);
});
这个策略的逻辑是:
- 静态资源(CSS/JS/图片):优先用缓存,后台偷偷更新,速度快
- API数据:先试网络,失败了再从缓存读,保证数据不丢
- 页面跳转:先试网络拿到最新内容,断网时展示离线页
3.3 离线页面
断网时,用户不应该看到浏览器默认的”无网络”页面。我们需要一个友好的离线提示页:
<!-- /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: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.container {
text-align: center;
padding: 40px 20px;
max-width: 400px;
}
.icon { font-size: 80px; margin-bottom: 20px; }
h1 { color: #333; margin-bottom: 12px; font-size: 24px; }
p { color: #666; line-height: 1.6; margin-bottom: 24px; }
.btn {
display: inline-block;
background: #ff6b35;
color: white;
padding: 12px 32px;
border-radius: 25px;
text-decoration: none;
font-size: 16px;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📡</div>
<h1>哎呀,网络断了</h1>
<p>别担心,你添加的订单已经保存在本地了。<br>网络恢复后会自动同步。</p>
<a href="/" class="btn" id="retryBtn">重试连接</a>
</div>
<script>
// 检测网络恢复
document.getElementById('retryBtn').addEventListener('click', () => {
if (navigator.onLine) {
location.reload();
} else {
alert('正在等待网络恢复...');
}
});
window.addEventListener('online', () => location.reload());
</script>
</body>
</html>
3.4 前端注册代码
// js/service-worker-register.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js', {
scope: '/'
})
.then(registration => {
console.log('[SW] 注册成功,scope:', registration.scope);
// 检测更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('[SW] 新版本的Service Worker已就绪,刷新页面生效');
showToast('检测到更新,刷新页面以使用最新版本');
}
});
});
})
.catch(error => {
console.error('[SW] 注册失败:', error);
});
});
}
四、场景一:餐厅点餐系统——断网也能下单结账
4.1 痛点分析
餐厅场景最核心的问题是:订单数据不能丢。
- 顾客点了菜,网络断了
- 服务员不知道订单有没有提交成功
- 后厨收不到单,顾客干等
- 结账时数据对不上,老板亏钱
4.2 核心功能实现
(1)订单离线存储
// js/order-offline.js
class OfflineOrderManager {
constructor() {
this.storageKey = 'offline_orders';
this.syncing = false;
}
// 保存订单到本地(断网时用)
saveOrder(orderData) {
const orders = this.getOfflineOrders();
orderData.id = this.generateId();
orderData.offline = true; // 标记为离线订单
orderData.createdAt = Date.now();
orderData.status = 'pending_sync'; // 待同步
orders.push(orderData);
localStorage.setItem(this.storageKey, JSON.stringify(orders));
console.log('[离线订单] 已保存:', orderData);
// 提示用户
this.showOfflineHint('订单已保存,网络恢复后自动上传');
return orderData;
}
// 尝试同步(网络恢复时调用)
async syncAll() {
if (this.syncing) return;
this.syncing = true;
const orders = this.getOfflineOrders();
console.log(`[离线同步] 发现 ${orders.length} 条待同步订单`);
for (const order of orders) {
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
});
if (response.ok) {
// 同步成功,从本地删除
order.status = 'synced';
order.syncedAt = Date.now();
} else {
order.status = 'sync_failed';
}
} catch (error) {
console.error('[离线同步] 订单提交失败:', error);
order.status = 'sync_failed';
}
}
// 只保留未同步成功的订单
const remaining = orders.filter(o => o.status !== 'synced');
localStorage.setItem(this.storageKey, JSON.stringify(remaining));
this.syncing = false;
this.showOfflineHint(`${orders.length - remaining.length} 条订单同步完成`);
}
getOfflineOrders() {
const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : [];
}
generateId() {
return 'offline_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
showOfflineHint(message) {
// 简单的Toast提示
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: #333; color: white; padding: 12px 24px; border-radius: 8px;
font-size: 14px; z-index: 9999; opacity: 0; transition: opacity 0.3s;
`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.style.opacity = '1', 10);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
}
// 全局单例
window.offlineOrderManager = new OfflineOrderManager();
(2)下单时的智能判断
// js/order-submit.js
async function submitOrder(orderData) {
const isOnline = navigator.onLine;
if (isOnline) {
// 在线:直接提交
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orderData)
});
if (response.ok) {
const result = await response.json();
showToast('✅ 下单成功!订单号: ' + result.orderId);
return result;
} else {
throw new Error('服务器返回错误');
}
} catch (error) {
console.error('提交失败,转为离线保存:', error);
// 自动降级为离线保存
return window.offlineOrderManager.saveOrder(orderData);
}
} else {
// 断网:直接离线保存
console.log('网络断开,订单将离线保存');
return window.offlineOrderManager.saveOrder(orderData);
}
}
// 监听网络状态变化
window.addEventListener('online', () => {
showToast('🌐 网络已恢复,正在同步数据...');
window.offlineOrderManager.syncAll();
});
window.addEventListener('offline', () => {
showToast('⚠️ 网络已断开,将使用离线模式');
});
(3)完整的餐厅点餐页面结构
<!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="stylesheet" href="/css/app.css">
<link rel="manifest" href="/manifest.json">
</head>
<body>
<header class="app-header">
<div class="logo">🍜 智慧餐厅</div>
<div class="network-status" id="networkStatus">
<span class="status-dot online"></span> 在线
</div>
</header>
<main class="order-app">
<!-- 菜单区域 -->
<section class="menu-section">
<h2>今日推荐</h2>
<div class="menu-grid" id="menuGrid">
<!-- 由JS动态渲染 -->
</div>
</section>
<!-- 购物车 -->
<section class="cart-section">
<h2>🛒 我的订单 <span id="cartCount">(0)</span></h2>
<div class="cart-items" id="cartItems"></div>
<div class="cart-total">
<span>合计:</span>
<span class="total-amount" id="totalAmount">¥0.00</span>
</div>
<button class="submit-btn" id="submitBtn" onclick="handleOrderSubmit()">
提交订单
</button>
</section>
</main>
<script src="/js/app.js"></script>
<script src="/js/order-offline.js"></script>
<script src="/js/order-submit.js"></script>
<script src="/js/service-worker-register.js"></script>
</body>
</html>
/* css/app.css */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
background: #f8f8f8;
padding-bottom: 80px;
}
.app-header {
background: linear-gradient(135deg, #ff6b35, #f7931e);
color: white;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.network-status {
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4CAF50;
}
.status-dot.offline { background: #f44336; }
.menu-section { padding: 20px; }
.menu-section h2 { margin-bottom: 16px; font-size: 18px; color: #333; }
.menu-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.menu-item {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.menu-item img {
width: 100%;
height: 120px;
object-fit: cover;
}
.menu-item-info { padding: 10px; }
.menu-item-name { font-size: 14px; font-weight: 600; color: #333; }
.menu-item-price { color: #ff6b35; font-weight: 700; margin-top: 4px; }
.cart-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 16px 20px;
box-shadow: 0 -2px 20px rgba(0,0,0,0.1);
z-index: 100;
}
.cart-items {
max-height: 150px;
overflow-y: auto;
margin-bottom: 12px;
}
.cart-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.submit-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #ff6b35, #f7931e);
color: white;
border: none;
border-radius: 25px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.submit-btn:active { opacity: 0.9; }
// js/app.js
const menuData = [
{ id: 1, name: '麻辣火锅', price: 88, image: '/images/hotpot.jpg' },
{ id: 2, name: '番茄锅底', price: 68, image: '/images/tomato.jpg' },
{ id: 3, name: '肥牛卷', price: 38, image: '/images/beef.jpg' },
{ id: 4, name: '鲜虾滑', price: 42, image: '/images/shrimp.jpg' },
{ id: 5, name: '毛肚', price: 36, image: '/images/tripe.jpg' },
{ id: 6, name: '鸭肠', price: 28, image: '/images/intestine.jpg' },
];
let cart = [];
function renderMenu() {
const grid = document.getElementById('menuGrid');
grid.innerHTML = menuData.map(item => `
<div class="menu-item" onclick="addToCart(${item.id})">
<img src="${item.image}" alt="${item.name}"
onerror="this.src='/images/default.jpg'">
<div class="menu-item-info">
<div class="menu-item-name">${item.name}</div>
<div class="menu-item-price">¥${item.price}</div>
</div>
</div>
`).join('');
}
function addToCart(menuId) {
const item = menuData.find(m => m.id === menuId);
const existing = cart.find(c => c.id === menuId);
if (existing) {
existing.quantity++;
} else {
cart.push({ ...item, quantity: 1 });
}
renderCart();
}
function renderCart() {
const container = document.getElementById('cartItems');
const countEl = document.getElementById('cartCount');
const totalEl = document.getElementById('totalAmount');
countEl.textContent = `(${cart.reduce((sum, c) => sum + c.quantity, 0)})`;
if (cart.length === 0) {
container.innerHTML = '<div style="text-align:center;color:#999;padding:20px">购物车是空的</div>';
} else {
container.innerHTML = cart.map(item => `
<div class="cart-item">
<span>${item.name} × ${item.quantity}</span>
<span>¥${item.price * item.quantity}</span>
</div>
`).join('');
}
const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
totalEl.textContent = `¥${total.toFixed(2)}`;
}
function handleOrderSubmit() {
if (cart.length === 0) {
showToast('请先选择菜品');
return;
}
const orderData = {
tableId: getCurrentTable(),
items: cart.map(c => ({
menuId: c.id,
name: c.name,
price: c.price,
quantity: c.quantity
})),
totalAmount: cart.reduce((sum, c) => sum + c.price * c.quantity, 0),
createdAt: Date.now()
};
submitOrder(orderData).then(result => {
if (result.offline) {
showToast('⚠️ 网络异常,订单已离线保存');
} else {
showToast('✅ 下单成功!');
cart = [];
renderCart();
}
});
}
function getCurrentTable() {
// 实际项目中从URL参数或登录状态获取
return new URLSearchParams(window.location.search).get('table') || '未知';
}
function showToast(message) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(0,0,0,0.8); color: white; padding: 12px 24px;
border-radius: 8px; font-size: 14px; z-index: 9999;
animation: fadeIn 0.3s;
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
// 初始化
renderMenu();
renderCart();
updateNetworkStatus();
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);
function updateNetworkStatus() {
const el = document.getElementById('networkStatus');
const isOnline = navigator.onLine;
el.innerHTML = isOnline
? '<span class="status-dot online"></span> 在线'
: '<span class="status-dot offline"></span> 离线';
}
五、场景二:医生诊所挂号——告别卡顿
5.1 诊所场景的特殊性
医院和诊所的场景与餐厅不同:
- 不能丢单:挂号信息必须准确
- 速度要快:患者排队等候,每一秒都很宝贵
- 数据要准:医生排班、号源数量必须实时更新
5.2 挂号系统的离线缓存策略
// service-worker.js - 挂号系统专用策略
const CLINIC_CACHE = 'clinic-cache-v2.0';
// 挂号系统需要缓存的数据类型
const CLINIC_CACHEABLE = {
// 医生排班(变化不频繁)
'/api/doctors/schedule': {
strategy: 'stale-while-revalidate', // 先用缓存,后台更新
maxAge: 3600000 // 1小时过期
},
// 科室列表(几乎不变)
'/api/departments': {
strategy: 'cache-first', // 优先用缓存
maxAge: 86400000 // 24小时
},
// 号源状态(必须实时)
'/api/slots': {
strategy: 'network-first', // 必须网络,失败则提示
maxAge: 0 // 不过期
},
// 患者个人信息
'/api/patient/profile': {
strategy: 'cache-first',
maxAge: 300000 // 5分钟
}
};
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
const cacheConfig = CLINIC_CACHEABLE[url.pathname];
if (!cacheConfig) {
// 默认策略
event.respondWith(fetch(event.request));
return;
}
switch (cacheConfig.strategy) {
case 'cache-first':
event.respondWith(cacheFirst(event.request, cacheConfig.maxAge));
break;
case 'network-first':
event.respondWith(networkFirst(event.request));
break;
case 'stale-while-revalidate':
event.respondWith(staleWhileRevalidate(event.request, cacheConfig.maxAge));
break;
}
});
// 缓存优先策略
async function cacheFirst(request, maxAge = 0) {
const cache = await caches.open(CLINIC_CACHE);
const cached = await cache.match(request);
if (cached) {
// 检查是否过期
const age = Date.now() - (cached.headers.get('sw-cache-time') || Date.now());
if (age < maxAge || maxAge === 0) {
// 缓存有效,后台更新
fetch(request).then(response => {
cache.put(request, response.clone());
}).catch(() => {});
return cached;
}
}
// 缓存无效,请求网络
const response = await fetch(request);
cache.put(request, response.clone());
return response;
}
// 网络优先策略
async function networkFirst(request) {
try {
const response = await fetch(request);
return response;
} catch (error) {
// 网络失败,返回缓存(如果有)
const cache = await caches.open(CLINIC_CACHE);
return cache.match(request) || new Response('网络异常,请重试', { status: 503 });
}
}
// 先缓存后更新
async function staleWhileRevalidate(request, maxAge) {
const cache = await caches.open(CLINIC_CACHE);
const cached = await cache.match(request);
// 立即返回缓存
const fetchPromise = fetch(request).then(response => {
cache.put(request, response.clone());
return response;
}).catch(() => cached);
return cached || fetchPromise;
}
5.3 挂号流程的完整实现
// js/clinic-appointment.js
class ClinicApp {
constructor() {
this.doctors = [];
this.slots = [];
this.currentPatient = null;
this.pendingAppointment = null;
}
// 加载医生排班(离线缓存)
async loadDoctors() {
try {
// 先尝试从Service Worker缓存获取
const response = await fetch('/api/doctors/schedule', {
cache: 'force-cache' // 优先用缓存
});
if (response.ok) {
this.doctors = await response.json();
this.renderDoctorList();
return;
}
} catch (e) {
// 缓存命中但网络失败
const cachedData = await this.getCachedDoctors();
if (cachedData) {
this.doctors = cachedData;
this.renderDoctorList();
this.showNotice('显示的是缓存数据,可能不是最新');
}
}
}
// 获取实时号源(必须在线)
async loadSlots(doctorId, date) {
const response = await fetch(`/api/slots?doctor=${doctorId}&date=${date}`);
if (!response.ok) {
throw new Error('号源加载失败');
}
this.slots = await response.json();
this.renderSlots();
}
// 提交挂号(带离线重试)
async submitAppointment(appointmentData) {
this.pendingAppointment = appointmentData;
try {
const response = await fetch('/api/appointments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(appointmentData)
});
if (response.ok) {
const result = await response.json();
this.showSuccess('挂号成功!诊室:' + result.room);
return result;
} else if (response.status === 429) {
// 请求过于频繁
this.showWarning('操作太快了,请稍后再试');
} else {
throw new Error('服务器错误');
}
} catch (error) {
// 网络故障,存入离线队列
await this.saveOfflineAppointment(appointmentData);
this.showWarning('网络异常,挂号已暂存,网络恢复后自动提交');
}
}
// 保存离线挂号
async saveOfflineAppointment(data) {
const orders = JSON.parse(localStorage.getItem('offline_appointments') || '[]');
orders.push({
...data,
id: 'apt_' + Date.now(),
createdAt: Date.now(),
status: 'pending_sync'
});
localStorage.setItem('offline_appointments', JSON.stringify(orders));
}
// 同步离线挂号
async syncAppointments() {
const orders = JSON.parse(localStorage.getItem('offline_appointments') || '[]');
if (orders.length === 0) return;
for (const order of orders) {
try {
const response = await fetch('/api/appointments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
});
if (response.ok) {
order.status = 'synced';
}
} catch (e) {
order.status = 'failed';
}
}
const remaining = orders.filter(o => o.status !== 'synced');
localStorage.setItem('offline_appointments', JSON.stringify(remaining));
}
showSuccess(msg) {
this.showToast(msg, 'success');
}
showWarning(msg) {
this.showToast(msg, 'warning');
}
showNotice(msg) {
this.showToast(msg, 'info');
}
showToast(msg, type) {
const colors = {
success: '#4CAF50',
warning: '#FF9800',
info: '#2196F3'
};
const toast = document.createElement('div');
toast.textContent = msg;
toast.style.cssText = `
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: ${colors[type] || colors.info}; color: white;
padding: 12px 24px; border-radius: 8px; font-size: 14px;
z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
}
// 初始化
const clinicApp = new ClinicApp();
clinicApp.loadDoctors();
// 监听网络恢复
window.addEventListener('online', () => {
clinicApp.syncAppointments();
clinicApp.loadDoctors().then(() => {
clinicApp.showToast('📡 网络已恢复,数据已刷新', 'success');
});
});
六、场景三:外卖骑单手顺订单——不丢失的秘诀
6.1 骑手场景的特殊挑战
外卖骑手是移动中的用户:
- 在地下室、电梯里信号极差
- 4G/5G网络频繁切换
- 浏览器可能在后台被系统杀掉
- 订单信息必须实时、准确,丢失一个订单就是几十块损失
6.2 Service Worker + IndexedDB 的双重保障
// service-worker.js - 骑手订单系统
const RIDER_CACHE = 'rider-cache-v3.0';
const ORDER_QUEUE = 'rider-order-queue';
self.addEventListener('fetch', event => {
// 订单状态更新 - 网络优先
if (event.request.url.includes('/api/orders/')) {
if (event.request.method === 'POST' || event.request.method === 'PUT') {
// 订单操作先存队列,再尝试发送
event.respondWith(
fetch(event.request).catch(() => {
// 网络失败,存入IndexedDB队列
event.waitUntil(saveToOrderQueue(event.request));
return new Response(JSON.stringify({ queued: true }), {
headers: { 'Content-Type': 'application/json' }
});
})
);
return;
}
}
// 订单列表 - 缓存优先(减少网络请求)
if (event.request.url.includes('/api/orders')) {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) {
// 返回缓存,同时后台更新
fetch(event.request).then(response => {
caches.open(RIDER_CACHE).then(cache => {
cache.put(event.request, response);
});
}).catch(() => {});
return cached;
}
return fetch(event.request);
})
);
return;
}
// 定位数据 - 必须网络
if (event.request.url.includes('/api/location')) {
event.respondWith(fetch(event.request));
return;
}
});
// 将请求存入IndexedDB
async function saveToOrderQueue(request) {
const db = await openDB();
const transaction = db.transaction('orderQueue', 'readwrite');
const store = transaction.objectStore('orderQueue');
const url = new URL(request.url);
const body = await request.text();
store.add({
id: Date.now(),
url: request.url,
method: request.method,
body: body,
createdAt: Date.now(),
retryCount: 0
});
}
// IndexedDB操作
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('rider-db', 1);
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains('orderQueue')) {
db.createObjectStore('orderQueue', { keyPath: 'id', autoIncrement: true });
}
if (!db.objectStoreNames.contains('currentOrder')) {
db.createObjectStore('currentOrder', { keyPath: 'id' });
}
};
request.onsuccess = event => resolve(event.target.result);
request.onerror = event => reject(event.target.error);
});
}
6.3 骑手端完整实现
// js/rider-app.js
class RiderApp {
constructor() {
this.currentOrder = null;
this.db = null;
this.locationTimer = null;
this.syncTimer = null;
}
async init() {
this.db = await this.openDB();
this.loadCurrentOrder();
this.startLocationTracking();
this.startSyncTimer();
this.setupNetworkListeners();
this.showOrderList();
}
// 加载当前订单(离线优先)
async loadCurrentOrder() {
try {
const db = await this.openDB();
const order = await this.getFromDB(db, 'currentOrder', 'active');
if (order) {
this.currentOrder = order;
this.renderCurrentOrder();
}
} catch (e) {
console.error('加载当前订单失败:', e);
}
}
// 接单
async acceptOrder(orderId) {
try {
const response = await fetch('/api/orders/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId })
});
if (response.ok) {
const order = await response.json();
await this.saveToDB('currentOrder', 'active', order);
this.currentOrder = order;
this.renderCurrentOrder();
this.playSound('accept');
}
} catch (error) {
// 离线接单:存入本地,标记待同步
const order = { id: orderId, status: 'accepted', offline: true };
await this.saveToDB('currentOrder', 'active', order);
this.currentOrder = order;
this.renderCurrentOrder();
this.showToast('已离线接单,网络恢复后自动同步', 'warning');
}
}
// 更新配送状态
async updateStatus(status) {
const payload = {
orderId: this.currentOrder?.id,
status: status,
location: this.currentLocation,
timestamp: Date.now()
};
try {
const response = await fetch('/api/orders/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('更新失败');
// 更新本地缓存
const updatedOrder = { ...this.currentOrder, status };
await this.saveToDB('currentOrder', 'active', updatedOrder);
this.currentOrder = updatedOrder;
this.showToast(`订单状态已更新:${this.getStatusText(status)}`, 'success');
} catch (error) {
// 存入待同步队列
await this.saveToOrderQueue('/api/orders/status', 'PUT', payload);
this.showToast('网络异常,状态已暂存', 'warning');
}
}
// 定位追踪
startLocationTracking() {
if (!navigator.geolocation) return;
this.locationTimer = setInterval(async () => {
navigator.geolocation.getCurrentPosition(
async (position) => {
this.currentLocation = {
lat: position.coords.latitude,
lng: position.coords.longitude,
accuracy: position.coords.accuracy,
timestamp: position.timestamp
};
// 每30秒上报一次位置(即使离线也先存本地)
if (navigator.onLine) {
await fetch('/api/location', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
riderId: this.riderId,
...this.currentLocation
})
}).catch(() => {
this.saveToOrderQueue('/api/location', 'POST', this.currentLocation);
});
}
},
(error) => console.error('定位失败:', error),
{ enableHighAccuracy: true, timeout: 10000 }
);
}, 10000); // 每10秒定位一次
}
// 定时同步离线数据
startSyncTimer() {
this.syncTimer = setInterval(async () => {
if (navigator.onLine) {
await this.syncPendingOrders();
}
}, 30000); // 每30秒检查一次
}
// 同步待处理的订单
async syncPendingOrders() {
const queue = await this.getFromDBBatch('orderQueue');
if (queue.length === 0) return;
for (const item of queue) {
try {
const response = await fetch(item.url, {
method: item.method,
headers: { 'Content-Type': 'application/json' },
body: item.body
});
if (response.ok) {
await this.removeFromQueue(item.id);
} else if (response.status >= 500) {
// 服务器错误,增加重试次数
item.retryCount = (item.retryCount || 0) + 1;
if (item.retryCount < 3) {
await this.updateQueueItem(item);
} else {
// 重试3次仍失败,标记为需要人工处理
item.status = 'failed';
await this.updateQueueItem(item);
}
}
} catch (error) {
item.retryCount = (item.retryCount || 0) + 1;
await this.updateQueueItem(item);
}
}
}
// 网络状态监听
setupNetworkListeners() {
window.addEventListener('online', () => {
this.showToast('🌐 网络已恢复', 'success');
this.syncPendingOrders();
this.loadOrderList();
});
window.addEventListener('offline', () => {
this.showToast('⚠️ 网络已断开,数据将离线保存', 'warning');
});
}
// IndexedDB工具方法
async openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('rider-db', 2);
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains('currentOrder')) {
db.createObjectStore('currentOrder', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('orderQueue')) {
const store = db.createObjectStore('orderQueue', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('status', 'status', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
};
request.onsuccess = event => resolve(event.target.result);
request.onerror = event => reject(event.target.error);
});
}
async saveToDB(storeName, key, data) {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.put({ id: key, ...data });
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async getFromDB(db, storeName, key) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const request = store.get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getFromDBBatch(storeName) {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async removeFromQueue(id) {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction('orderQueue', 'readwrite');
const store = transaction.objectStore('orderQueue');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async updateQueueItem(item) {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction('orderQueue', 'readwrite');
const store = transaction.objectStore('orderQueue');
const request = store.put(item);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async saveToOrderQueue(url, method, body) {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction('orderQueue', 'readwrite');
const store = transaction.objectStore('orderQueue');
const request = store.add({
url, method, body: JSON.stringify(body),
status: 'pending',
retryCount: 0,
createdAt: Date.now()
});
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// UI渲染
renderCurrentOrder() {
const el = document.getElementById('currentOrder');
if (!this.currentOrder) {
el.innerHTML = '<div class="empty-state">暂无订单,等待接单...</div>';
return;
}
const order = this.currentOrder;
el.innerHTML = `
<div class="order-card">
<div class="order-header">
<span class="order-id">#${order.id}</span>
<span class="order-status ${order.status}">${this.getStatusText(order.status)}</span>
</div>
<div class="order-info">
<p>📍 取货:${order.pickupAddress}</p>
<p>🏠 送达:${order.deliveryAddress}</p>
<p>💰 金额:¥${order.amount}</p>
<p>⏱ 限时:${order.deadline}</p>
</div>
<div class="order-actions">
${this.getActionButtons(order.status)}
</div>
</div>
`;
}
getActionButtons(status) {
const buttons = {
'accepted': [
{ text: '已取货', action: () => this.updateStatus('picked_up') },
{ text: '已开始配送', action: () => this.updateStatus('delivering') }
],
'picked_up': [
{ text: '已送达', action: () => this.updateStatus('delivered') }
],
'delivering': [
{ text: '确认送达', action: () => this.updateStatus('delivered') }
]
};
return (buttons[status] || []).map(btn => `
<button class="action-btn" onclick="riderApp.updateStatus('${status}')">
${btn.text}
</button>
`).join('');
}
getStatusText(status) {
const map = {
'accepted': '已接单',
'picked_up': '已取货',
'delivering': '配送中',
'delivered': '已送达'
};
return map[status] || status;
}
showToast(msg, type = 'info') {
const toast = document.createElement('div');
const colors = { success: '#4CAF50', warning: '#FF9800', error: '#f44336', info: '#2196F3' };
toast.textContent = msg;
toast.style.cssText = `
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: ${colors[type]}; color: white; padding: 10px 20px;
border-radius: 20px; font-size: 13px; z-index: 9999;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
animation: slideDown 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
playSound(type) {
// 简单的声音提示
const audio = new Audio(`/sounds/${type}.mp3`);
audio.play().catch(() => {});
}
}
// 全局实例
const riderApp = new RiderApp();
riderApp.init();
6.4 骑手端CSS样式
/* css/rider-app.css */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
}
.rider-header {
background: linear-gradient(135deg, #ff6b35, #f7931e);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.rider-header h1 { font-size: 18px; color: white; }
.online-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: rgba(255,255,255,0.9);
}
.online-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4CAF50;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.main-content {
padding: 16px;
max-width: 480px;
margin: 0 auto;
}
.order-card {
background: #16213e;
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.order-id {
font-size: 14px;
color: #888;
}
.order-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.order-status.accepted { background: #2196F3; }
.order-status.picked_up { background: #FF9800; }
.order-status.delivering { background: #9C27B0; }
.order-status.delivered { background: #4CAF50; }
.order-info p {
font-size: 14px;
color: #ccc;
margin-bottom: 8px;
line-height: 1.6;
}
.order-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.action-btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
background: linear-gradient(135deg, #ff6b35, #f7931e);
color: white;
}
.action-btn:active { opacity: 0.9; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.stats-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.stat-item {
flex: 1;
background: #16213e;
padding: 16px;
border-radius: 12px;
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #ff6b35;
}
.stat-label {
font-size: 12px;
color: #888;
margin-top: 4px;
}
七、Manifest文件配置
为了让应用真正像一个”App”,需要配置Web App Manifest:
{
"name": "智慧餐厅点餐系统",
"short_name": "点餐",
"description": "离线可用的餐厅点餐系统",
"start_url": "/?home",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ff6b35",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
八、关键数据流总结
用一张图来理解整个系统的数据流向:
┌──────────────────────────────────────────────────────────────────┐
│ 数据流向全景图 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 用户操作 │ ───▶ │ Service Worker│ ────▶│ 本地缓存 │ │
│ │ (点击) │ │ (拦截/路由) │ │ (Cache API) │ │
│ └──────────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 网络请求 │ ────▶│ IndexedDB │ │
│ │ (fetch) │ │ (持久化存储) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ┌────────────┘ └────────────┐ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌────────────┐ │
│ │ 服务器 API │ ◀──── 同步 ─────────── │ 离线数据队列│ │
│ │ (后端处理) │ │ (自动补发) │ │
│ └──────────────────┘ └────────────┘ │
│ │
├──────────────────────────────────────────────────────────────────┤
│ 离线模式:用户操作 → Service Worker拦截 → 存入IndexedDB │
│ 在线模式:用户操作 → Service Worker转发 → 服务器响应 → 更新缓存 │
│ 恢复同步:检测到网络恢复 → 遍历离线队列 → 逐个提交 → 清理已同步 │
└──────────────────────────────────────────────────────────────────┘
九、实施步骤清单
第一步:准备阶段(1-2天)
- [ ] 确认哪些页面/数据需要离线支持
- [ ] 梳理核心API接口列表
- [ ] 设计离线数据存储结构(哪些数据用Cache,哪些用IndexedDB)
第二步:Service Worker开发(2-3天)
- [ ] 编写
service-worker.js,实现缓存策略 - [ ] 创建离线页面
offline.html - [ ] 编写注册脚本,确保SW正确注册
第三步:前端改造(3-5天)
- [ ] 改造所有API请求,加入离线重试逻辑
- [ ] 实现IndexedDB存储层(订单队列、用户数据)
- [ ] 添加网络状态检测和UI提示
- [ ] 实现后台数据同步机制
第四步:测试验证(2-3天)
- [ ] Chrome DevTools → Application → Service Workers,手动切换offline模式测试
- [ ] 模拟网络中断,验证订单不丢失
- [ ] 模拟网络恢复,验证数据自动同步
- [ ] 多浏览器兼容性测试(iOS Safari、Android Chrome)
第五步:上线监控
- [ ] 添加错误监控,记录SW相关异常
- [ ] 监控离线订单数量,及时发现同步问题
- [ ] 设置缓存过期时间,避免数据永久不更新
十、常见问题与注意事项
Q1:离线缓存会不会导致数据过期?
会的,所以关键数据(如号源、订单状态)必须使用
network-first策略,确保实时性。只有不常变化的数据(如菜单、医生列表)才能用缓存优先。
Q2:移动端浏览器对Service Worker支持如何?
Android Chrome支持最好,iOS Safari从14.5开始支持,但有限制(如后台执行能力较弱)。关键数据必须同时用IndexedDB做兜底。
Q3:缓存容量有没有限制?
各浏览器不同,Chrome约为主存储空间的25%(约几GB),但建议按需缓存,不要缓存大文件。
Q4:如何确保用户首次使用也能离线?
需要用户至少访问过一次完整页面,Service Worker才能完成安装和缓存。可以在首次启动时强制加载所有关键资源。
HTML5离线缓存技术的核心思想很简单:把”必须在线才能用”变成”离线也能用,在线更好用”。不管是在餐厅、诊所还是骑手中,这套技术都能让业务不因网络波动而中断。
