Service Worker 离线优先策略:构建真正可靠的 PWA

4403 字
22 分钟
Service Worker 离线优先策略:构建真正可靠的 PWA

你有没有遇到过这种场景:地铁里打开一个网页,转了半天菊花,最后白屏了。WiFi 断了一秒钟,正在提交的表单丢了。弱网环境下,页面加载了 10 秒还没出来。

这些问题的根源在于:传统 Web 应用完全依赖网络。没有网络,一切归零。

Service Worker 改变了这个局面。它是一个运行在浏览器后台的独立线程,可以拦截网络请求、管理缓存、实现离线功能。配合正确的缓存策略,你可以构建出**离线优先(Offline First)**的 PWA——即使没有网络,用户也能正常使用你的应用。

本文将深入讲解 Service Worker 的生命周期、各种缓存策略的原理与实现、Workbox 实战,以及后台同步等高级特性。

一、Service Worker 基础#

1.1 什么是 Service Worker#

Service Worker 本质上是一个网络代理,运行在独立线程中,拥有以下特性:

  • 独立于主线程:不能访问 DOM,但可以通过 postMessage 与页面通信
  • 事件驱动:通过监听 installactivatefetch 等事件工作
  • HTTPS Only:出于安全考虑,只能在 HTTPS 环境下使用(localhost 除外)
  • 有自己的生命周期:安装 → 等待 → 激活 → 运行
  • 可以持久化:即使页面关闭,Service Worker 仍可被唤醒处理事件

1.2 注册 Service Worker#

main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/', // 控制范围
});
console.log('SW 注册成功,scope:', registration.scope);
// 监听更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('发现新版本 SW');
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// 有旧版本在运行,新版本已安装但等待激活
console.log('新版本已就绪,刷新页面即可更新');
showUpdateNotification();
} else {
// 首次安装
console.log('内容已缓存,可离线使用');
}
}
});
});
} catch (error) {
console.error('SW 注册失败:', error);
}
});
}

1.3 Service Worker 生命周期#

注册(Register)
安装(Install)→ 失败则废弃
等待(Waiting)→ 等旧 SW 控制的所有页面关闭
激活(Activate)→ 清理旧缓存
运行(Activated)→ 拦截 fetch 事件
闲置(Idle)→ 可被浏览器终止以节省资源
终止(Terminated)→ 有事件时重新唤醒
// sw.js — 完整的生命周期处理
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.svg',
'/offline.html', // 离线回退页面
];
// 安装:预缓存静态资源
self.addEventListener('install', (event) => {
console.log('[SW] 安装中...');
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
console.log('[SW] 预缓存静态资源');
return cache.addAll(STATIC_ASSETS);
})
.then(() => {
// 跳过等待,立即激活
// 注意:这会导致新 SW 接管旧 SW 的页面,可能有兼容问题
return self.skipWaiting();
})
);
});
// 激活:清理旧缓存
self.addEventListener('activate', (event) => {
console.log('[SW] 激活中...');
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('[SW] 删除旧缓存:', name);
return caches.delete(name);
})
);
})
.then(() => {
// 立即接管所有页面
return self.clients.claim();
})
);
});

二、缓存策略详解#

缓存策略是 Service Worker 的核心。不同的资源类型适合不同的策略。

2.1 Cache First(缓存优先)#

逻辑:先查缓存,有则返回;没有则请求网络,并缓存结果。

适用场景:静态资源(CSS、JS、字体、图片)——这些资源内容不会变(带 hash),命中缓存就不需要网络请求。

// Cache First 策略实现
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
return cached;
}
try {
const response = await fetch(request);
// 只缓存成功的响应
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
// 网络失败且无缓存,返回离线页面
return caches.match('/offline.html');
}
}
// 在 fetch 事件中使用
self.addEventListener('fetch', (event) => {
const { request } = event;
// 静态资源使用 Cache First
if (isStaticAsset(request.url)) {
event.respondWith(cacheFirst(request, 'static-cache-v1'));
}
});
function isStaticAsset(url) {
return /\.(js|css|woff2?|png|jpg|jpeg|webp|svg|ico)(\?.*)?$/.test(url);
}

2.2 Network First(网络优先)#

逻辑:先请求网络;成功则缓存并返回;失败则返回缓存。

适用场景:API 请求、动态页面——需要最新数据,但断网时能降级使用缓存数据。

