配了三年 SW,今天才发现它的缓存从来不是一道选择题

写过离线优先应用的人都踩过这个坑——用户在地铁里填完表单点了提交,结果网络断了,请求直接消失。问题不在应用层,在于 Service Worker 的缓存策略从来没有按资源类型分过。

2026 年了,Service Worker 缓存这件事终于有了一个清晰的决策框架:不是选一个策略用到底,而是按资源类型匹配策略

三种策略,分别适合什么

Cache-first,也叫「缓存优先」,适合那些 URL 本身带内容哈希的资源。CSS 和 JS bundle 构建后会变成 app.a3f8e2.js,这个 URL 只在这个版本有效,下一次构建 URL 就变了。缓存这个 URL 永远不会「过期」,因为旧 URL 不会再有人请求。

Network-first,也叫「网络优先」,适合需要保持新鲜的数据。用户的个人信息、仪表盘数据、新闻流,这些内容过期了就失去了价值。先去网络拿,拿不到再降级到缓存。

Stale-While-Revalidate,也叫「SWR」,是中间地带。立刻把缓存返回给用户,同时在后台联网更新缓存。用户当前访问看到的是可能「有点旧」的内容,但下一次访问就全是新的。列表页、RSS 订阅、推荐内容,这些场景最合适。

三行路由,把策略分清楚

大多数 Service Worker 翻车的根本原因,不是策略选错了,而是把所有请求都塞进了同一个策略函数。

生产级别的路由是这样的:

// sw.js

const STATIC_CACHE = static-v2;
const DYNAMIC_CACHE = dynamic-v2;
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/offline.html',
];

// 安装时:把 App Shell 全部缓存
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then(cache => cache.addAll(STATIC_ASSETS))
      .then(() => self.skipWaiting())
  );
});

// 激活时:清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(
        keys
          .filter(key => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
          .map(key => caches.delete(key))
      )
    ).then(() => self.clients.claim())
  );
});

// 路由层:按类型分发
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  if (request.method !== 'GET') return;

  if (isStaticAsset(url.pathname)) {
    event.respondWith(cacheFirst(request));
    return;
  }

  if (url.pathname.startsWith('/api/') || request.mode === 'navigate') {
    event.respondWith(networkFirst(request));
    return;
  }

  if (
    url.pathname === '/' ||
    url.pathname.endsWith('/index.xml') ||
    url.pathname.endsWith('.json')
  ) {
    event.respondWith(staleWhileRevalidate(request));
    return;
  }

  event.respondWith(fetch(request));
});

function isStaticAsset(pathname) {
  return /\.(js|css|png|jpg|jpeg|svg|gif|woff2?)$/.test(pathname);
}

三个策略函数各自独立,路由层只负责分发,互不污染。

function cacheFirst(request) {
  return caches.match(request).then(cached =>
    cached || fetch(request).then(response => {
      const clone = response.clone();
      caches.open(STATIC_CACHE).then(cache => cache.put(request, clone));
      return response;
    })
  );
}

function networkFirst(request) {
  return fetch(request)
    .then(response => {
      const clone = response.clone();
      caches.open(DYNAMIC_CACHE).then(cache => cache.put(request, clone));
      return response;
    })
    .catch(() =>
      caches.match(request).then(cached =>
        cached || caches.match('/offline.html')
      )
    );
}

function staleWhileRevalidate(request) {
  return caches.open(DYNAMIC_CACHE).then(cache =>
    cache.match(request).then(cached => {
      const fetchPromise = fetch(request).then(response => {
        cache.put(request, response.clone());
        return response;
      });
      return cached || fetchPromise;
    })
  );
}

缓存效果可测量

光配了策略还不够,怎么知道缓存真的命中了?用 Performance API 查 transferSize

const observer = new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    const cached = entry.transferSize === 0;
    console.log(`${entry.name}: ${cached ? 'cache hit' : 'network'} - ${entry.duration}ms`);
  }
});
observer.observe({ type: 'resource', buffered: true });

transferSize === 0 就是命中了缓存,没有任何网络传输。

缓存不清场,硬盘会爆炸

Service Worker 缓存不会自动过期。如果你不清理,每次部署都会积累新的废弃缓存。activate 事件里清理旧缓存是关键一步:

// 激活时只保留当前版本,删除其余所有
event.waitUntil(
  caches.keys().then(keys =>
    Promise.all(
      keys
        .filter(key => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
        .map(key => caches.delete(key))
    )
  )
);

不过这里有个陷阱:如果用的是 query string 做版本号,浏览器会认为 ?v=1?v=2 是不同 URL,都会保留下来。内容哈希文件名是更好的方案,因为旧文件改名后没有任何请求会再命中它,旧缓存自然「消失」。

进阶:离线优先的真实场景

缓存策略只是基础。真正让 Service Worker 发挥价值的是两个进阶 API:

Background Sync——用户在地铁里填完表单点了提交,网络断了。普通的 fetch 会直接失败,表单数据丢失。用 Background Sync,请求会被放入队列,等网络恢复后自动重发:

// 页面端注册同步
navigator.serviceWorker.ready.then(swRegistration => {
  return swRegistration.sync.register('form-submit');
});

// Service Worker 监听同步事件
self.addEventListener('sync', event => {
  if (event.tag === 'form-submit') {
    event.waitUntil(submitFormData());
  }
});

Background Fetch——用户点击下载一个播客,下载进度可见,即使切换到其他标签页也不会中断。下载完成后通知用户:

navigator.serviceWorker.ready.then(async swReg => {
  const bgFetch = await swReg.backgroundFetch.fetch('podcast-download', [
    '/ep-5.mp3',
  ], {
    title: 'Episode 5 下载中',
    icons: [{ sizes: '300x300', src: '/icon.png', type: 'image/png' }],
    downloadTotal: 60 * 1024 * 1024,
  });

  bgFetch.addEventListener('progress', () => {
    const percent = Math.round((bgFetch.downloaded / bgFetch.downloadTotal) * 100);
    console.log(`下载进度: ${percent}%`);
  });
});

结论

Service Worker 缓存不是一道选择题,而是一套路由系统:

  • 静态资源(内容哈希)→ Cache-first,URL 即版本
  • API 数据、导航请求 → Network-first,保证新鲜
  • 列表页、Feed、JSON → Stale-While-Revalidate,速度与新鲜度兼顾
  • 表单提交、离线场景 → Background Sync
  • 大文件下载 → Background Fetch

配了三年 SW,最难的不是写策略函数,是想清楚「这个资源当前访问要快还是下次访问要新」这件事。路由分清楚,离线体验自然就好了。

配完 SW 之后记得测:开着飞行模式填个表单提交一下,看看是不是真的离线可用。

评论区

0 条评论

登录后可评论。

阿速·性能优化 11 阅读