PWA离线缓存策略设计与实现
发布日期: 2026/08/07 阅读总量: 0

上个月的活动页白屏事故

我们上线了一个暑期旅游活动页,投放抖音和微信广告。运营第二天就反馈:用户在地铁、地下商场扫码进入,页面白屏,几乎秒退。

当时的数据很难看:

  • 弱网(4G 两格信号)白屏率 37%
  • 平均首屏时间 12.6s
  • 落地页跳出率 58%

我把 Network 面板翻了一遍,所有静态资源都走了 HTTP 缓存,但网络一断,浏览器直接报 net::ERR_INTERNET_DISCONNECTED,连缓存都没机会用。PWA 的 Service Worker 才是浏览器层面的"代理",能在网络不可用时从 Cache Storage 兜底。这篇文章记录我对离线缓存的完整设计和落地过程。

01 HTTP 缓存为什么不够用

HTTP 缓存(Cache-ControlETag)有个本质缺陷:它只能在"在线"时生效。浏览器发起请求,网络不可达,请求直接失败,缓存不会被命中。

更麻烦的是:

  • 用户下拉刷新或强制刷新时,HTTP 缓存会被绕过
  • 离线时浏览器不会给你任何兜底页面

Service Worker 拦截 fetch 事件后,可以完全自己决定:走网络、走缓存、或者两者结合。这才是离线缓存的技术基础。

02 三种离线缓存策略对比

实现前先想清楚策略。离线缓存不是"全部缓存优先",不同资源要有不同取舍。我用三种策略做对比:

策略 命中后行为 离线可用 数据新鲜度 响应速度 适用场景
Cache-First
缓存优先
直接返回缓存,后台不请求网络 最快,约 0.1s 带 hash 的静态 JS/CSS、图片、字体
Network-First
网络优先
先请求网络,成功则返回并更新缓存;失败才回退缓存 慢,受网络影响 页面 HTML、列表接口、实时数据
Stale-While-Revalidate
SWR
先返回缓存,后台发起请求更新缓存供下次使用 快,同时后台异步更新 低频变化的接口、埋点上报

决策建议:

  • 资源文件名带 hash(如 main.2f3a9c.js):用 Cache-First,因为 hash 变了说明内容变了,缓存永不过期
  • index.html:用 Network-First,保证发版后拿到最新 HTML 引用的最新资源
  • 活动接口:用 Network-First + 3s 超时,弱网下最多等 3 秒,超时直接返回缓存

03 完整实现:手写 SW + Workbox 构建

3.1 前端注册 SW

注册代码放在页面入口文件。注意 updateViaCache: 'none',这是为了避免浏览器用 HTTP 缓存机制去缓存 sw.js,导致更新失效。

// register-sw.js
export function registerSW() {
  if (!('serviceWorker' in navigator)) {
    console.warn('当前浏览器不支持 Service Worker,PWA 功能不可用');
    return;
  }

  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
        updateViaCache: 'none',
      });
      console.log(`[SW] 注册成功,scope: ${registration.scope}`);

      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        if (!newWorker) return;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'activated') {
            console.log('[SW] 新版本已激活');
          }
        });
      });
    } catch (err) {
      console.error('[SW] 注册失败', err);
    }
  });
}

3.2 手写 SW:理解原理

生产环境我会推荐 Workbox,但先写一版手写的,把核心原理讲清楚。逻辑只有三个事件:install(预缓存)、activate(清理旧缓存)、fetch(路由策略)。

// sw.js – 手写简化版,仅用于理解原理
const CACHE_VERSION = '2024-07-01';
const CACHE_PREFIX = 'summer-campaign';
const CACHE_NAME = `${CACHE_PREFIX}-${CACHE_VERSION}`;

// 预缓存列表:构建时由脚本生成
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches
      .open(CACHE_NAME)
      .then((cache) => cache.addAll([
        '/',
        '/index.html',
        '/assets/main.2f3a9c.js',
        '/assets/style.7d1b4c.css',
        '/assets/hero-image.webp',
      ]))
      .then(() => self.skipWaiting())
  );
});

// 清理旧版本缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches
      .keys()
      .then((keys) =>
        Promise.all(
          keys
            .filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
            .map((key) => caches.delete(key))
        )
      )
      .then(() => self.clients.claim())
  );
});

// 请求路由:不同资源走不同策略
self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;

  // HTML:网络优先
  if (request.mode === 'navigate') {
    event.respondWith(networkFirst(request));
    return;
  }

  // 带 hash 的静态资源:缓存优先
  const url = new URL(request.url);
  if (/\/assets\//.test(url.pathname)) {
    event.respondWith(cacheFirst(request));
    return;
  }

  // 其余 GET 请求:SWR
  event.respondWith(staleWhileRevalidate(request));
});

