离线使用实战:PWA与Service Worker核心技术解析
为什么我又在折腾离线方案?
最近一个移动端项目要求“弱网甚至无网也能用”,老板说“用户地铁里也要能操作”。行吧,那就得搞离线。但前端离线这事,水挺深——Service Worker、localStorage、IndexedDB、Cache API、甚至本地 SQLite……方案一堆,选哪个?我踩过不少坑,今天就掏心窝子聊聊几个主流方案的实际体验。
谁更灵活?谁更省事?
先说结论:**简单数据用 localStorage,复杂数据上 IndexedDB,全站离线缓存必须 Service Worker + Cache API**。别一上来就堆 IndexedDB,也别以为 localStorage 能扛大梁。
我之前图省事,把整个用户配置塞进 localStorage,结果某天用户导出 500 条记录,直接卡死。localStorage 是同步的,主线程阻塞,页面直接冻住。后来改用 IndexedDB,异步操作,流畅多了。但 IndexedDB 的 API 又臭又长,写起来像在写 Java。
所以我的习惯是:小量、结构简单的数据(比如主题设置、token),用 localStorage;稍微复杂点的(比如草稿、历史记录),用 idb 这个轻量封装库,它把 IndexedDB 的 Promise 化做得挺干净。
// 用 idb 简化 IndexedDB
import { openDB } from 'idb';
const dbPromise = openDB('myApp', 1, {
upgrade(db) {
db.createObjectStore('drafts', { keyPath: 'id' });
}
});
// 存草稿
async function saveDraft(id, content) {
const db = await dbPromise;
await db.put('drafts', { id, content });
}
// 读草稿
async function getDraft(id) {
const db = await dbPromise;
return await db.get('drafts', id);
}
这比原生 IndexedDB 少写一半代码,亲测有效。
Service Worker:真香,但坑也不少
要做“离线也能打开页面”,绕不开 Service Worker(SW)。很多人以为 SW 就是加个 sw.js 完事,其实细节多到爆炸。
我第一次写 SW,缓存了 HTML、CSS、JS,结果用户更新后还是看到旧页面——因为 SW 默认不会自动更新缓存。后来加上版本号控制和 skipWaiting() + clientsClaim() 才解决。
下面是我现在用的 SW 模板,核心逻辑就这些:
const CACHE_NAME = 'v3';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/offline.html'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', (event) => {
// 跳过非 GET 请求和跨域
if (event.request.method !== 'GET' || !event.request.url.startsWith(self.location.origin)) {
return;
}
event.respondWith(
caches.match(event.request)
.then((response) => {
// 有缓存就返回,没有就走网络
return response || fetch(event.request).then((networkRes) => {
// 克隆响应,因为流只能读一次
const cloned = networkRes.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, cloned);
});
return networkRes;
}).catch(() => {
// 网络失败,返回离线页
return caches.match('/offline.html');
});
})
);
});
// 更新时删旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((name) => {
if (name !== CACHE_NAME) {
return caches.delete(name);
}
})
);
})
);
});
这里注意我踩过好几次坑:一定要 clone 响应,不然你缓存完,主页面拿不到 body;离线页必须提前缓存,否则 offline.html 自己都加载不了;还有,不要缓存带时间戳的 API 接口,比如 /api/data?t=12345,这种动态 URL 会撑爆缓存。
API 数据怎么离线?
静态资源缓存好办,但用户数据怎么办?比如从 https://jztheme.com/api/posts 拉的文章列表。
我试过两种方式:
- 方案A:SW 里拦截 API 请求,存到 Cache API
- 方案B:业务层用 IndexedDB 存原始数据,SW 不管 API
实测下来,我更倾向方案B。原因很简单:Cache API 是按 URL 存的,没法做复杂的查询或更新。而用户可能要“标记已读”“收藏某条”,这些操作需要修改数据,Cache API 做不到。
所以我现在的做法是:首次加载时,把 API 数据存 IndexedDB;后续请求先查 DB,同时后台静默拉新数据更新 DB。离线时直接读 DB,体验无缝。
// 业务层处理 API 离线
async function fetchPosts() {
const cached = await getPostsFromDB(); // 先读本地
if (cached) render(cached);
try {
const res = await fetch('https://jztheme.com/api/posts');
const data = await res.json();
await savePostsToDB(data); // 更新本地
render(data);
} catch (err) {
// 网络失败,已用 cached 渲染,无需额外处理
}
}
这样虽然多写点代码,但灵活性高得多。SW 只负责静态资源,数据交给业务层,职责分明。
我的选型逻辑
总结一下我的实战选型逻辑:
- 如果只是“离线能打开首页”,用 SW + Cache API,100 行代码搞定
- 如果有用户生成内容(草稿、笔记等),必须上 IndexedDB(配合 idb 库)
- API 数据不要扔给 SW 缓存,自己用 DB 管理更可控
- localStorage 只用于小量配置,别贪多
别被“PWA 一键离线”忽悠了。真实项目里,离线不是开关,而是一堆细节拼起来的体验。比如:离线时按钮要置灰、提示“当前无网络”、同步队列管理……这些比技术选型更耗时间。
另外,iOS 对 SW 支持一直半吊子,Safari 时不时抽风。上线前务必真机测,别只看 Chrome DevTools 的“Offline”勾选框——那玩意儿太理想化了。
最后一点真心话
离线方案没有银弹。我见过团队为了“完美离线”花两周重构,结果用户根本不在意——他们只是偶尔断网几秒。所以先问清楚需求:是要“完全离线可用”,还是“弱网下不白屏”?前者成本高,后者用 SW 缓存关键资源就行。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的 IndexedDB 封装技巧,或者 SW 避坑经验,欢迎评论区交流。这个话题水太深,一个人折腾容易翻车。

暂无评论