PWA离线缓存策略:从踩坑到落地
发布日期: 2026/07/20 阅读总量: 0

一、真实场景:一个离线就崩的H5活动页

2024年双11,我们团队负责一个H5秒杀活动页。上线后,用户反馈:在电梯、地铁等弱网环境下,页面白屏,商品列表加载失败,直接导致转化率下降40%。

排查发现:页面依赖大量静态资源(JS/CSS/图片)和API数据,没有做任何离线缓存。网络一断,所有请求都挂掉。

解决方案:引入PWA离线缓存。但问题来了——缓存策略怎么选?

二、问题:三种缓存策略,选哪个?

PWA离线缓存核心是Service Worker拦截请求,配合Cache API存储响应。常见策略有三种:

  • Cache-First(缓存优先):先查缓存,有则返回,无则网络请求并缓存。
  • Network-First(网络优先):先请求网络,成功则缓存并返回,失败则回退到缓存。
  • Stale-While-Revalidate(过期时重新验证):立即返回缓存,同时发起网络请求更新缓存。

每种策略适用场景不同。选错策略,要么缓存失效,要么用户看到旧数据。

三、我的方案:按资源类型混合策略

经过压测和线上数据对比,我最终采用混合策略:

  • 静态资源(JS/CSS/字体):Cache-First。这些文件版本号固定,更新频率低,缓存命中率95%以上。
  • API数据(商品列表、详情):Network-First。需要实时性,但离线时提供缓存兜底。
  • 图片资源:Stale-While-Revalidate。图片体积大,先展示缓存图,后台更新。

3.1 方案一:手写Service Worker(纯Cache API)

版本:Chrome 120+,Service Worker API 标准。

// sw.js - 手写缓存策略
const CACHE_NAME = 'pwa-cache-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/js/app.js',
  '/css/style.css',
  '/fonts/icon.woff2'
];

// 安装阶段:预缓存静态资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('预缓存静态资源');
      return cache.addAll(STATIC_ASSETS);
    })
  );
});

// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((name) => {
          if (name !== CACHE_NAME) {
            console.log('删除旧缓存:', name);
            return caches.delete(name);
          }
        })
      );
    })
  );
});

// 拦截请求:混合策略
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  // 静态资源:Cache-First
  if (url.pathname.match(/\.(js|css|woff2?)$/)) {
    event.respondWith(cacheFirst(event.request));
  }
  // API请求:Network-First
  else if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request));
  }
  // 图片:Stale-While-Revalidate
  else if (url.pathname.match(/\.(png|jpg|jpeg|gif|svg|webp)$/)) {
    event.respondWith(staleWhileRevalidate(event.request));
  }
  // 其他:Network-First
  else {
    event.respondWith(networkFirst(event.request));
  }
});

// Cache-First 实现
async function cacheFirst(request) {
  const cachedResponse = await caches.match(request);
  if (cachedResponse) {
    return cachedResponse;
  }
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    return new Response('离线', { status: 503 });
  }
}

// Network-First 实现
async function networkFirst(request) {
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    const cachedResponse = await caches.match(request);
    if (cachedResponse) {
      return cachedResponse;
    }
    return new Response('离线', { status: 503 });
  }
}

// Stale-While-Revalidate 实现
async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME);
  const cachedResponse = await cache.match(request);
  const fetchPromise = fetch(request).then((networkResponse) => {
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  }).catch(() => cachedResponse);
  
  return cachedResponse || fetchPromise;
}

3.2 方案二:使用Workbox(Google官方库)

版本:Workbox 7.0.0,通过CDN引入。

// sw.js - 使用Workbox
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');

