离线缓存策略设计

离线缓存是现代Web应用提升用户体验的关键技术,它允许应用在网络不稳定甚至断网时继续运行,提供无缝的浏览体验。其核心在于通过Service Worker等现代浏览器API,对网络请求进行代理和缓存管理,实现资源的可靠存储与快速读取。

核心技术:Service Worker与Cache API

Service Worker是一个独立于网页主线程运行的脚本,充当网络代理的角色。它能够拦截和处理页面的网络请求,这是实现离线功能的基础。与之配合的Cache API则提供了请求(Request)和响应(Response)对象的持久化存储机制。

一个基础的Service Worker注册流程如下:

javascript 复制代码
// 在主线程(例如 main.js)中注册Service Worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js').then(function(registration) {
      console.log('ServiceWorker 注册成功,作用域为:', registration.scope);
    }).catch(function(err) {
      console.log('ServiceWorker 注册失败:', err);
    });
  });
}

缓存策略的设计模式

缓存策略的选择直接决定了应用的性能、数据新鲜度和离线能力。以下是几种核心策略及其应用场景。

1. 缓存优先,网络回退 (Cache First)

此策略优先从缓存中返回资源,若缓存未命中,则请求网络并将响应存入缓存。适用于版本稳定的静态资源(如CSS、JS、图片)。

javascript 复制代码
// 在 sw.js 中
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cachedResponse => {
      // 缓存命中,直接返回
      if (cachedResponse) {
        return cachedResponse;
      }
      // 缓存未命中,请求网络
      return fetch(event.request).then(networkResponse => {
        // 可选:将新资源加入缓存(注意避免缓存不透明响应如opaque responses)
        if (networkResponse.ok && event.request.method === 'GET') {
          const responseToCache = networkResponse.clone();
          caches.open('my-static-cache-v1').then(cache => {
            cache.put(event.request, responseToCache);
          });
        }
        return networkResponse;
      });
    })
  );
});

2. 网络优先,缓存回退 (Network First)

优先尝试从网络获取最新内容,仅在网络失败时使用缓存。适用于需要较高数据新鲜度的内容,如文章列表、API数据。

javascript 复制代码
self.addEventListener('fetch', event => {
  event.respondWith(
    fetch(event.request).catch(() => {
      return caches.match(event.request);
    })
  );
});

3. 仅缓存 (Cache Only)

直接从缓存中获取资源,不进行网络请求。适用于那些在安装阶段就确定被缓存的、完全离线的核心资源。

javascript 复制代码
self.addEventListener('fetch', event => {
  event.respondWith(caches.match(event.request));
});

4. 仅网络 (Network Only)

强制从网络获取,不使用缓存。适用于需要绝对实时性的请求,如支付验证、实时消息。

5. 增量缓存与动态更新 (Stale-While-Revalidate)

此策略在返回缓存内容(可能已过时)给页面的同时,在后台发起网络请求以获取最新内容并更新缓存。它平衡了响应速度与数据新鲜度。

javascript 复制代码
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.open('my-dynamic-cache-v1').then(cache => {
      return cache.match(event.request).then(cachedResponse => {
        const fetchPromise = fetch(event.request).then(networkResponse => {
          // 用新响应更新缓存
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        // 返回缓存内容(如果有),否则等待网络请求
        return cachedResponse || fetchPromise;
      });
    })
  );
});

缓存生命周期管理

预缓存关键资源

在Service Worker的install事件中,可以预先缓存应用外壳(App Shell)和核心静态资源,确保首次加载后即可离线使用。

javascript 复制代码
const CACHE_NAME = 'app-shell-v1';
const urlsToCache = [
  '/',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.svg'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => {
      console.log('已打开缓存');
      return cache.addAll(urlsToCache);
    })
  );
});

缓存版本与清理

当应用更新时,需要清理旧缓存。通常在activate事件中执行此操作。

javascript 复制代码
self.addEventListener('activate', event => {
  const cacheWhitelist = ['app-shell-v2', 'dynamic-data-v1']; // 当前需要保留的缓存名称

  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            // 删除不在白名单中的旧缓存
            return caches.delete(cacheName);
          }
        })
      );
    }).then(() => {
      // 激活后立即控制所有客户端
      return self.clients.claim();
    })
  );
});

高级缓存策略与实践

缓存API响应与请求克隆

缓存Response对象时,需要注意它们只能被读取一次体(body)。因此,如果需要同时返回给页面和存入缓存,必须进行克隆。

javascript 复制代码
fetch(event.request).then(networkResponse => {
  // 克隆响应
  const responseClone = networkResponse.clone();
  caches.open('my-cache').then(cache => {
    cache.put(event.request, responseClone);
  });
  return networkResponse; // 原始响应返回给页面
});

缓存容量与淘汰策略

浏览器为缓存分配了有限的存储空间。可以使用Cache API配合IndexedDB来实现更复杂的淘汰策略(如LRU-最近最少使用)。

javascript 复制代码
// 简化的LRU思路:在缓存PUT操作后,检查并清理最旧的条目
async function cacheWithLRU(cacheName, request, response, maxEntries = 50) {
  const cache = await caches.open(cacheName);
  await cache.put(request, response);

  const keys = await cache.keys();
  if (keys.length > maxEntries) {
    // 删除第一个(最旧的)条目
    await cache.delete(keys[0]);
  }
}

后台同步 (Background Sync)

对于因网络失败而未能发送的用户操作(如评论、表单提交),可以利用Background Sync API在网络恢复后自动重试。

javascript 复制代码
// 在主线程中,网络请求失败后注册同步任务
navigator.serviceWorker.ready.then(registration => {
  return registration.sync.register('post-comment');
});

// 在Service Worker中监听同步事件
self.addEventListener('sync', event => {
  if (event.tag === 'post-comment') {
    event.waitUntil(
      // 从IndexedDB中取出保存的数据并重新发送
      retryFailedPostRequest()
    );
  }
});

调试与测试

  1. Chrome DevTools:在Application面板的Service WorkersCache Storage部分,可以查看、调试Service Worker状态及缓存内容,并模拟离线环境。
  2. 更新机制:Service Worker文件字节级变化会触发更新。也可以通过registration.update()手动触发。新Worker安装后,会在所有旧标签页关闭后或通过skipWaiting()立即激活。
  3. 缓存一致性:对于由构建工具生成的文件(如main.[hash].js),可以利用文件哈希作为缓存名称的一部分(如app-shell-${hash}),实现资源更新时缓存名称自然更迭,便于清理。

安全与注意事项

  • HTTPS要求:Service Worker仅在HTTPS环境(或localhost)下运行,以确保中间人攻击无法篡改代理逻辑。
  • 作用域 (Scope):Service Worker只能控制其所在目录及子目录下的页面。注册时需注意路径。
  • 缓存敏感信息:避免缓存包含个人身份信息或敏感数据的API响应。可通过检查请求URL或响应头来排除。
  • 内存与存储:过度缓存可能导致用户设备存储空间被占用。应设置合理的缓存上限和清理策略。
  • 降级兼容:始终为非支持浏览器或Service Worker失败的情况提供降级方案,确保基础功能可用。