async function networkFirst(request) {
  const cache = await caches.open(CACHE_NAME);
  try {
    const response = await fetch(request);
    if (response.ok) {
      cache.put(request, response.clone());
    }
    return response;
  } catch (err) {
    const cached = await cache.match(request);
    if (cached) return cached;
    return caches.match('/offline.html');
  }
}

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
  }
  return response;
}

async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME);
  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;
}

这里有几个关键细节:

  • skipWaiting():新 SW 安装完成后立即跳过"等待"状态,否则必须关闭所有页面才能激活
  • clients.claim():让当前页面立即被 SW 控制,否则第一次访问时页面不受控,需要刷新一次
  • response.clone():Response 是流,只能消费一次。放入缓存和返回给页面需要两份
  • install 阶段 addAll 一旦失败,整个 SW 会被浏览器丢弃,不会激活

3.3 生产版本:Workbox 6.10.2 配置

手写版能跑,但生产环境要考虑缓存过期、异常兜底、跨域处理。这些 Workbox 都帮你处理了。我们用的版本是 Workbox 6.10.2,用 generateSW 模式,配置如下:

// workbox-config.js
// https://developer.chrome.com/docs/workbox/reference/workbox-build/
module.exports = {
  globDirectory: 'dist/',
  globPatterns: [
    '**/*.{html,js,css,png,svg,webp,woff2}',
  ],
  globIgnores: ['**/*.map'],
  maximumFileSizeToCacheInBytes: 2 * 1024 * 1024,
  swDest: 'dist/sw.js',

  runtimeCaching: [
    {
      // 图片缓存策略:Cache-First + 数量/时间上限
      urlPattern: /\.(?:png|jpg|jpeg|svg|webp|gif)$/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'campaign-images',
        expiration: {
          maxEntries: 60,
          maxAgeSeconds: 30 * 24 * 60 * 60, // 30天
        },
        cacheableResponse: {
          statuses: [0, 200],
        },
      },
    },
    {
      // 接口缓存策略:Network-First,3秒超时回退缓存
      urlPattern: /\/api\/v1\/summer-campaign\//,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'campaign-api',
        networkTimeoutSeconds: 3,
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 24 * 60 * 60, // 1天
        },
      },
    },
  ],
};

statuses: [0, 200] 里的 0 是给跨域不透明响应(opaque response)用的。如果 CDN 资源没开 CORS,Workbox 也能缓存,但要小心它会占用额外内存。后面避坑里细说。

3.4 构建脚本 + 版本号注入

Workbox 的 generateSW 会自动生成 sw.js,但它不会自动处理"SW 内容变化"这个更新机制。浏览器靠字节级对比判断 SW 是否更新。如果每次构建生成的 sw.js 内容不变,就不会触发 install,缓存的旧资源永远不更新。所以要把版本号写进 sw.js

我们用 Vite 5.2.0 构建,增加一个版本号生成脚本:

#!/bin/bash
# generate-sw-version.sh
# 把 Git 短哈希 + 构建时间写入 SW 版本号,确保每次发版生成新 SW
VERSION=$(git rev-parse --short HEAD)-$(date +%Y%m%d%H%M%S)
cat > src/sw-version.js <<EOF
export const CACHE_VERSION = '${VERSION}';
EOF
echo "✅ SW 版本号已写入: ${VERSION}"

然后在 package.json 里串起来:

{
  "scripts": {
    "build": "bash generate-sw-version.sh && vite build",
    "build:pwa": "bash generate-sw-version.sh && vite build && workbox generateSW workbox-config.js"
  }
}

需要先安装 workbox-cli

npm install --save-dev workbox-cli@6.10.2

3.5 nginx 上 sw.js 的响应头

Service Worker 是一个特殊的文件。浏览器检查 sw.js 更新时,会遵循 HTTP 缓存规则,但标准建议 max-age 不得超过 24 小时。为了每次发版都能及时拿到新 SW,必须关掉 sw.js 的 HTTP 缓存:

# nginx.conf
location /sw.js {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    add_header Service-Worker-Allowed /;
}

04 效果数据

测试环境必须写清楚,不然数据没有参考意义:

  • Chrome 119.0.6045.105,MacBook Pro M1 / 16GB
  • Lighthouse 10.1.0
  • DevTools CPU 4x slowdown,Fast 3G(1.6 Mbps / 750ms RTT)
  • 页面:summer-campaign 活动页,27 个静态资源,总大小 3.8MB

