Service Worker离线缓存实战技巧与性能优化

雨晨 ☘︎ 优化 阅读 1,393
赞 14 收藏
二维码
手机扫码查看
反馈

先上效果,再讲原理

我最近给一个项目加离线访问功能,用户打开过一次页面后,哪怕断网也能继续用。这玩意儿说白了就是 Service Worker 的缓存能力。别一上来就看概念,先看怎么用。

Service Worker离线缓存实战技巧与性能优化

最简单的场景:你有个静态站点,JS、CSS、HTML 都想缓存下来,下次直接走本地。核心代码其实就这几行。

// sw.js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll([
        '/',
        '/styles/main.css',
        '/scripts/app.js',
        '/images/logo.png'
      ])
    })
  )
})

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request)
    })
  )
})

上面这段代码,install 阶段预缓存资源,fetch 阶段优先从缓存拿。如果没命中,再去网络请求。就这么简单。

然后在主页面注册一下:

// main.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then((registration) => {
        console.log('SW registered: ', registration.scope)
      })
      .catch((error) => {
        console.log('SW registration failed: ', error)
      })
  })
}

刷新页面,打开 DevTools -> Application -> Service Workers,能看到你的 SW 已激活。Network 请求里,那些被缓存的资源会标着 from disk cachefrom service worker

这个场景最好用:API 缓存 + 失败兜底

光缓存静态资源不够用。我们项目里有个数据列表页,每次都要 fetch API。网络差的时候用户体验很差。所以我加了个策略:API 请求失败时返回上次成功的缓存。

const CACHE_NAME = 'api-cache-v2'

self.addEventListener('fetch', (event) => {
  const { request } = event

  // 只处理 GET 请求和 API 路径
  if (request.method !== 'GET' || !request.url.includes('/api/')) {
    event.respondWith(fetch(request))
    return
  }

  event.respondWith(
    fetch(request.clone()).then((response) => {
      // 克隆响应对象并缓存
      const clonedResponse = response.clone()
      caches.open(CACHE_NAME).then((cache) => {
        cache.put(request, clonedResponse)
      })
      return response
    }).catch(() => {
      // 网络失败,尝试读取缓存
      return caches.match(request).then((cached) => {
        if (cached) {
          return cached
        }
        // 连缓存都没有,返回一个默认响应(比如空数组)
        return new Response(JSON.stringify([]), {
          headers: { 'Content-Type': 'application/json' }
        })
      })
    })
  )
})

这里注意下,我踩过好几次坑:fetch 后的 response 是流式的,只能读一次。所以要 clone() 一份去缓存,否则主流程拿不到数据。

另外,缓存命名建议带版本号,方便后续清理旧缓存。不然更新逻辑后,老缓存还在,容易出问题。

缓存更新?别指望自动

很多人以为改了 SW 文件,缓存就自动更新了。错。Service Worker 更新机制有点反直觉。

浏览器会在每次页面加载时检查 SW 文件是否有变化(基于字节差异),有变才会安装新版本。但旧的 Worker 还在运行,直到所有页面关闭。这时候新版本处于 waiting 状态。

解决办法是在注册时加上 updateViaCache 和监听状态:

navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then(registration => {
  setInterval(() => {
    registration.update() // 主动检查更新
  }, 60000) // 每分钟一次
})

或者更狠一点,强制跳过 waiting 阶段:

// 在 sw.js 里
self.addEventListener('install', () => {
  self.skipWaiting()
})

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim())
})

这样新版本一准备好就立刻接管页面。亲测有效,但要注意:如果老页面正在操作数据,突然切换可能有问题。所以建议只在纯展示类项目里这么干。

踩坑提醒:这三点一定注意

  • HTTPS 才能用 SW:本地开发可以用 localhost 绕过,但部署到线上必须 HTTPS。HTTP 下 navigator.serviceWorker 直接是 undefined。别问我怎么知道的,折腾了半天才发现测试服没配证书。
  • 路径问题很恶心:register SW 时传的路径是 scope,默认是你当前 JS 文件所在目录。比如你在 /admin/js/app.js 里 register(‘/sw.js’),那它的控制范围只是 /admin/ 下的页面。想全局控制,记得写成 register('/sw.js') 并确保它在根目录。
  • 调试要用 Hard Reload:普通刷新不会触发 install,得 Ctrl+Shift+R。DevTools 里勾上 “Update on reload” 也不靠谱,有时候还是卡住。最保险是手动 unregister 再刷新。

高级点:动态缓存 + 清理策略

上面都是固定缓存,实际项目中图片、文章内容太多,不能全塞进去。我后来改成动态缓存,只存最近访问的 N 个资源。

const MAX_CACHE_ENTRIES = 50

self.addEventListener('fetch', (event) => {
  const { request } = event

  if (request.url.includes('/articles/') || request.url.includes('/images/')) {
    event.respondWith(
      caches.open('dynamic-cache').then(async (cache) => {
        const response = await fetch(request)
        const clonedResponse = response.clone()

        await cache.put(request, clonedResponse)

        // 检查条目数,超了就删最老的
        const keys = await cache.keys()
        if (keys.length > MAX_CACHE_ENTRIES) {
          cache.delete(keys[0]) // FIFO,简单粗暴
        }

        return response
      }).catch(() => caches.match(request))
    )
  }
})

这种适合内容型站点,比如博客。用户看了 50 篇文章,缓存最近的,老的自动踢掉。内存不会爆炸,体验也够顺滑。

不过这个方案不是最优的,LRU 会更好,但实现复杂。目前这个够用,我就没改。

拓展玩法:预加载 + 后台同步

Service Worker 还能干更多事。比如用户提交了个表单,但当时没网。你可以把数据暂存 IndexedDB,等 SW 发现网络恢复后自动重发。

// 页面中
navigator.serviceWorker.ready.then((sw) => {
  sw.sync.register('submit-form')
})
// sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'submit-form') {
    event.waitUntil(
      sendFormDataFromIndexedDB()
    )
  }
})

这个叫 Background Sync,兼容性还行,Android 上基本都能用。iOS Safari 不支持,得降级处理。

还有 Push Notifications、Periodic Sync(定期拉数据)、Offline Analytics(离线埋点)等等,都是基于 SW 的能力。这些我后面会单独写博客聊。

最后说两句

Service Worker 学起来有点门槛,主要是生命周期反人类,调试也不直观。但我用了两年,现在基本成了标配。特别是做 PWA 或者提升首屏速度,几乎是必选项。

改完之后仍有小问题:比如 iOS Safari 对 Cache Storage 的清理太激进,偶尔会丢缓存。但这无大碍,顶多重新拉一次数据。

以上是我个人对 Service Worker 的实战总结,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论