async function networkFirst(request, cacheName, timeout = 3000) {
const cache = await caches.open(cacheName);
try {
// 设置超时,避免弱网下等待太久
const response = await Promise.race([
fetch(request),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), timeout)
),
]);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
// 网络失败或超时,使用缓存
const cached = await cache.match(request);
if (cached) {
return cached;
}
// 完全没有数据,返回自定义的离线响应
return new Response(
JSON.stringify({
error: 'offline',
message: '当前处于离线状态,请检查网络连接',
}),
{
status: 503,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
// 在 fetch 事件中使用
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.url.includes('/api/')) {
event.respondWith(networkFirst(request, 'api-cache-v1', 3000));
}
});

2.3 Stale While Revalidate(过期重验证)#

逻辑:立即返回缓存(即使可能过期),同时在后台请求网络更新缓存。下次请求时就能拿到新数据。

适用场景:非关键数据(用户头像、新闻列表、统计数据)——速度优先,允许短暂的数据延迟。

async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// 无论是否有缓存,都发起网络请求(后台更新)
const fetchPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => null);
// 有缓存就立即返回,没有就等网络
if (cached) {
// 后台更新不阻塞返回
// 可以通过 postMessage 通知页面数据已更新
fetchPromise.then((response) => {
if (response) {
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage({
type: 'CACHE_UPDATED',
url: request.url,
});
});
});
}
});
return cached;
}
// 没有缓存,等待网络
const response = await fetchPromise;
return response || new Response('Offline', { status: 503 });
}

2.4 Cache Only 与 Network Only#

// Cache Only:只用缓存,不请求网络
// 适用于预缓存的静态资源(你确定一定在缓存中的资源)
async function cacheOnly(request, cacheName) {
const cache = await caches.open(cacheName);
return cache.match(request);
}
// Network Only:只用网络,不缓存
// 适用于实时性要求极高的请求(如支付、认证)
async function networkOnly(request) {
return fetch(request);
}

2.5 策略选择指南#

资源类型 → 推荐策略
─────────────────────────────────────────
带 hash 的静态资源 → Cache First
HTML 页面 → Network First(或 Stale While Revalidate)
API 数据(关键) → Network First
API 数据(非关键) → Stale While Revalidate
用户头像/缩略图 → Stale While Revalidate
字体文件 → Cache First(+ 长过期时间)
第三方 CDN 资源 → Cache First
支付/认证请求 → Network Only

三、完整的 Service Worker 实现#

把所有策略组合在一起:

sw.js
const APP_CACHE = 'app-shell-v2';
const STATIC_CACHE = 'static-v2';
const API_CACHE = 'api-v1';
const IMAGE_CACHE = 'images-v1';
// 预缓存列表
const PRECACHE_URLS = [
'/',
'/index.html',
'/offline.html',
'/styles/critical.css',
'/scripts/app.js',
'/manifest.json',
];
// 安装
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(APP_CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
// 激活
self.addEventListener('activate', (event) => {
const currentCaches = [APP_CACHE, STATIC_CACHE, API_CACHE, IMAGE_CACHE];
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => !currentCaches.includes(name))
.map((name) => caches.delete(name))
)
)
);
self.clients.claim();
});
// 路由分发
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// 跳过非 GET 请求
if (request.method !== 'GET') return;
// 跳过 chrome-extension 等非 http(s) 请求
if (!url.protocol.startsWith('http')) return;
// 导航请求(HTML 页面)→ Network First
if (request.mode === 'navigate') {
event.respondWith(handleNavigate(request));
return;
}
// API 请求 → Network First(带超时)
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, API_CACHE, 5000));
return;
}
// 图片 → Stale While Revalidate
if (request.destination === 'image') {
event.respondWith(
staleWhileRevalidateWithLimit(request, IMAGE_CACHE, 100)
);
return;
}
// 静态资源 → Cache First
if (isStaticAsset(url.pathname)) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// 其他 → Network First
event.respondWith(networkFirst(request, APP_CACHE, 3000));
});
// 导航请求处理
async function handleNavigate(request) {
try {
// 尝试网络
const response = await fetch(request);
// 缓存成功的导航响应
const cache = await caches.open(APP_CACHE);
cache.put(request, response.clone());
return response;
} catch (error) {
// 网络失败,尝试缓存
const cached = await caches.match(request);
if (cached) return cached;
// 都没有,返回离线页面
return caches.match('/offline.html');
}
}
// 带缓存数量限制的 Stale While Revalidate
async function staleWhileRevalidateWithLimit(request, cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then(async (response) => {
if (response.ok) {
await cache.put(request, response.clone());
// 清理超出限制的旧缓存
await trimCache(cacheName, maxEntries);
}
return response;
}).catch(() => null);
return cached || (await fetchPromise) || new Response('', { status: 404 });
}
// 缓存淘汰(LRU 近似)
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxEntries) {
// 删除最早的条目
const deleteCount = keys.length - maxEntries;
for (let i = 0; i < deleteCount; i++) {
await cache.delete(keys[i]);
}
}
}
function isStaticAsset(pathname) {
return /\.(js|css|woff2?|ttf|eot)$/.test(pathname);
}

