跳转到内容

离线策略

发布于

完成本指南后,断网状态下你的应用照样能加载并保持可用,因为一个 Service Worker 会为每类请求从正确的地方 作答:应用本身用缓存的外壳,数据走网络并以缓存回退,两者都未命中的导航则返回一张存好的离线页。 并不存在单一的「离线模式」;离线是一组按请求做出的决定:何时信任缓存、何时信任网络。

你需要一个已注册的 Service Worker(没有的话先做入门),并了解 install 与 activate 何时触发,见 Service worker 生命周期:install、activate 与更新。 下面的代码是一份完整的 sw.js,每一步往里加一个处理函数。

在 install 中打开一个带版本号的缓存,用 addAll() 放入每个视图都需要的 HTML、CSS 与 JavaScript, 再加上离线页。缓存名里的版本号是下一次发布得以整体替换的依据。只要有一个文件抓取失败,addAll() 就会 拒绝,而 waitUntil() 收到被拒绝的 Promise 会放弃本次安装,所以只列出你能控制的文件。

const VERSION = 'v3';
const SHELL_CACHE = `shell-${VERSION}`;
const DATA_CACHE = `data-${VERSION}`;
const SHELL = ['/', '/app.css', '/app.js', '/offline.html'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL)));
});

带哈希文件名的外壳文件可以在缓存存活期间一直缓存优先;不带哈希的 /app.js 只在 VERSION 改变、 worker 重新安装时才更新。

逐请求决定。导航请求先走网络让用户看到最新 HTML,网络失败时用缓存的外壳。外壳资源缓存优先:即时响应, 由下一个 worker 版本更新。API 响应网络优先、以最近一次成功的副本回退,代价是在线时每次请求都要等一次 完整的网络往返。图片与字体用 stale-while-revalidate:缓存副本立即返回,后台抓取刷新它,所以可能有一次 请求拿到过期内容。

self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return; // POST 之类直接走网络
const url = new URL(request.url);
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request, SHELL_CACHE, '/offline.html'));
} else if (SHELL.includes(url.pathname)) {
event.respondWith(caches.match(request).then((hit) => hit || fetch(request)));
} else if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, DATA_CACHE));
} else if (request.destination === 'image' || request.destination === 'font') {
event.respondWith(staleWhileRevalidate(request, DATA_CACHE));
}
});
async function networkFirst(request, cacheName, fallbackPath) {
const cache = await caches.open(cacheName);
try {
const fresh = await fetch(request);
if (fresh.ok) cache.put(request, fresh.clone());
return fresh;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
if (fallbackPath) return cache.match(fallbackPath);
throw new Error(`offline and ${request.url} is not cached`);
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const refresh = fetch(request).then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
});
return cached || refresh;
}

cache.put() 会消耗响应体,所以代码先 clone() 再存,返回原始响应。各策略的取舍以及与 Workbox 的对应关系见 选择缓存策略。

activate 在新 worker 接管页面、旧 worker 退出之后运行,这是删除上一版本缓存的安全时机。缓存在整个源内共享, 所以按自己的前缀匹配,而不是全部删除。这个处理函数要短:fetch 会排队等待过长的激活。

self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => (name.startsWith('shell-') || name.startsWith('data-')) && !name.endsWith(VERSION))
.map((name) => caches.delete(name)),
),
),
);
});

networkFirst 对 GET 返回的是旧数据;失败的 POST 需要重试。在存在 ServiceWorkerRegistration.sync 的地方,把请求体存进 IndexedDB 并注册一个 sync 标签;网络恢复时浏览器触发 sync。该 API 只有 Chromium 实现(版本见 Background Sync 后台同步支持),所以注册抛错时页面也要能工作。

// 页面中,POST 失败之后:
async function queueForSync(tag) {
const registration = await navigator.serviceWorker.ready;
if (!('sync' in registration)) return false; // 改为提示「已保存到本地,稍后重试」
try {
await registration.sync.register(tag);
return true;
} catch {
return false;
}
}
// sw.js 中:
self.addEventListener('sync', (event) => {
if (event.tag === 'outbox') event.waitUntil(replayOutbox(event.lastChance));
});

event.lastChance 在浏览器的最后一次重试时为 true,这是告诉用户写入已丢弃的时机。

每个缓存都计入源的配额,而逐出可能一次清空全部。navigator.storage.estimate() 返回保守估计的 quota 与当前 usage(单位字节);因为压缩与去重,数字只是近似值。对必须保留的数据请求 navigator.storage.persist(),见存储持久化、配额与逐出。

const { usage, quota } = await navigator.storage.estimate();
console.log(`${(usage / 1048576).toFixed(1)} MB used of ${(quota / 1048576).toFixed(0)} MB`);

先在线加载一次应用让 install 跑完,然后在 Chrome DevTools 打开 Network 面板,在 Disable cache 复选框旁的 Network throttling 下拉菜单中选择 Offline(英文界面),再重新加载。外壳从 shell-v3 绘制,/api/ 请求返回最近缓存的副本,导航到未缓存的 URL 时显示 /offline.html。也要从冷启动测试已安装的 应用;热标签页的内存里早已备好一切。

← 返回指南总览。