if (workbox) {
  console.log('Workbox 加载成功');
  
  // 设置缓存名称
  workbox.core.setCacheNameDetails({
    prefix: 'pwa-app',
    suffix: 'v1',
    precache: 'precache',
    runtime: 'runtime'
  });
  
  // 预缓存静态资源
  workbox.precaching.precacheAndRoute([
    { url: '/', revision: '1' },
    { url: '/index.html', revision: '1' },
    { url: '/js/app.js', revision: '1' },
    { url: '/css/style.css', revision: '1' },
    { url: '/fonts/icon.woff2', revision: '1' }
  ]);
  
  // 静态资源:Cache-First
  workbox.routing.registerRoute(
    /\.(?:js|css|woff2?)$/,
    new workbox.strategies.CacheFirst({
      cacheName: 'static-cache',
      plugins: [
        new workbox.expiration.ExpirationPlugin({
          maxEntries: 60,
          maxAgeSeconds: 30 * 24 * 60 * 60 // 30天
        })
      ]
    })
  );
  
  // API请求:Network-First
  workbox.routing.registerRoute(
    /\/api\//,
    new workbox.strategies.NetworkFirst({
      cacheName: 'api-cache',
      plugins: [
        new workbox.expiration.ExpirationPlugin({
          maxEntries: 50,
          maxAgeSeconds: 5 * 60 // 5分钟
        }),
        new workbox.backgroundSync.BackgroundSyncPlugin('api-queue', {
          maxRetentionTime: 24 * 60 // 重试24小时
        })
      ]
    })
  );
  
  // 图片:Stale-While-Revalidate
  workbox.routing.registerRoute(
    /\.(?:png|jpg|jpeg|gif|svg|webp)$/,
    new workbox.strategies.StaleWhileRevalidate({
      cacheName: 'image-cache',
      plugins: [
        new workbox.expiration.ExpirationPlugin({
          maxEntries: 100,
          maxAgeSeconds: 7 * 24 * 60 * 60 // 7天
        })
      ]
    })
  );
  
  // 离线回退
  workbox.routing.setDefaultHandler(new workbox.strategies.NetworkOnly());
  workbox.routing.registerRoute(
    /\/api\//,
    new workbox.strategies.NetworkFirst({
      networkTimeoutSeconds: 3,
      fallbackToCache: true
    })
  );
} else {
  console.log('Workbox 加载失败');
}

3.3 方案对比

维度手写Service WorkerWorkbox
代码量~150行~60行
学习成本高(需理解Cache API)低(配置化)
灵活性完全可控受限于插件
缓存过期手动实现内置ExpirationPlugin
后台同步手动实现内置BackgroundSyncPlugin
调试难度高(需Chrome DevTools)低(Workbox日志)

结论:小项目用手写,大项目用Workbox。我最终选Workbox,因为项目有20+API,后台同步需求多。

四、完整代码实现:注册Service Worker

在HTML中注册Service Worker,注意作用域和版本控制。

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>PWA离线缓存示例</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <div id="app"></div>
  <script src="/js/app.js"></script>
  <script>
    // 注册Service Worker
    if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker.register('/sw.js', {
          scope: '/'  // 作用域,控制哪些页面受缓存影响
        }).then((registration) => {
          console.log('Service Worker 注册成功:', registration.scope);
          
          // 监听更新
          registration.addEventListener('updatefound', () => {
            const newWorker = registration.installing;
            newWorker.addEventListener('statechange', () => {
              if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
                // 新版本已安装,提示用户刷新
                showUpdateNotification();
              }
            });
          });
        }).catch((error) => {
          console.error('Service Worker 注册失败:', error);
        });
      });
    }
    
    // 更新提示
    function showUpdateNotification() {
      const notification = document.createElement('div');
      notification.className = 'update-notification';
      notification.innerHTML = '新版本已可用,请刷新页面';
      notification.addEventListener('click', () => {
        window.location.reload();
      });
      document.body.appendChild(notification);
    }
  </script>
</body>
</html>

五、效果数据:离线加载速度提升300%

测试环境:Chrome 120,模拟网络节流(Offline模式),页面包含:

  • 5个JS文件(总大小1.2MB)
  • 3个CSS文件(总大小300KB)
  • 10张图片(总大小2.5MB)
  • 3个API请求(总大小150KB)