四、Workbox 实战#

手写 Service Worker 虽然能让你理解原理,但生产环境中推荐使用 Google 的 Workbox 库——它封装了所有缓存策略,处理了大量边界情况,并提供了预缓存、路由、后台同步等开箱即用的功能。

4.1 安装与配置#

Terminal window
npm install workbox-webpack-plugin # Webpack
# 或
npm install workbox-build # 通用构建工具
# 或直接在 SW 中 importScripts

4.2 使用 Workbox 重写 Service Worker#

// sw.js(使用 Workbox)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { createHandlerBoundToURL } from 'workbox-precaching';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
// 1. 预缓存(由构建工具自动注入文件列表)
precacheAndRoute(self.__WB_MANIFEST);
// 2. 导航请求 → App Shell
const handler = createHandlerBoundToURL('/index.html');
const navigationRoute = new NavigationRoute(handler, {
// 排除 API 路径
denylist: [/^\/api\//],
});
registerRoute(navigationRoute);
// 3. API 请求 → Network First
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 60 * 60, // 1 小时
}),
],
})
);
// 4. 图片 → Stale While Revalidate
registerRoute(
({ request }) => request.destination === 'image',
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天
purgeOnQuotaError: true, // 存储空间不足时自动清理
}),
],
})
);
// 5. 字体 → Cache First
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: 'fonts',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 年
}),
],
})
);
// 6. 静态资源(JS/CSS)→ Cache First
registerRoute(
({ request }) =>
request.destination === 'script' || request.destination === 'style',
new CacheFirst({
cacheName: 'static-resources',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天
}),
],
})
);

4.3 Workbox 构建集成#

Webpack 配置

webpack.config.js
const { InjectManifest } = require('workbox-webpack-plugin');
module.exports = {
plugins: [
new InjectManifest({
swSrc: './src/sw.js',
swDest: 'sw.js',
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
exclude: [/\.map$/, /^manifest.*\.js$/],
}),
],
};

Vite 配置

vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.js',
registerType: 'prompt', // 'autoUpdate' 或 'prompt'
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
},
manifest: {
name: 'My PWA App',
short_name: 'MyApp',
theme_color: '#4f46e5',
icons: [
{
src: 'icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'icon-512.png',
sizes: '512x512',
type: 'image/png',
},
],
},
}),
],
};

五、后台同步(Background Sync)#

离线时用户提交的数据怎么办?后台同步允许你把请求暂存,等网络恢复后自动重发。

5.1 基本原理#

用户离线提交表单
Service Worker 拦截请求
请求存入 IndexedDB 队列
注册 sync 事件
网络恢复 → 浏览器触发 sync 事件
Service Worker 从队列中取出请求并重发

5.2 手动实现#

