第4章 Fetch事件拦截:请求拦截与响应替换、fallback页面、离线资源兜底、请求日志

Service Worker 的 fetch 事件是离线优先能力的核心入口。通过拦截页面的所有网络请求(HTML、CSS、JS、图片、API 等),开发者可以决定使用缓存、网络或同时使用两者,从而精确控制每个资源的交付方式。本章深入讲解 fetch 事件的拦截机制、响应替换、fallback 页面设计、离线资源兜底策略以及请求日志记录,帮助你构建一个可观测、可恢复的离线应用。

Fetch 事件拦截与响应替换流程 页面(客户端) 缓存优先? Cache API 查找 返回缓存响应 发起网络请求 成功? 返回网络响应 触发 fallback 页面 写入请求日志(IndexedDB) 说明:所有被拦截的请求及其处理结果(命中缓存、网络成功、网络失败)均被记录到本地请求日志中。 日志可用于离线分析和问题排查,不影响实际响应返回。

4.1 原理说明

浏览器每次发起网络请求时,如果当前页面注册了 Service Worker,都会触发 Service Worker 的 fetch 事件。事件对象 FetchEvent 包含 request 属性和一个 respondWith() 方法。开发者通过 respondWith() 返回一个 Response 对象(来自缓存或网络),从而完全接管该请求的响应。拦截范围覆盖同源请求,也可通过 no-cors 模式处理跨域资源(但响应体不可读)。

本章的核心策略包括:

  • 缓存优先 (Cache First) – 先查缓存,命中则返回,否则发网络请求并缓存。
  • 网络优先 (Network First) – 先请求网络,失败或超时则降级到缓存。
  • 仅网络 / 仅缓存 – 适用于特定资源。
  • fallback 页面 – 当请求失败且缓存无匹配时,返回一个预定义的离线页面。
  • 请求日志 – 将每次请求的 url、时间戳、状态、处理方式等记录到 IndexedDB。

4.2 实现流程

  1. 注册 Service Worker – 确保页面与 SW 建立联系,SW 进入激活状态。
  2. 在 activate 事件中清理旧缓存 – 避免缓存膨胀。
  3. 监听 fetch 事件 – 根据 url、请求类型等条件分发处理。
  4. 执行缓存策略 – 调用 Cache API 进行匹配、打开、存入操作。
  5. 网络降级与 fallback – 若网络返回错误或超时,尝试缓存或返回 fallback 页面。
  6. 记录请求日志 – 将结果写入 IndexedDB(异步,不阻塞响应返回)。

4.3 代码示例

以下为一个完整的 sw.js 核心片段,展示了 fetch 事件拦截、缓存优先、网络降级、fallback 页面以及请求日志记录。

// sw.js – Fetch 事件拦截与响应替换

const CACHE_NAME = 'v1';
const FALLBACK_URL = '/offline.html';

self.addEventListener('install', (event) => {
  // 预缓存 fallback 页面和核心资源
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll([
        '/offline.html',
        '/style.css',
        '/app.js'
      ]);
    })
  );
});

self.addEventListener('activate', (event) => {
  // 清理旧版本缓存
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then((keyList) => {
      return Promise.all(keyList.map((key) => {
        if (!cacheWhitelist.includes(key)) {
          return caches.delete(key);
        }
      }));
    })
  );
});

self.addEventListener('fetch', (event) => {
  const requestUrl = new URL(event.request.url);

  // 对同源请求执行缓存优先策略
  if (requestUrl.origin === location.origin) {
    event.respondWith(
      cacheFirstWithFallback(event.request)
    );
  } else {
    // 跨域资源:仅网络,不缓存
    event.respondWith(fetch(event.request));
  }

  // 异步记录请求日志(不阻塞响应)
  event.waitUntil(
    logRequest(event.request, /* 占位,实际结果在respondWith之后 */)
  );
});

async function cacheFirstWithFallback(request) {
  const cache = await caches.open(CACHE_NAME);
  let cachedResponse = await cache.match(request);

  if (cachedResponse) {
    // 缓存命中,返回缓存并异步更新缓存(stale-while-revalidate)
    updateCache(request);
    return cachedResponse;
  }

  // 缓存未命中,尝试网络
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      // 只缓存成功的响应(避免缓存错误页面)
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (err) {
    // 网络失败,返回 fallback 页面(仅限文档请求)
    if (request.destination === 'document') {
      const fallback = await cache.match(FALLBACK_URL);
      if (fallback) return fallback;
    }
    // 其他资源可返回空响应或占位图
    return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
  }
}

async function updateCache(request) {
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, networkResponse);
    }
  } catch (e) {
    // 静默失败,不影响当前页面
  }
}