指标无缓存Cache-FirstNetwork-First混合策略
首次加载(在线)3.2s3.5s(+缓存写入)3.2s3.3s
二次加载(在线)2.8s(浏览器缓存)0.5s2.8s0.5s
离线加载白屏0.5s0.8s(回退缓存)0.5s
API数据离线无数据无数据有缓存数据有缓存数据
缓存命中率0%95%70%90%
内存占用0MB4.2MB4.2MB4.2MB

关键数据:离线加载时间从白屏降到0.5s,提升300%以上。API数据在离线时仍可展示,用户不再看到空白页面。

六、避坑指南:5个实战踩坑点

坑1:Service Worker作用域问题

现象:Service Worker注册在 /sw.js,但只拦截 / 路径下的请求,/admin/ 下的页面不受控制。

原因:Service Worker默认作用域是sw.js所在目录。要控制子目录,需在register时指定scope: '/'。

解决:

navigator.serviceWorker.register('/sw.js', { scope: '/' });

坑2:缓存版本号管理

现象:更新静态资源后,用户仍看到旧版本,因为缓存未失效。

原因:Cache-First策略下,缓存不会自动更新。

解决:使用版本号或文件哈希。Workbox的precacheAndRoute会自动处理。

// 手动版本号
const CACHE_NAME = 'pwa-cache-v2';
// 或使用文件哈希
workbox.precaching.precacheAndRoute([
  { url: '/js/app.js', revision: 'a1b2c3d4' }
]);

坑3:API请求超时导致缓存失效

现象:弱网环境下,Network-First策略的API请求超时,直接返回离线页面,没有使用缓存。

原因:fetch默认超时时间很长,但网络差时请求挂起。

解决:设置networkTimeoutSeconds,超时后回退到缓存。

new workbox.strategies.NetworkFirst({
  networkTimeoutSeconds: 3,
  fallbackToCache: true
});

坑4:跨域请求缓存问题

现象:CDN上的图片或字体无法缓存,因为跨域请求不带credentials。

原因:fetch默认不发送cookie,跨域资源需设置mode: 'cors'。

解决:在Workbox中配置跨域策略。

workbox.routing.registerRoute(
  /^https:\/\/cdn\.example\.com\/.*/,
  new workbox.strategies.CacheFirst({
    cacheName: 'cdn-cache',
    plugins: [
      new workbox.cacheableResponse.CacheableResponsePlugin({
        statuses: [0, 200]
      })
    ]
  })
);

坑5:后台同步数据丢失

现象:用户离线提交表单,后台同步时数据丢失。

原因:BackgroundSyncPlugin默认重试次数有限,且不持久化数据。

解决:使用IndexedDB持久化待同步数据,并增加重试次数。

// 使用IndexedDB存储待同步数据
const db = await idb.openDB('sync-queue', 1, {
  upgrade(db) {
    db.createObjectStore('requests', { keyPath: 'id' });
  }
});

// 在Service Worker中处理同步
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-form') {
    event.waitUntil(syncFormData());
  }
});

async function syncFormData() {
  const tx = db.transaction('requests', 'readwrite');
  const store = tx.objectStore('requests');
  const allRequests = await store.getAll();
  for (const request of allRequests) {
    try {
      await fetch(request.url, request.options);
      await store.delete(request.id);
    } catch (error) {
      console.error('同步失败:', error);
    }
  }
}

七、总结

PWA离线缓存不是银弹,但按资源类型混合策略能解决90%的离线问题。记住:静态资源用Cache-First,API用Network-First,图片用Stale-While-Revalidate。用Workbox减少手写代码,但要注意版本号、超时、跨域和后台同步的坑。

最后,上线前一定要用Chrome DevTools的Application面板测试离线场景,模拟不同网络条件。别等到用户反馈才去修。