sw.js
// 请求队列(使用 IndexedDB)
class RequestQueue {
constructor(dbName = 'sync-queue') {
this.dbName = dbName;
this.storeName = 'requests';
}
async _getDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, {
keyPath: 'id',
autoIncrement: true,
});
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async enqueue(requestData) {
const db = await this._getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
store.add({
url: requestData.url,
method: requestData.method,
headers: Object.fromEntries(requestData.headers),
body: requestData.body,
timestamp: Date.now(),
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async dequeueAll() {
const db = await this._getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => {
const items = request.result;
// 清空队列
store.clear();
resolve(items);
};
request.onerror = () => reject(request.error);
});
}
}
const queue = new RequestQueue();
// 拦截 POST 请求
self.addEventListener('fetch', (event) => {
if (event.request.method === 'POST' && event.request.url.includes('/api/')) {
event.respondWith(handlePostRequest(event.request));
}
});
async function handlePostRequest(request) {
try {
const response = await fetch(request.clone());
return response;
} catch (error) {
// 网络失败,存入队列
const body = await request.text();
await queue.enqueue({
url: request.url,
method: request.method,
headers: request.headers,
body,
});
// 注册后台同步
await self.registration.sync.register('sync-posts');
// 返回一个"已暂存"的响应
return new Response(
JSON.stringify({
status: 'queued',
message: '请求已暂存,网络恢复后将自动提交',
}),
{
status: 202,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
// 后台同步事件
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-posts') {
event.waitUntil(replayQueue());
}
});
async function replayQueue() {
const requests = await queue.dequeueAll();
for (const req of requests) {
try {
await fetch(req.url, {
method: req.method,
headers: req.headers,
body: req.body,
});
console.log('[SW] 同步成功:', req.url);
} catch (error) {
// 仍然失败,重新入队
await queue.enqueue(req);
// 重新注册 sync
await self.registration.sync.register('sync-posts');
throw error; // 让浏览器知道同步失败,稍后重试
}
}
// 通知页面同步完成
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({ type: 'SYNC_COMPLETE', count: requests.length });
});
}

5.3 使用 Workbox 的 Background Sync#

sw.js
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
const bgSyncPlugin = new BackgroundSyncPlugin('api-queue', {
maxRetentionTime: 24 * 60, // 最多保留 24 小时(分钟)
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request);
console.log('[BgSync] 重发成功:', entry.request.url);
} catch (error) {
console.error('[BgSync] 重发失败:', error);
await queue.unshiftRequest(entry);
throw error;
}
}
// 通知页面
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({ type: 'SYNC_COMPLETE' });
});
},
});
// 所有 POST 请求到 /api/ 的路由使用 Background Sync
registerRoute(
({ url, request }) =>
url.pathname.startsWith('/api/') && request.method === 'POST',
new NetworkOnly({
plugins: [bgSyncPlugin],
}),
'POST'
);

六、Service Worker 更新策略#

6.1 更新流程#

1. 浏览器检测到 sw.js 文件有变化(字节级比较)
2. 下载新的 sw.js
3. 新 SW 进入 installing 状态
4. install 事件触发,预缓存新资源
5. 新 SW 进入 waiting 状态(等旧 SW 控制的页面全部关闭)
6. 所有旧页面关闭后,新 SW 激活
7. activate 事件触发,清理旧缓存

6.2 提示用户更新#

main.js
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!refreshing) {
refreshing = true;
window.location.reload();
}
});
async function registerSW() {
const registration = await navigator.serviceWorker.register('/sw.js');
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
// 显示更新提示
showUpdateBanner(newWorker);
}
});
});
}
function showUpdateBanner(worker) {
const banner = document.createElement('div');
banner.className = 'update-banner';
banner.innerHTML = `
<p>新版本已就绪</p>
<button id="update-btn">立即更新</button>
<button id="dismiss-btn">稍后</button>
`;
document.body.appendChild(banner);
document.getElementById('update-btn').addEventListener('click', () => {
// 通知新 SW 跳过等待
worker.postMessage({ type: 'SKIP_WAITING' });
banner.remove();
});
document.getElementById('dismiss-btn').addEventListener('click', () => {
banner.remove();
});
}
// sw.js 中处理 SKIP_WAITING 消息
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});