// 请求日志写入 IndexedDB
async function logRequest(request, result) {
  const db = await openDB();
  const tx = db.transaction('log', 'readwrite');
  const store = tx.objectStore('log');
  store.add({
    url: request.url,
    method: request.method,
    timestamp: Date.now(),
    result: result // 可改为实际状态
  });
  await tx.done;
}

function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open('RequestLogDB', 1);
    req.onupgradeneeded = (e) => {
      const db = e.target.result;
      if (!db.objectStoreNames.contains('log')) {
        db.createObjectStore('log', { autoIncrement: true });
      }
    };
    req.onsuccess = (e) => resolve(e.target.result);
    req.onerror = (e) => reject(e.target.error);
  });
}
提示:使用 event.waitUntil() 包装异步日志操作,可以确保 Service Worker 在写入完成前不会被终止。日志写入不应阻塞 respondWith(),因此先调用 respondWith(),再将日志任务放入 waitUntil 中。

4.4 调试方法

1. 在 Chrome DevTools 中打开 Application 面板,选择 Service Workers,可以查看当前注册的 SW 并触发 “Update” 和 “Unregister”。
2. 在 Cache Storage 中检查预缓存和动态缓存的内容。
3. 在 Network 面板中,勾选 “Disable cache” 可以观察离线行为;勾选 “Offline” 模拟无网络场景。
4. 打开 Console,查看 console.log 输出或错误提示。可以在 SW 中添加 self.addEventListener('message', ...) 实现更复杂的调试日志。

4.5 部署步骤

  1. 将 sw.js 和 offline.html 放置于站点的根目录(或任意可访问路径),确保作用域覆盖需要离线使用的页面。
  2. 在主页面(如 index.html)中注册 SW:
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js', { scope: '/' })
    .then(() => console.log('SW registered'))
    .catch(err => console.error('SW registration failed:', err));
}
  1. 使用 HTTPS(或 localhost)测试,因为 Service Worker 只在安全上下文中生效。
  2. 通过 DevTools 模拟离线场景,验证 fallback 页面和缓存策略是否正确。

4.6 常见错误处理

错误现象可能原因解决方案
SW 注册后不触发 fetch 事件SW 作用域不包含当前页面,或页面被强制刷新跳过 SW检查 scope 参数;关闭 DevTools 的 "Bypass for network" 选项
跨域请求被拦截后无法读取响应跨域资源使用 no-cors 模式,响应体为 opaque只对跨域资源进行网络请求,不尝试缓存 opaque 响应;或配置 CORS 头
Fallback 页面显示空白预缓存时未包含 fallback 页面,或路由不匹配在 install 事件中添加 cache.addAll(['/offline.html']);确保 FALLBACK_URL 路径正确
日志未写入 IndexedDBIndexedDB 版本更新失败,或事务未提交检查 onupgradeneeded 中的 store 创建;使用 await tx.done
缓存一直使用旧版资源缓存未清除旧版本在 activate 事件中清理旧缓存;使用版本号如 CACHE_NAME = 'v2'

4.7 性能与稳定性优化建议

  • 避免过度缓存 – 只缓存必要的资源(HTML、CSS、JS、字体、关键图片),排除 API 响应或用户数据。
  • 使用 stale-while-revalidate – 对于频繁更新的资源,先返回缓存版本,再异步更新缓存,提升感知速度。
  • 控制并发请求 – 在 fetch 事件中,对于同一 URL 的重复请求,可以使用 Request 对象进行去重,避免同时写入缓存导致竞态。
  • 合理设置超时 – 对于网络优先策略,可以设置超时(如 5 秒),超时后降级到缓存。
  • 日志节流 – 日志写入频率过高可能影响性能,建议在写入前合并或使用批量写入。
// 去重示例:使用闭包缓存 pending 请求
const pendingRequests = new Map();

async function dedupedFetch(request) {
  if (pendingRequests.has(request.url)) {
    return pendingRequests.get(request.url);
  }
  const promise = fetch(request).finally(() => {
    pendingRequests.delete(request.url);
  });
  pendingRequests.set(request.url, promise);
  return promise;
}
警告:不要在所有 fetch 事件中都执行复杂的同步逻辑,这可能导致页面卡顿或 SW 被强制终止。尽量让 respondWith() 路径轻量,将日志、缓存更新等操作放在 waitUntil() 中异步执行。

通过本章的学习,你已经掌握了 Fetch 事件拦截的核心机制,能够在 Service Worker 中灵活地实施缓存策略、提供离线 fallback 页面,并建立可观测的请求日志系统。这些技能是构建稳健离线优先应用的基石。