PWA离线缓存:四套方案实测与避坑
发布日期: 2026/07/28 阅读总量: 0

把白屏按在地上摩擦:那个被电梯吃掉的页面我忍了两年

两年前公司上线了一个移动端 H5 订单查询页,用户在地铁、电梯里打开永远一片空白。PM 天天追,测试在群里发截图:“连 error 提示都没有,像死了一样”。最严重的一次,某大客户在电梯里点了下单,页面转菊花转了20秒然后白屏,直接跑到微博投诉。

我们用了三个月给整个页面套上 Service Worker 离线缓存,上线后离线加载时间从 2.8s 降到 0.3s,Lighthouse PWA 评分从 48 分怼到 96 分。今天把四套缓存方案的选型决策、完整实现、以及我翻过的七个坑全写出来。

一、真实问题:80% 的用户打开页面时网络不可靠

我们的埋点显示:移动端页面请求失败率在工作日早晚高峰达到 12.7%(地铁场景);首次加载成功但后续交互请求失败的占比 9.3%(电梯/隧道切换)。这些失败直接导致白屏或部分组件缺失。

传统的 HTTP 缓存(强缓存 & 协商缓存)只能管浏览器主动缓存,但页面首次加载或者资源更新时依然依赖网络。PWA 的 Service Worker 可以劫持所有请求,在离线时直接从本地 CacheStorage 返回。

但 Service Worker 不是无脑全缓存。选错策略反而会使页面更新滞后、缓存膨胀甚至崩溃。下面给出四种主流缓存策略的实测对比。

二、四套方案实测:数据说话

2.1 方案概览

策略核心逻辑适用场景离线可用实时性
Cache First有缓存直接返回,没有则请求网络并缓存静态资源(JS/CSS/图片)低(缓存不过期则看不到新版本)
Network First先尝试网络,成功则返回并更新缓存;失败则回退缓存API 接口(需要实时数据,但可接受降级)✔(降级)
Stale-While-Revalidate立即返回缓存,同时发起网络请求更新缓存新闻列表、用户头像等可延迟更新的内容✔(立即展示旧数据)
Network Only只走网络,不缓存支付、验证码等必须实时的请求最高

2.2 压测环境与数据

测试条件:

  • Chrome 120(模拟移动端 3G 网络 + 离线)
  • 页面:订单列表页(1 个 HTML,3 个 CSS,5 个 JS,10 张图片,2 个 API)
  • Service Worker 版本:v1.0.0,缓存命名:v1-static, v1-api
  • 数据收集:Performance API + Chrome DevTools Network 面板
策略首次加载(3G)离线加载缓存命中率(10次重复访问)Lighthouse PWA 离线评分总缓存体积(KB)
Cache First1.8s0.3s100%(全部命中)962380
Network First2.1s(网络超时则回退)0.5s(回退缓存)约 90%(API 若返回新数据则覆盖缓存,但离线时永远命中)892380
Stale-While-Revalidate首次 1.8s,后续立即展示旧数据+后台更新0.3s首次 0%,后续 100%952390(含临时缓存差异)
Network Only2.8s(全部需网络)❌失败,白屏0%180

结论:静态资源(JS/CSS/图片)使用 Cache First,API 接口根据实时性需求选用 Network FirstStale-While-Revalidate。我们最终混用三种策略,下文给出完整代码。

三、完整代码实现

3.1 注册 Service Worker(主线程)

文件:sw-register.js,在页面 <head> 内加载。

// sw-register.js (主线程)
if ('serviceWorker' in navigator) {
    // 避免重复注册
    window.addEventListener('load', () => {
        navigator.serviceWorker.register('/sw.js', { scope: '/' })
            .then(reg => {
                console.log('SW 注册成功,scope:', reg.scope);
                // 检测更新(每5分钟检查一次)
                setInterval(() => {
                    reg.update();
                }, 5 * 60 * 1000);
            })
            .catch(err => console.error('SW 注册失败:', err));
    });
}

3.2 Service Worker 完整实现(sw.js)

包含 install、activate 和 fetch 事件。采用混合策略:

  • Cache First:静态资源(url 包含 .js, .css, .png, .jpg, .svg, .woff2)
  • Network First:API 请求(url 包含 /api/,且 method 为 GET)
  • Stale-While-Revalidate:头像类图片(url 包含 /avatar/)
// sw.js
const CACHE_VERSION = 'v1'; // 版本号,更新时修改
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const API_CACHE = `${CACHE_VERSION}-api`;
const AVATAR_CACHE = `${CACHE_VERSION}-avatar`;

// 预缓存静态资源(安装时预加载关键资源)
const PRECACHE_URLS = [
    '/',
    '/static/css/main.a1b2c3.css',
    '/static/js/main.d4e5f6.js',
    '/static/fonts/icons.woff2'
];

self.addEventListener('install', event => {
    console.log('[SW] 安装版本:', CACHE_VERSION);
    event.waitUntil(
        caches.open(STATIC_CACHE).then(cache => {
            return cache.addAll(PRECACHE_URLS);
        })
    );
    // 立即激活新 SW
    self.skipWaiting();
});

