Service Worker 离线优先策略:构建真正可靠的 PWA
你有没有遇到过这种场景:地铁里打开一个网页,转了半天菊花,最后白屏了。WiFi 断了一秒钟,正在提交的表单丢了。弱网环境下,页面加载了 10 秒还没出来。
这些问题的根源在于:传统 Web 应用完全依赖网络。没有网络,一切归零。
Service Worker 改变了这个局面。它是一个运行在浏览器后台的独立线程,可以拦截网络请求、管理缓存、实现离线功能。配合正确的缓存策略,你可以构建出**离线优先(Offline First)**的 PWA——即使没有网络,用户也能正常使用你的应用。
本文将深入讲解 Service Worker 的生命周期、各种缓存策略的原理与实现、Workbox 实战,以及后台同步等高级特性。
一、Service Worker 基础
1.1 什么是 Service Worker
Service Worker 本质上是一个网络代理,运行在独立线程中,拥有以下特性:
- 独立于主线程:不能访问 DOM,但可以通过
postMessage与页面通信 - 事件驱动:通过监听
install、activate、fetch等事件工作 - HTTPS Only:出于安全考虑,只能在 HTTPS 环境下使用(localhost 除外)
- 有自己的生命周期:安装 → 等待 → 激活 → 运行
- 可以持久化:即使页面关闭,Service Worker 仍可被唤醒处理事件
1.2 注册 Service Worker
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 FirstHTML 页面 → Network First(或 Stale While Revalidate)API 数据(关键) → Network FirstAPI 数据(非关键) → Stale While Revalidate用户头像/缩略图 → Stale While Revalidate字体文件 → Cache First(+ 长过期时间)第三方 CDN 资源 → Cache First支付/认证请求 → Network Only三、完整的 Service Worker 实现
把所有策略组合在一起:
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 Revalidateasync 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 安装与配置
npm install workbox-webpack-plugin # Webpack# 或npm install workbox-build # 通用构建工具# 或直接在 SW 中 importScripts4.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 Shellconst handler = createHandlerBoundToURL('/index.html');const navigationRoute = new NavigationRoute(handler, { // 排除 API 路径 denylist: [/^\/api\//],});registerRoute(navigationRoute);
// 3. API 请求 → Network FirstregisterRoute( ({ 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 RevalidateregisterRoute( ({ 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 FirstregisterRoute( ({ 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 FirstregisterRoute( ({ 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 配置:
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 配置:
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 手动实现
// 请求队列(使用 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
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 SyncregisterRoute( ({ url, request }) => url.pathname.startsWith('/api/') && request.method === 'POST', new NetworkOnly({ plugins: [bgSyncPlugin], }), 'POST');六、Service Worker 更新策略
6.1 更新流程
1. 浏览器检测到 sw.js 文件有变化(字节级比较)2. 下载新的 sw.js3. 新 SW 进入 installing 状态4. install 事件触发,预缓存新资源5. 新 SW 进入 waiting 状态(等旧 SW 控制的页面全部关闭)6. 所有旧页面关闭后,新 SW 激活7. activate 事件触发,清理旧缓存6.2 提示用户更新
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(); }});七、离线页面设计
<!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 应用的基石。通过本文你应该掌握了:
- Service Worker 生命周期:安装 → 等待 → 激活 → 运行
- 五大缓存策略:Cache First、Network First、Stale While Revalidate、Cache Only、Network Only
- Workbox 实战:用声明式的方式配置缓存策略,比手写优雅得多
- 后台同步:离线时暂存请求,网络恢复后自动重发
- 更新策略:如何优雅地提示用户更新
离线优先不是”没有网络也能用”这么简单。它是一种架构思维的转变——从”假设网络总是可用”转变为”假设网络随时可能断开”。这种思维让你的应用在弱网、断网、不稳定网络环境下都能给用户提供可靠的体验。
最后一个建议:渐进增强。不要试图一次性把整个应用变成离线可用的 PWA。从最关键的页面(首页、核心功能)开始,逐步扩展离线能力。每一步都要确保不破坏在线功能。
Service Worker 是强大的工具,但也是双刃剑——缓存策略配错了,用户可能永远看到旧版本。务必做好版本管理和更新机制。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!