你还记得那个暴雨天的傍晚吗?
我点了一份麻辣烫,骑手小哥的电话打不通。不是他偷懒,是那个正在下雨的城中村,4G信号只剩下微弱的两格,页面卡在99%,怎么刷新都转圈。那一刻我意识到,对于每天穿梭在地铁隧道、电梯井、地下车库的人来说,”网络连接”这四个字,从来不是一种确定性的权利,而是一种奢侈的运气。
我也是这样的人。
每天早晚高峰挤进一号线,从出站口到公司工位,有整整六分钟的盲区。这六分钟,足够让我焦虑,也足够让我思考:如果有一款应用,能在没有网络的时候依然流畅运行,该有多好?
于是,我开始研究HTML5的离线缓存技术。今天,我想和你聊聊,如何让一个Web应用在没有网络的地铁里,依然”秒开”。
一、为什么我们需要离线缓存?
先说一个数据。根据StatCounter的统计,2023年全球移动端流量占比已经超过55%,而在中国,这个数字更是高达72%。这意味着什么?意味着你的用户,大部分时间是在手机上访问你的应用。
而手机的处境,比桌面电脑糟糕得多。
场景一:地铁通勤族
想象一下,你挤在早高峰的地铁里,四周是密密麻麻的人头,手机信号从4G变成3G,再变成2G,最后彻底消失。这时候,如果你打开一个Web应用,它告诉你”无网络连接”,你会怎么办?
大多数人会选择关掉它,或者等到出站再打开。而那些坚持等到出站的用户,已经流失了。
场景二:外卖骑手
骑手小哥在送餐途中,需要实时查看订单信息、导航路线、用户电话。如果应用依赖网络,一旦进入地下室或电梯,订单信息就看不到了。这不仅影响效率,更可能影响收入。
场景三:偏远地区用户
在中国,还有很多农村地区、山区、边疆,网络覆盖并不完善。对于这些用户来说,离线能力不是一个”锦上添花”的功能,而是”雪中送炭”的需求。
所以,离线缓存不是技术炫技,而是用户体验的基本保障。
二、HTML5离线缓存的三种方案
在深入讲解之前,我需要先澄清一个常见的误解:HTML5的离线缓存,并不是只有一种技术。实际上,它有三种主要方案,每种方案都有自己的适用场景。
方案一:AppCache(Application Cache)——已经淘汰,但值得了解
AppCache是HTML5最早推出的离线缓存技术,语法非常简单:
<!DOCTYPE html>
<html manifest="/cache.manifest">
<head>
<title>我的离线应用</title>
</head>
<body>
<h1>即使没有网络,我也能正常运行</h1>
<script src="/app.js"></script>
<link rel="stylesheet" href="/style.css">
</body>
</html>
然后,你需要创建一个.manifest文件:
CACHE MANIFEST
# 版本号:20231015
CACHE:
/index.html
/app.js
/style.css
/images/logo.png
NETWORK:
/api/*
*
FALLBACK:
/offline /fallback/offline.html
听起来很简单,对吧?
但问题在于,AppCache有几个致命的缺陷:
- 缓存策略不灵活:它只能缓存,不能控制缓存的过期时间,也不能按需更新。
- 缓存更新困难:一旦manifest文件改变,整个缓存就会失效,导致之前的缓存全部失效。
- 兼容性差:Safari在2016年就移除了对AppCache的支持,Chrome也在2016年宣布弃用,Firefox在2020年彻底移除。
MDN官方文档明确写道:”AppCache is deprecated. Do not use it to serve offline resources.”
所以,我们不应该再使用AppCache。但了解它的历史,有助于我们更好地理解为什么Service Worker会出现。
方案二:Service Worker——现代离线缓存的标准方案
Service Worker是Chrome在2014年引入的,它是一种运行在后台的脚本,可以拦截网络请求,控制缓存策略,实现离线功能。
与AppCache不同,Service Worker提供了更细粒度的控制能力,可以更好地管理缓存。
第一步:注册Service Worker
在你的主页面中,添加以下代码:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker 注册成功:', registration.scope);
})
.catch(error => {
console.log('Service Worker 注册失败:', error);
});
});
}
第二步:创建Service Worker脚本
创建一个名为sw.js的文件:
const CACHE_NAME = 'my-app-v1';
const urlsToCache = [
'/',
'/index.html',
'/app.js',
'/style.css',
'/images/logo.png',
'/offline.html'
];
// 安装阶段:预缓存关键资源
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('缓存打开成功');
return cache.addAll(urlsToCache);
})
);
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(cacheName => cacheName !== CACHE_NAME)
.map(cacheName => caches.delete(cacheName))
);
})
);
});
// 请求阶段:拦截网络请求,优先使用缓存
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// 如果缓存中有,返回缓存
if (response) {
return response;
}
// 如果缓存中没有,发起网络请求
return fetch(event.request)
.then(networkResponse => {
// 将网络响应缓存起来
if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
});
})
);
});
这段代码的逻辑是这样的:
- 安装阶段:当Service Worker首次安装时,它会打开一个缓存,并将关键资源添加到缓存中。
- 激活阶段:当Service Worker激活时,它会清理旧的缓存,避免缓存堆积。
- 请求阶段:当用户发起网络请求时,Service Worker会拦截这个请求,先检查缓存,如果缓存中有,直接返回缓存;如果缓存中没有,发起网络请求,并将响应缓存起来。
第三步:测试离线功能
你可以在Chrome DevTools中,切换到”Application”标签页,找到”Service Workers”,然后勾选”Offline”,模拟离线环境。此时,刷新页面,你会发现应用依然可以正常加载。
方案三:IndexedDB + Service Worker——适合复杂数据缓存
对于需要缓存大量数据的应用(比如新闻App、社交App),Service Worker的缓存空间可能不够用。这时候,我们可以结合IndexedDB来缓存数据。
创建一个数据缓存层
// 数据库初始化
const DB_NAME = 'my-app-db';
const DB_VERSION = 1;
const STORE_NAME = 'articles';
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
});
}
// 保存数据到IndexedDB
async function saveToDB(data) {
const db = await openDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.put(data);
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// 从IndexedDB读取数据
async function getFromDB(id) {
const db = await openDB();
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
在Service Worker中,我们可以利用这个数据库来缓存API返回的数据:
self.addEventListener('fetch', event => {
// 只拦截API请求
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
// 克隆响应,一份给页面,一份存数据库
const responseClone = response.clone();
responseClone.json().then(data => {
saveToDB({ id: Date.now(), data, timestamp: Date.now() });
});
return response;
})
.catch(async () => {
// 离线时,从数据库读取缓存的数据
const id = new URL(event.request.url).searchParams.get('id');
return await getFromDB(id);
})
);
}
});
这样,即使用户离线,他们也能看到之前加载过的内容。
三、缓存失效与更新策略——如何避免”缓存僵尸”
缓存技术最大的问题,不是如何实现,而是如何管理。
想象一下,你发布了一个新版本的应用,但用户因为缓存没有更新,依然看到旧版本。这不仅影响用户体验,还可能导致安全问题。
所以,缓存失效和更新策略,是离线缓存技术中最重要的部分。
策略一:版本号管理
最常用且最可靠的方法,是在缓存名称中加入版本号:
const CACHE_NAME = 'my-app-v2-20231015';
当应用更新时,改变版本号,Service Worker就会认为这是一个新的缓存,从而清除旧缓存,重新缓存新资源。
但这有一个问题:如何通知浏览器缓存已经更新?
策略二:Service Worker更新检测
浏览器会自动检测Service Worker脚本的变化。当sw.js文件的内容发生变化时,浏览器会认为Service Worker已经更新,并触发新的安装流程。
但为了确保用户体验,我们应该在JavaScript中主动检测更新:
// 定期检测Service Worker更新
setInterval(async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/sw.js');
if (registration.installing) {
console.log('Service Worker 正在安装...');
} else if (registration.waiting) {
console.log('Service Worker 已安装,等待激活');
// 显示"新版本可用"的提示
showUpdateBanner();
} else if (registration.active) {
console.log('Service Worker 正在运行');
}
}
}, 60 * 60 * 1000); // 每小时检测一次
function showUpdateBanner() {
const banner = document.createElement('div');
banner.className = 'update-banner';
banner.textContent = '发现新版本,点击刷新以获取最新内容';
banner.onclick = () => {
// 强制刷新页面,触发新的Service Worker激活
window.location.reload(true);
};
document.body.appendChild(banner);
}
策略三:缓存失效策略的选择
不同的资源,应该采用不同的缓存失效策略:
1. 缓存优先策略(Cache First)
适用于静态资源(HTML、CSS、JS、图片):
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request);
})
);
});
2. 网络优先策略(Network First)
适用于动态数据(API响应):
self.addEventListener('fetch', event => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(networkResponse => {
// 缓存网络响应
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseClone);
});
return networkResponse;
})
.catch(async () => {
// 离线时,返回缓存
return caches.match(event.request);
})
);
}
});
3. stale-while-revalidate 策略
先返回缓存,同时更新缓存:
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
// 先返回缓存
const fetchPromise = fetch(event.request)
.then(networkResponse => {
// 同时更新缓存
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
});
return cachedResponse || fetchPromise;
})
);
});
策略四:TTL(Time To Live)机制
为缓存设置过期时间,避免缓存”僵尸”:
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24小时
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(async cachedResponse => {
if (cachedResponse) {
// 检查缓存时间
const cacheTime = await getCacheTime(event.request.url);
const now = Date.now();
if (now - cacheTime < CACHE_TTL) {
// 缓存有效,返回缓存
return cachedResponse;
} else {
// 缓存过期,尝试网络请求
return fetch(event.request)
.then(networkResponse => {
// 更新缓存
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseClone);
saveCacheTime(event.request.url, now);
});
return networkResponse;
})
.catch(() => {
// 网络失败,返回过期缓存
return cachedResponse;
});
}
}
return fetch(event.request);
})
);
});
// 辅助函数:保存和获取缓存时间
async function saveCacheTime(url, time) {
const db = await openDB();
const transaction = db.transaction(['cache-times'], 'readwrite');
const store = transaction.objectStore('cache-times');
store.put({ url, time });
}
async function getCacheTime(url) {
const db = await openDB();
const transaction = db.transaction(['cache-times'], 'readonly');
const store = transaction.objectStore('cache-times');
const request = store.get(url);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result ? request.result.time : 0);
request.onerror = () => reject(request.error);
});
}
策略五:主动清除缓存
在某些情况下,我们需要主动清除缓存。比如,用户登录状态改变,或者应用发生重大更新。
// 在用户登录时,清除个人数据缓存
async function clearPersonalCache() {
const cacheNames = await caches.keys();
for (const cacheName of cacheNames) {
if (cacheName.startsWith('personal-')) {
await caches.delete(cacheName);
}
}
}
// 在Service Worker中监听消息
self.addEventListener('message', event => {
if (event.data.type === 'CLEAR_CACHE') {
caches.delete(CACHE_NAME)
.then(() => {
self.skipWaiting();
});
}
});
// 在页面中发送消息
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.active.postMessage({ type: 'CLEAR_CACHE' });
});
}
四、实战案例:为外卖骑手设计离线应用
让我用一个具体的案例,来说明离线缓存的实际应用。
背景
我认识一个外卖骑手,叫老张。他每天的工作时间是早上8点到晚上10点,期间会穿梭在城市的大街小巷。他发现,每当他进入地下室或电梯,订单信息就会消失,导致他需要重新加载页面,耽误送餐时间。
需求
- 订单列表需要在离线状态下可见
- 导航信息需要缓存
- 用户电话需要离线可拨
- 订单状态需要实时同步
技术方案
”`javascript // sw.js - Service Worker脚本 const CACHE_NAME = ‘delivery-app-v1’; const DB_NAME = ‘delivery-db’;
// 预缓存关键资源 const urlsToCache = [
'/',
'/index.html',
'/app.js',
'/style.css',
'/images/logo.png',
'/offline.html'
];
// 安装阶段 self.addEventListener(‘install’, event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
// 激活阶段 self.addEventListener(‘activate’, event => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys
.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
))
);
});