self.addEventListener('activate', event => {
    console.log('[SW] 激活');
    event.waitUntil(
        caches.keys().then(cacheNames => {
            return Promise.all(
                cacheNames.map(cache => {
                    // 删除旧版本缓存
                    if (!cache.startsWith(CACHE_VERSION)) {
                        console.log('[SW] 清除旧缓存:', cache);
                        return caches.delete(cache);
                    }
                })
            );
        })
    );
    // 控制所有打开页面
    return self.clients.claim();
});

self.addEventListener('fetch', event => {
    const { request } = event;
    // 只处理 GET 请求
    if (request.method !== 'GET') return;

    const url = new URL(request.url);
    // 忽略非自身域名的请求(跨域资源单独处理)
    if (url.origin !== location.origin) {
        // 简单跨域请求可以缓存,但需要判断 response.ok
        event.respondWith(staleWhileRevalidateCrossOrigin(request));
        return;
    }

    // 判断路径,选择策略
    if (isStaticResource(request)) {
        event.respondWith(cacheFirst(request));
    } else if (isApiRequest(request)) {
        event.respondWith(networkFirst(request));
    } else if (isAvatarRequest(request)) {
        event.respondWith(staleWhileRevalidate(request));
    } else {
        // 其他请求(如页面跳转的 HTML)使用 Network First 兜底
        event.respondWith(networkFirst(request));
    }
});

// ---------- 工具函数 ----------
function isStaticResource(request) {
    const ext = new URL(request.url).pathname.split('.').pop();
    return ['js', 'css', 'png', 'jpg', 'jpeg', 'svg', 'woff2', 'ttf', 'ico'].includes(ext);
}
function isApiRequest(request) {
    return request.url.includes('/api/');
}
function isAvatarRequest(request) {
    return request.url.includes('/avatar/');
}

// ---------- 缓存策略实现 ----------
async function cacheFirst(request) {
    const cached = await caches.match(request);
    if (cached) {
        return cached;
    }
    try {
        const response = await fetch(request);
        if (response.ok) {
            const cache = await caches.open(STATIC_CACHE);
            cache.put(request, response.clone());
        }
        return response;
    } catch (err) {
        // 网络失败又没有缓存,返回离线占位图或空响应
        return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
    }
}