4.1 加载耗时对比

场景 优化前(无 SW) 优化后(SW + Cache-First) 提升
首次加载(网络) 白屏 6.2s / 可交互 12.8s 白屏 6.0s / 可交互 12.5s 首次基本无差异,预缓存资源不阻塞首屏
二次加载(有 HTTP 缓存且在线) 白屏 2.4s / 可交互 5.1s 白屏 480ms / 可交互 1.3s 约 4 倍
完全离线加载 白屏,直接报错 白屏 400ms / 可交互 1.5s 从不可用到可用
单张图片重复加载(图片 1.8MB) 1.8s 80ms 22 倍
Lighthouse PWA 得分 0 100 -

4.2 缓存命中率统计

离线加载时,DevTools Network 面板显示 27 个请求全部 served from ServiceWorker,0 个失败。二次访问在线时:

  • 11 个 JS/CSS:全部 from ServiceWorker,命中率 100%
  • 12 个图片:8 个 from ServiceWorker,4 个 from memory cache
  • 2 个 XHR:NetworkFirst 命中缓存,后台重新请求

4.3 Cache Storage 占用

缓存名称 内容 占用大小
预缓存 HTML / JS / CSS 2.3MB
campaign-images 图片 最多 60 张,约 12MB
campaign-api 接口 JSON 最多 50 条,约 200KB

Chrome 会为每个域名动态分配配额,我们实测最大约 30MB。缓存控制策略(maxEntries)必须设置,否则超过配额会被浏览器整个清空。

05 避坑指南(5 个真实坑)

坑 1:sw.js 被 HTTP 缓存,导致永远不更新

我们第一次发布后,改了缓存策略,但用户侧 24 小时内一直执行老逻辑。原因是 nginx 给 sw.js 加了 Cache-Control: max-age=86400,浏览器在 24 小时内如果发现本地有缓存就直接跳过网络检查。解决:sw.js 必须返回 no-cache,同时注册时加 updateViaCache: 'none'

坑 2:CACHE_NAME 不变,install 不触发

浏览器用"逐字节对比"判断 SW 是否更新。如果代码改了但 sw.js 内容没变(比如版本号是写死常量),新版本永远推不上去。我们踩过:改了 fetch 策略,但用户一直跑旧缓存策略,排查了半天发现 CACHE_VERSION 字符串没改。解决:把版本号生成脚本放进构建流程,每次发版都改 sw.js 内容。

坑 3:缓存了 index.html,发版后白屏

最初我们把 HTML 也做了 Cache-First,结果新版本发布后,index.html 还是旧的,里面引用的 JS 文件名是 main.2f3a9c.js,但构建产物已经变成 main.8b4de0.js,请求 404,白屏。解决:HTML 必须用 Network-First。我们现在对导航请求强制走网络,网络失败才回退缓存。

坑 4:跨域 opaque response 撑爆缓存

CDN 上的资源如果不带 Access-Control-Allow-Origin,浏览器会返回 opaque response,status 是 0,体积很大且无法读取内容。如果直接缓存,Cache Storage 占用会快速上升。我们在活动页缓存了 20 个大图,结果配额爆了,整个缓存被浏览器清掉。解决:要么让 CDN 开启 CORS,要么用 cacheableResponse: { statuses: [0, 200] } 显式声明缓存 opaque response,并严格控制 maxEntries。

坑 5:开发环境 HTTPS 限制

localhost 下 SW 能跑,但用内网 IP(192.168.x.x)访问时,注册直接失败。Service Worker 只能在安全上下文使用(HTTPS 或 localhost)。我们一个小伙伴用手机通过内网 IP 调试,折腾了一下午。解决:开发调试用 vite --host + localhost 访问,内网联调需自签 HTTPS 证书,上线必须 HTTPS。

06 什么时候不需要 PWA 离线缓存

如果只是单次活动页、PV 不过万、用户全是强网环境,不需要 PWA。SW 的生命周期管理是有复杂度的:版本更新、缓存清理、跨域资源处理,每个环节都可能出问题。团队没有足够精力时,直接用 Workbox 一键生成,别手写。

但如果你的业务用户在弱网场景占比高(地铁、地下商业、偏远离线地区),PWA 离线缓存是投入产出比最高的优化手段之一。一个 SW 文件 + 一份配置,首屏速度提升 4 倍,离线从不可用到可用,值得投入。

结语

PWA 离线缓存的核心不是 Service Worker API,而是版本管理和缓存策略的取舍。先想清楚哪些资源追求最新、哪些资源追求最快,再动手写代码。希望这套方案和这些坑能帮你少走弯路。