地铁没信号时网站照样打开HTML5离线缓存技术如何让网页像APP一样脱机使用
早高峰挤进地铁,手机信号一格都不剩,打开网页永远在”转圈圈”——你是不是也受够了这种体验?但有没有想过,为什么有些网站就算断网也能秒开,就像装了个离线版APP一样?今天咱们就聊聊这个让网页”脱胎换骨”的黑科技:HTML5离线缓存技术。
先说说,离线缓存到底是个啥?
简单来说,离线缓存就是把网页的资源提前存到你的设备里,下次打开的时候,浏览器直接从本地读,不用再联网请求服务器了。
想象一下,你每次去图书馆借书,都要跑一趟。但如果图书馆有个”快递服务”,你把想看的书一次性全部”快递”到家,以后想读的时候直接从书架上拿,不用再去图书馆排队了——离线缓存就是这个道理。
两代技术方案:从Application Cache到Service Worker
第一代:Application Cache(已退休)
早年HTML5推出过一个叫 applicationCache 的API,写法大概是这样的:
<!DOCTYPE html>
<html manifest="/cache.appcache">
<head>
<meta charset="UTF-8">
<title>离线页面示例</title>
</head>
<body>
<h1>即使断网也能打开</h1>
<p>这是缓存的内容</p>
</body>
</html>
然后你需要创建一个 .appcache 文件:
CACHE MANIFEST
# 版本号 2024-01-01
CACHE:
index.html
style.css
app.js
logo.png
NETWORK:
*
FALLBACK:
/offline/ /fallback.html
看起来挺简单对吧?但问题来了——这个方案被废弃了。为啥?因为它有很多致命缺陷:
- 缓存更新不灵活,要手动改版本号
- 缓存失败时没有细粒度控制
- 浏览器兼容性也有坑
- 安全模型不够严谨
各大浏览器早就宣布淘汰它了,所以现在主流方案是——Service Worker。
第二代:Service Worker + Cache API(现在的主角)
Service Worker 是一个运行在浏览器后台的独立脚本,它不阻塞页面渲染,能拦截网络请求、决定从缓存返回还是从网络获取。
下面我用一个完整的实战项目来演示,假设你要做一个离线可用的小程序页面。
实战:做一个地铁里也能用的网页
第一步:创建 Service Worker
在项目根目录创建 sw.js:
// sw.js - Service Worker 脚本
const CACHE_NAME = 'metro-app-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/style.css',
'/app.js',
'/images/hero.jpg',
'/images/logo.png',
'https://cdn.example.com/font.woff2' // 第三方资源也可以缓存
];
// 安装阶段:预缓存关键资源
self.addEventListener('install', event => {
console.log('[Service Worker] 安装中,开始缓存资源...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('[Service Worker] 缓存了', ASSETS_TO_CACHE.length, '个资源');
return cache.addAll(ASSETS_TO_CACHE);
})
.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 => {
console.log('[Service Worker] 删除旧缓存:', name);
return caches.delete(name);
})
);
}).then(() => {
// 让当前页面立即使用新的 Service Worker
return self.clients.claim();
})
);
});
// 拦截请求:优先从缓存获取
self.addEventListener('fetch', event => {
// 只对 GET 请求做缓存处理
if (event.request.method !== 'GET') return;
// 静态资源:缓存优先,网络兜底(Stale-While-Revalidate)
if (event.request.url.match(/\.(css|js|png|jpg|svg|woff2?)$/)) {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
// 构造网络请求(不 await,后台静默更新)
const fetchPromise = fetch(event.request)
.then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
})
.catch(() => null);
// 返回缓存(如果有),否则等网络
return cachedResponse || fetchPromise;
});
})
);
return;
}
// HTML 页面:网络优先,失败用缓存
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then(networkResponse => {
// 成功获取后更新缓存
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
})
.catch(() => {
// 离线时返回缓存的首页
return caches.match('/index.html');
})
);
return;
}
// 其他请求:直接走网络
event.respondWith(fetch(event.request));
});
第二步:在主页面注册 Service Worker
// app.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', {
scope: '/' // 限定作用域
})
.then(registration => {
console.log('[Service Worker] 注册成功,范围:', registration.scope);
// 监听更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
console.log('[Service Worker] 新状态:', newWorker.state);
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本,提示用户刷新
showUpdatePrompt();
}
});
});
})
.catch(error => {
console.error('[Service Worker] 注册失败:', error);
});
});
} else {
console.warn('当前浏览器不支持 Service Worker');
}
function showUpdatePrompt() {
const banner = document.createElement('div');
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#333;color:#fff;padding:12px;text-align:center;z-index:9999;';
banner.innerHTML = '📦 发现新版本,<a href="#" style="color:#fff;text-decoration:underline">刷新获取最新内容</a>';
document.body.appendChild(banner);
banner.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
// 通知所有已打开的页面立即更新
navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
});
}
第三步:检测在线状态,给用户友好提示
// 实时检测网络状态
function updateOnlineStatus() {
const statusEl = document.getElementById('network-status');
if (navigator.onLine) {
statusEl.textContent = '🟢 在线';
statusEl.style.color = 'green';
} else {
statusEl.textContent = '🔴 离线(使用缓存)';
statusEl.style.color = 'orange';
}
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();
缓存策略选哪种?看场景!
不同资源适合不同的缓存策略,我总结了几个最常用的:
| 策略 | 适用场景 | 代码模式 |
|---|---|---|
| Cache First | 静态资源(CSS/JS/图片) | 先查缓存,有就返回,没有再请求网络 |
| Network First | 实时性强的数据(新闻/价格) | 先请求网络,失败才用缓存 |
| Stale While Revalidate | 不常变但希望后台更新 | 先用旧缓存,同时后台更新 |
| Network Only | 敏感数据(登录状态) | 只走网络,不缓存 |
| Cache Only | 纯离线功能 | 只从缓存读取,不请求网络 |
用 Workbox 简化开发
如果你觉得手写 Service Worker 太繁琐,Google 的 Workbox 库可以帮你自动生成:
// 用 Workbox 的 SW Precaching 插件
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
// 预缓存构建产出的文件
precacheAndRoute(self.__WB_MANIFEST);
// 图片资源:缓存优先
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images-cache',
plugins: [new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 })]
})
);
// API 请求:网络优先,30秒缓存
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 })]
})
);
配合 workbox-cli 一键配置,开发效率直接拉满。
离线缓存的安全考量
很多人忽略了一点:离线缓存不是万能的,有些场景必须谨慎。
⚠️ 不要缓存的内容:
- 用户个人信息(token、用户名、订单详情)
- 支付相关页面
- 银行/金融类数据
- 任何带敏感信息的 API 响应
✅ 建议用 Secure Context 限制:
self.addEventListener('fetch', event => {
// 只对 HTTPS 或 localhost 生效
if (!self.location.protocol.startsWith('https')
&& !self.location.hostname.includes('localhost')) {
return;
}
// ... 正常处理
});
另外,Service Worker 必须通过 HTTPS 部署(本地开发除外),这是浏览器的安全底线。
和 PWA 是什么关系?
经常有人把”离线缓存”和”PWA”混为一谈,其实它们是不同的概念:
- 离线缓存:是一个技术手段,让网页能离线工作
- PWA(渐进式Web应用):是一套完整的标准,包含离线缓存 + 可添加到主屏幕 + 推送通知 + 后台同步
如果你只想要离线能力,做好 Service Worker 就够了。如果你想让网页像原生APP一样有完整体验(桌面图标、全屏、推送通知),那就往 PWA 的方向走,额外还需要:
// manifest.json - Web App Manifest
{
"name": "我的地铁阅读助手",
"short_name": "地铁助手",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4a90e2",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
<!-- 在 index.html 中引用 -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#4a90e2">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
加了这些之后,用户就可以在手机上”添加到主屏幕”,像打开APP一样打开你的网页了。
常见问题排查
问题1:缓存不更新?
原因:版本号没改,或者浏览器缓存了旧的 SW。
解决:
1. 确保 sw.js 内容变更(哪怕加个注释)
2. 在 Manifest 里改版本号
3. 开发者工具 → Application → Service Workers → 勾选 "Update on reload"
4. 或者在控制台执行:navigator.serviceWorker.getRegistrations().then(regs => regs.forEach(r => r.update()))
问题2:缓存了太多内容,占满磁盘?
原因:没有设置缓存上限。
解决:使用 ExpirationPlugin 限制数量和有效期
registerRoute(
/.*\.png/,
new CacheFirst({
cacheName: 'static-images',
plugins: [
new ExpirationPlugin({
maxEntries: 50, // 最多缓存50张图
maxAgeSeconds: 7 * 24 * 60 * 60 // 7天过期
})
]
})
);
问题3:某些资源必须从网络获取?
用 navigationPreload 提升体验,同时排除敏感接口:
self.addEventListener('activate', event => {
event.waitUntil(self.navigationPreload.enable());
});
// 拦截请求时跳过敏感 API
if (event.request.url.includes('/api/payment')
|| event.request.url.includes('/api/user')) {
event.respondWith(fetch(event.request));
return;
}
写在最后
离线缓存这项技术,本质上是在”实时性”和”可用性”之间做权衡。它不是什么黑科技,而是浏览器原生就支持的标准能力。理解它的原理后,你完全可以根据自己的业务场景,灵活选择不同的缓存策略。
下次再进地铁,打开信号格数为零的状态,如果你做的网站还能正常显示——那种成就感,真的不亚于自己写了一个原生APP。
有什么想深入了解的,或者在实际开发中遇到了坑,随时来问。😊
