你有没有遇到过这种情况:正在等地铁,刷到一篇好文章,刚想看,结果信号格变成了”无服务”,页面卡在转圈……这时候如果你能直接看离线版本,是不是爽翻了?
说实话,这个问题的答案其实挺有意思的——技术上完全可以做到,但现实是大部分网页还是依赖网络。今天我们就来扒一扒,为什么有些网页能在没网时打开,而有些就不行,以及开发者是怎么做到这一点的。
一、先搞清楚:网页的本质是什么?
很多人以为网页就是”打开浏览器就能看的东西”,但这个理解太浅了。实际上,一个网页由好几种文件组成:
| 文件类型 | 作用 | 大小 |
|---|---|---|
| HTML | 网页的骨架,告诉你页面上有什么 | 几KB |
| CSS | 样式表,决定颜色、字体、布局 | 几十KB |
| JavaScript | 脚本,让页面有交互功能 | 几百KB到几MB |
| 图片/视频 | 视觉内容 | 几十MB不等 |
| 字体文件 | 自定义字体 | 几MB |
| API数据 | 动态内容(用户信息、评论等) | 实时请求 |
关键点来了:HTML5 的离线能力,本质上就是让浏览器把上面这些文件提前存到本地,下次没网的时候直接从本地读取。
二、三种主要的离线技术方案
方案一:Application Cache(appcache)—— 已过时但值得了解
这是 HTML5 最早推出的离线方案,语法简单到离谱:
<!DOCTYPE html>
<html manifest="app.manifest">
<head>
<title>我的离线页面</title>
</head>
<body>
<h1>即使断网也能看!</h1>
<img src="photo.jpg">
</body>
</html>
然后创建一个 app.manifest 文件:
CACHE MANIFEST
# 版本号:2024.01.15
CACHE:
index.html
style.css
app.js
photo.jpg
NETWORK:
*
FALLBACK:
/ /offline.html
工作原理:
- 浏览器第一次访问时,下载 manifest 文件
- 根据 manifest 的指令,缓存列出的所有资源
- 下次断网时,直接从本地缓存读取
为什么它被淘汰了?
- 缓存更新机制有bug,经常不生效
- 安全性问题,容易被劫持
- 2016 年后各大浏览器逐步移除支持
- Chrome 88 正式删除,Firefox 也停了
结论:别在新项目里用这个,但老代码里可能还能见到。
方案二:Service Worker + Cache API —— 现在的标准方案
这才是现代 PWA(Progressive Web App)的幕后英雄。
2.1 Service Worker 是什么?
Service Worker 是一个跑在浏览器后台的脚本,它能在网络请求时拦截它们,决定是返回缓存还是去网络拿。你可以把它理解成一个”网络代理”。
2.2 完整实现代码
第一步:注册 Service Worker
// main.js - 主页面脚本
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('Service Worker 注册成功:', registration.scope);
})
.catch(function(error) {
console.log('Service Worker 注册失败:', error);
});
});
}
第二步:编写 Service Worker
// sw.js
const CACHE_NAME = 'my-app-v1';
const URLsToCache = [
'/',
'/index.html',
'/style.css',
'/app.js',
'/images/logo.png',
'/fonts/roboto.woff2'
];
// 安装时:预缓存关键资源
self.addEventListener('install', function(event) {
console.log('[SW] 安装中...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('[SW] 缓存打开成功');
return cache.addAll(URLsToCache);
})
.then(function() {
return self.skipWaiting(); // 立即激活
})
);
});
// 激活时:清理旧缓存
self.addEventListener('activate', function(event) {
console.log('[SW] 激活中...');
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames
.filter(function(name) {
return name !== CACHE_NAME;
})
.map(function(name) {
console.log('[SW] 删除旧缓存:', name);
return caches.delete(name);
})
);
})
).then(function() {
return self.clients.claim(); // 接管所有客户端
});
});
// 拦截网络请求
self.addEventListener('fetch', function(event) {
console.log('[SW] 拦截请求:', event.request.url);
event.respondWith(
caches.match(event.request)
.then(function(response) {
// 命中缓存,直接返回
if (response) {
console.log('[SW] 使用缓存:', event.request.url);
return response;
}
// 未命中缓存,去网络拿
console.log('[SW] 请求网络:', event.request.url);
return fetch(event.request).then(function(fetchResponse) {
// 只在成功时才缓存
if (fetchResponse && fetchResponse.status === 200) {
var responseClone = fetchResponse.clone();
caches.open(CACHE_NAME)
.then(function(cache) {
cache.put(event.request, responseClone);
});
}
return fetchResponse;
}).catch(function() {
// 网络失败,返回离线页面
return caches.match('/offline.html');
});
})
);
});
第三步:创建离线页面
<!-- 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>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 20px;
}
.icon {
font-size: 80px;
margin-bottom: 20px;
}
h1 {
font-size: 24px;
margin-bottom: 10px;
}
p {
font-size: 16px;
opacity: 0.8;
margin-bottom: 30px;
}
button {
background: white;
color: #667eea;
border: none;
padding: 12px 30px;
border-radius: 25px;
font-size: 16px;
cursor: pointer;
transition: transform 0.2s;
}
button:hover {
transform: scale(1.05);
}
</style>
</head>
<body>
<div class="icon">📡</div>
<h1>暂无网络连接</h1>
<p>当前处于离线状态,部分功能可能不可用</p>
<button onclick="location.reload()">重试</button>
</body>
</html>
工作原理图解:
用户请求页面
│
▼
Service Worker 拦截
│
├── 命中缓存?─── 是 ──→ 返回缓存
│
└── 未命中 ──→ 发起网络请求
│
├── 成功 ──→ 缓存 + 返回
│
└── 失败 ──→ 返回 offline.html
方案三:IndexedDB + 本地存储 —— 适合存大量数据
如果你需要存储用户数据(比如草稿、离线文章),Service Worker 的缓存不够用,这时候用 IndexedDB:
// 使用 IndexedDB 存储离线数据
const DB_NAME = 'offline-app';
const DB_VERSION = 1;
const STORE_NAME = 'articles';
function openDB() {
return new Promise(function(resolve, reject) {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = function(event) {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = function(event) {
resolve(event.target.result);
};
request.onerror = function(event) {
reject(event.target.error);
};
});
}
// 存储文章
async function saveArticle(article) {
const db = await openDB();
return new Promise(function(resolve, reject) {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.put(article);
request.onsuccess = function() {
resolve();
};
request.onerror = function() {
reject(request.error);
};
});
}
// 读取文章列表
async function getArticles() {
const db = await openDB();
return new Promise(function(resolve, reject) {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = function() {
resolve(request.result);
};
request.onerror = function() {
reject(request.error);
};
});
}
三、实际案例分析
案例一:微信读书的离线阅读
微信读书为什么能在没网时看书?
// 简化版的缓存策略
const BOOK_CACHE_NAME = 'books-v1';
self.addEventListener('fetch', function(event) {
// 只缓存书籍内容
if (event.request.url.includes('/book/')) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
if (response) return response;
return fetch(event.request)
.then(function(fetchResponse) {
// 缓存整本书
var clone = fetchResponse.clone();
caches.open(BOOK_CACHE_NAME)
.then(function(cache) {
cache.put(event.request, clone);
});
return fetchResponse;
});
})
);
}
});
关键点:书籍是静态内容,适合预缓存。用户加入书架的书会自动下载到本地。
案例二:Notion 的离线模式
Notion 使用了一套更复杂的方案:
- Service Worker 缓存页面结构
- IndexedDB 存储内容数据
- 同步队列处理离线编辑
// 离线编辑队列
const OFFLINE_QUEUE = 'offline-edits';
async function queueEdit(edit) {
const db = await openDB();
const transaction = db.transaction([OFFLINE_QUEUE], 'readwrite');
const store = transaction.objectStore(OFFLINE_QUEUE);
edit.synced = false;
edit.timestamp = Date.now();
await store.add(edit);
// 尝试后台同步
await attemptSync();
}
async function attemptSync() {
if (!navigator.onLine) return;
const edits = await getUnsyncedEdits();
for (const edit of edits) {
try {
await fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(edit)
});
await markAsSynced(edit.id);
} catch (error) {
console.error('同步失败:', error);
break; // 失败就停,等下次重试
}
}
}
四、常见兼容性问题与解决指南
问题一:iOS Safari 对 Service Worker 支持有限
现象:在 iPhone 上,Service Worker 可能不工作。
原因:iOS 的 WKWebView 对 Service Worker 的支持不完善,且后台运行时会被系统限制。
解决方案:
// 检测平台并降级
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isIOS && isSafari) {
// 使用更简单的缓存方案
useSimpleCache();
} else {
// 正常使用 Service Worker
registerServiceWorker();
}
function useSimpleCache() {
// 方案A:使用 localStorage 缓存关键数据
const CACHE_KEY = 'app-cache-v1';
// 保存数据
localStorage.setItem(CACHE_KEY, JSON.stringify(data));
// 读取数据
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const data = JSON.parse(cached);
renderPage(data);
}
}
更好的方案:使用 PWA 的 beforeinstallprompt 事件,引导用户把网页”添加到主屏幕”,这样 iOS 会给予更好的离线支持。
问题二:缓存不更新
现象:修改了代码,但用户看到的还是旧版本。
原因:Service Worker 缓存了旧资源,没有触发更新。
解决方案:
// 方案一:版本号控制
const CACHE_NAME = 'my-app-v' + Date.now();
// 方案二:使用 workbox 库自动管理缓存
import { precacheAndRoute } from 'workbox-precaching';
precacheAndRoute(self.__precacheManifest || []);
// 方案三:设置缓存过期策略
import { registerRoute, ExpirationPlugin } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
// 图片缓存30天
registerRoute(
({request}) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [new ExpirationPlugin({maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60})]
})
);
// API 数据边缓存边更新
registerRoute(
({request}) => request.url.includes('/api/'),
new StaleWhileRevalidate({
cacheName: 'api-data'
})
);
问题三:用户不知道页面已缓存
现象:页面离线可用,但用户以为还是在线状态。
解决方案:
// 监听网络状态变化
window.addEventListener('online', updateStatus);
window.addEventListener('offline', updateStatus);
function updateStatus() {
const statusEl = document.getElementById('network-status');
if (navigator.onLine) {
statusEl.textContent = '🟢 已连接';
statusEl.style.color = 'green';
} else {
statusEl.textContent = '🔴 离线模式';
statusEl.style.color = 'orange';
}
}
// Service Worker 通知
self.addEventListener('push', function(event) {
const options = {
body: event.data.text(),
icon: '/icon.png',
badge: '/badge.png'
};
event.waitUntil(
self.registration.showNotification('新消息', options)
);
});
问题四:HTTPS 要求
现象:在非 HTTPS 页面上,Service Worker 无法注册。
原因:出于安全考虑,Service Worker 只能在 HTTPS 或 localhost 下工作。
解决方案:
// 检测是否HTTPS
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
// 提示用户升级到HTTPS
showUpgradePrompt();
}
function showUpgradePrompt() {
const banner = document.createElement('div');
banner.innerHTML = `
<div style="background:#ff6b6b;color:white;padding:10px;text-align:center;">
⚠️ 当前页面不是安全连接,离线功能可能无法使用
</div>
`;
document.body.insertBefore(banner, document.body.firstChild);
}
实际做法:现在主流托管平台(Vercel、Netlify、GitHub Pages)都默认提供 HTTPS,不用太担心。
五、性能优化技巧
1. 分级缓存策略
不是所有内容都值得缓存:
高优先级(必须缓存):
├── HTML 结构
├── 关键 CSS
├── 核心 JavaScript
├── 字体文件
└── 离线页面
中优先级(按需缓存):
├── 文章图片
├── 用户头像
└── 图标资源
低优先级(不缓存):
├── 大视频文件
├── 实时数据 API
└── 第三方广告脚本
2. 预加载关键资源
”`html