async function networkFirst(request) {
    try {
        const response = await fetch(request);
        if (response.ok) {
            const cache = await caches.open(API_CACHE);
            cache.put(request, response.clone());
        }
        return response;
    } catch (err) {
        const cached = await caches.match(request);
        if (cached) {
            return cached;
        }
        return new Response(JSON.stringify({ error: 'Offline' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
    }
}

async function staleWhileRevalidate(request) {
    const cache = await caches.open(AVATAR_CACHE);
    const cached = await cache.match(request);
    const fetchPromise = fetch(request).then(response => {
        if (response.ok) {
            cache.put(request, response.clone());
        }
        return response;
    }).catch(() => cached); // 网络失败时,如果已经有缓存则返回缓存

    return cached || fetchPromise;
}

async function staleWhileRevalidateCrossOrigin(request) {
    // 跨域资源简单处理:不缓存,只网络
    try {
        const response = await fetch(request);
        if (response.ok) {
            // 跨域资源需要判断响应头包含 'Access-Control-Allow-Origin'
            // 如果不想缓存跨域,直接返回
            return response;
        }
        return response;
    } catch {
        return new Response('', { status: 204 });
    }
}

3.3 manifest.json 配置

属性必须包含 display: standaloneiconsstart_url

{
    "name": "订单查询",
    "short_name": "订单",
    "description": "公司移动端订单查询系统",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#ffffff",
    "theme_color": "#1890ff",
    "icons": [
        { "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
        { "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
    ]
}

3.4 在 HTML 中引入 manifest

<link rel="manifest" href="/manifest.json">

3.5 验证脚本:检查 SW 状态

开发环境中可以用此脚本快速确认 Service Worker 是否注册并激活。

// 在控制台执行
navigator.serviceWorker.getRegistration().then(reg => {
    if (reg) {
        console.log('SW active at script:', reg.active?.scriptURL);
        console.log('SW state:', reg.active?.state);
    } else {
        console.warn('No SW registered');
    }
});

四、效果数据:生产环境实测

上线后我们基于 3 万台真实设备采集了数据(Chromium 内核 + iOS Safari):

  • 离线可用率:从 0% 提升至 98.2%(剩下 1.8% 是首次访问未缓存时离线场景)
  • 第二次访问加载时间(3G):从 2.3s 下降到 0.8s(命中缓存)
  • API 接口失败率:从 9.3% 下降到 0.4%(因为 Network First 方案中多数失败回退到缓存)
  • 缓存体积:平均 2.4 MB,占用户设备存储的 0.1%(大部分设备剩余存储 > 1GB)
  • Lighthouse PWA 分数:从 48 分提升至 96 分(唯一失分项是 iOS 上的 manifest 图标尺寸要求 1024px,暂时未补)

缓存命中率统计(使用 Chrome DevTools 的「Service Workers」面板中的「opened caches」功能,以及自定义埋点):静态资源 100% 命中(预缓存列表 + 首次访问后缓存),API 接口 87% 命中(因为首次打开某些 API 尚未请求),头像图片 92%。

五、避坑指南(实战踩过的 7 个坑)

以下坑点按严重程度排列。直接抄我的代码可能也会触发,建议逐一核对。

5.1 坑1:Service Worker 版本更新后,旧缓存没有清除

新 SW 激活后,旧缓存会一直保留,导致用户看到旧版本页面。我们在 activate 事件中根据缓存名前缀(CACHE_VERSION)清除不匹配的缓存,但坑在于:如果新 SW 的 CACHE_VERSION 没变,缓存名称相同,则不会清除旧资源(比如某个 JS 文件更新了但缓存名称相同)。
解决方案:每次发布代码时,必须修改 CACHE_VERSION(例如改为 v2v3),并且确保预缓存列表 URL 变更时能触发新的 fetch。我们甚至用构建工具在打包时自动生成版本号。

5.2 坑2:iOS Safari 不支持 Service Worker(2019年前)

2020 年后 iOS Safari 12.2+ 已支持 SW,但部分老旧设备(如 iPhone 6 停在 iOS 12.1)不支持。我们的用户群体有 3% 仍用旧系统,如果直接注册 SW,这些用户会报错?不会,因为条件判断 if ('serviceWorker' in navigator) 会跳过。但问题在于:这些用户无法享受离线功能,而且我们的首页依赖 SW 预缓存加速(因为有预加载),导致他们在首次访问时性能更差。
解决方案:在注册时加一个 fallback:如果 SW 不支持,加载内联的 polyfill 或直接使用普通 HTTP 缓存。我们选择维持原样,因为 3% 用户影响可控。

5.3 坑3:跨域资源的缓存失败(如 CDN 上的图片)

当我们尝试缓存第三方的 CDN 图片时,cache.put() 会抛出异常,因为跨域响应默认没有 Access-Control-Allow-Origin,导致 response.typeopaque,而 opaque 响应无法被 Cache API 存储。
解决方案:不对跨域资源做缓存,或者要求 CDN 加上 CORS 头部。我们选择不对跨域资源缓存,但至少保证本域资源完全离线。如果必须缓存,请设置 mode: 'cors' 并让 CDN 返回 Access-Control-Allow-Origin: *

5.4 坑4:预缓存列表过大导致安装失败

我们一开始把整个打包后的 dist 目录(> 200 个文件)全部放在 cache.addAll() 里,结果在弱网下 install 阶段经常超时(Chrome 默认超时 5 分钟?实际上 install 事件如果没有 waitUntil 完成,SW 会被视为安装失败)。
解决方案:只预缓存首屏关键资源(HTML、核心 CSS、核心 JS、应用图标),其余资源在首次请求时通过 cacheFirst 按需缓存。我们的 PRECACHE_URLS 只有 4 个文件,总大小约 120KB,安装耗时 < 2s。

5.5 坑5:self.skipWaiting()clients.claim() 导致的页面刷新问题

如果不调用 skipWaiting(),新 SW 会在所有页面关闭后才会激活;如果不调用 clients.claim(),新 SW 不会立即控制当前已打开的页面。但是用户可能正在操作,突然被新 SW 接管导致页面重新加载或请求中断。
解决方案:我们选择 立即激活并接管(因为我们的页面可以接受短暂重刷新)。但更好的做法是提示用户「有新版本,请刷新」,然后调用 skipWaiting() 并触发刷新。我们在实践中发现大多数用户不会主动刷新,所以直接强制激活,虽然会闪一下,但能保证最新代码。

5.6 坑6:localStorage 与 CacheStorage 混用导致离线时数据不同步

我们的页面有一些配置参数存在 localStorage 里,但缓存策略只缓存了 API 响应。离线时 API 返回缓存数据,但 localStorage 的配置可能是旧的,导致渲染错乱。
解决方案:把关键配置也放进预缓存,或者使用 IndexedDB 统一存储离线数据,并在 SW 中同时维护一份配置缓存。我们最终将配置参数移到了 API 接口中返回,避免两端分离。

5.7 坑7:使用 cacheFirst 导致用户看到过时的静态资源(版本更新但缓存未失效)

我们更新了某个 CSS 文件,但缓存版本号没变,结果用户一直看到旧样式持续一周(缓存最大时间设为无限)。
解决方案:除了版本号策略,我们还在构建时给资源文件名加上 hash(webpack 的 [contenthash]),这样每次发布新版本时,URL 发生变化,旧缓存自然无法命中,触发网络请求并缓存新资源。配合版本号变更,双保险。

六、总结

PWA 离线缓存不是银弹,但如果你能管好版本号、预缓存资源、跨域策略和 iOS 兼容四个点,就能让你的移动端页面在没有网络时依然正常工作。我写的这套代码在 GitHub 上已经服役两年,支撑了日均 50 万 PV,建议直接复制并按照避坑指南调整。