七、离线页面设计#

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>离线 - MyApp</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f5f5f5;
color: #333;
}
.offline-container {
text-align: center;
padding: 2rem;
}
.offline-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
p { color: #666; margin-bottom: 1.5rem; }
.retry-btn {
padding: 0.75rem 2rem;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
}
.retry-btn:hover { background: #4338ca; }
.cached-pages {
margin-top: 2rem;
text-align: left;
}
.cached-pages h3 {
font-size: 0.9rem;
color: #666;
margin-bottom: 0.5rem;
}
.cached-pages a {
display: block;
padding: 0.5rem 0;
color: #4f46e5;
text-decoration: none;
}
</style>
</head>
<body>
<div class="offline-container">
<div class="offline-icon">📡</div>
<h1>你好像断网了</h1>
<p>别担心,之前访问过的内容仍然可用</p>
<button class="retry-btn" onclick="window.location.reload()">
重试连接
</button>
<div class="cached-pages" id="cachedPages">
<h3>可离线访问的页面:</h3>
</div>
</div>
<script>
// 列出已缓存的页面
async function listCachedPages() {
const container = document.getElementById('cachedPages');
const cacheNames = await caches.keys();
for (const name of cacheNames) {
const cache = await caches.open(name);
const keys = await cache.keys();
for (const request of keys) {
const url = new URL(request.url);
if (
url.pathname !== '/offline.html' &&
request.headers.get('accept')?.includes('text/html')
) {
const link = document.createElement('a');
link.href = url.pathname;
link.textContent = url.pathname === '/' ? '首页' : url.pathname;
container.appendChild(link);
}
}
}
}
listCachedPages();
// 网络恢复时自动刷新
window.addEventListener('online', () => {
window.location.reload();
});
</script>
</body>
</html>

八、调试 Service Worker#

8.1 Chrome DevTools#

Application → Service Workers
- 查看当前 SW 状态(installing/waiting/activated)
- 手动 Update / Unregister / Stop
- 勾选 "Update on reload" 开发时自动更新
- 勾选 "Bypass for network" 临时禁用 SW
Application → Cache Storage
- 查看所有缓存及其内容
- 手动删除缓存条目
Network → 查看请求是否被 SW 拦截
- Size 列显示 "(ServiceWorker)" 表示由 SW 返回

8.2 常见陷阱#

// 陷阱 1:忘记 event.waitUntil()
// ❌ 异步操作可能在 SW 被终止后还没完成
self.addEventListener('install', (event) => {
caches.open('v1').then((cache) => cache.addAll(urls)); // 可能不完整
});
// ✅ 用 waitUntil 保证异步操作完成
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then((cache) => cache.addAll(urls))
);
});
// 陷阱 2:缓存了 opaque response 却不知道
// 跨域请求如果没有 CORS,返回的是 opaque response(status=0)
// opaque response 会占用大量缓存配额(每个 ~7MB)
// 陷阱 3:没有版本化缓存名
// ❌ 永远不会清理旧缓存
const CACHE_NAME = 'my-cache';
// ✅ 版本化
const CACHE_NAME = 'my-cache-v3';
// 陷阱 4:response 只能被消费一次
// ❌
event.respondWith(
fetch(request).then((response) => {
cache.put(request, response); // 消费了 response body
return response; // 这里 body 已经是空的了!
})
);
// ✅ 使用 clone()
event.respondWith(
fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
})
);

九、存储配额与管理#

// 查看存储使用情况
async function checkStorageQuota() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
console.log(`已用: ${(estimate.usage / 1024 / 1024).toFixed(2)} MB`);
console.log(`配额: ${(estimate.quota / 1024 / 1024).toFixed(2)} MB`);
console.log(`使用率: ${((estimate.usage / estimate.quota) * 100).toFixed(1)}%`);
}
}
// 请求持久化存储(避免被浏览器自动清理)
async function requestPersistentStorage() {
if (navigator.storage && navigator.storage.persist) {
const granted = await navigator.storage.persist();
console.log('持久化存储:', granted ? '已授权' : '未授权');
}
}

十、总结#

Service Worker 是构建可靠 Web 应用的基石。通过本文你应该掌握了:

  1. Service Worker 生命周期:安装 → 等待 → 激活 → 运行
  2. 五大缓存策略:Cache First、Network First、Stale While Revalidate、Cache Only、Network Only
  3. Workbox 实战:用声明式的方式配置缓存策略,比手写优雅得多
  4. 后台同步:离线时暂存请求,网络恢复后自动重发
  5. 更新策略:如何优雅地提示用户更新

离线优先不是”没有网络也能用”这么简单。它是一种架构思维的转变——从”假设网络总是可用”转变为”假设网络随时可能断开”。这种思维让你的应用在弱网、断网、不稳定网络环境下都能给用户提供可靠的体验。

最后一个建议:渐进增强。不要试图一次性把整个应用变成离线可用的 PWA。从最关键的页面(首页、核心功能)开始,逐步扩展离线能力。每一步都要确保不破坏在线功能。

Service Worker 是强大的工具,但也是双刃剑——缓存策略配错了,用户可能永远看到旧版本。务必做好版本管理和更新机制。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Service Worker 离线优先策略:构建真正可靠的 PWA
https://boke.hackerdream.xyz/posts/service-worker-offline/
作者
晴天
发布于
2026-01-16
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录