Service Worker 使用 Workbox 预缓存实践

2,339 阅读5分钟

关于 Workbox 的介绍推荐看官方文档,咱直接进入正题

最近在项目中使用 ServiceWorker 达成 PWA,主要采用的策略有两个

  • 预缓存:用户在注册 ServiceWorker 时,便会预先请求项目的所有 js、css 等资源,后续访问直接缓存优先
  • 增量缓存:图片、字体文件会在用户请求到时,才增量地进行缓存

(以下用 SW 简称 ServiceWorker)

precache 预缓存

使用介绍

要达到预缓存目的,SW 需获知项目的所有构建资源 url,来源是 webpack 构建得到的 manifest.json
而 workbox 提供的 InjectManifest 插件便可以帮我们完成这件事

const { InjectManifest } = require("workbox-webpack-plugin");

module.exports = {
  // ...
  plugins: [
    new InjectManifest({
      swSrc: "./pwa/service-worker.js", // 编写好的 sw.js 的位置,相对于根目录
      swDest: "pwa/service-worker.js", // 经 webpack 处理后的 sw.js 位置,相对于 /public
      maximumFileSizeToCacheInBytes: 10485760, // 适当调整预缓存的单个文件大小上限
    }),
  ],
};

如上在 webpack.config.js 加入该插件后,workbox 会在 webpack 构建的尾声将资源列表注入 sw.js 文件

注入位置约定为 self.__WB_MANIFEST,workbox 会将该变量替换为实际的预缓存资源数组
因此在 sw.js 中可如下获取预缓存列表并使用该功能

import { precacheAndRoute } from 'workbox-precaching';

const precacheList = self.__WB_MANIFEST || [];
precacheAndRoute(precacheList);

经 webpack 打包构建后的 sw.js 即会变成

// ...
var precacheList = [{'revision':null,'url':'/public/css/common.2d2f38fb.css'},{'revision':null,'url':'/public/js/login.428914e6.js'},{'revision':null,'url':'/public/js/runtime.c56cd543.js'},{...}] || [];
// ...

生命周期

首次注册 SW 时,precache 功能会在 install 阶段请求并缓存所有 precacheList 资源

从上面 webpack 构建完的 sw.js 可以看到 revision 都为 null,这是由于 url 中已经包含哈希值了,通过 url 判断文件无更新足矣
后续有版本更新时,precacheList 自然会发生变化,sw.js 文件也即发生变化,浏览器便会更新注册 SW

更新过程中,precache 仍然会在 install 阶段下载新的 precacheList 资源
此时,文件名与旧缓存相同的,即未发生变化的资源不会重新请求,只会请求旧缓存中没有的新资源

请求并缓存完后,接着在 activate 阶段检查缓存,将不在新 precacheList 中的旧缓存进行清除

如此 precache 便能准确地进行预缓存与更新

staticCache 增量缓存

使用介绍

对用户后续访问到的资源实时缓存,可用 registerRoute(matchCb, handlerCb) 方法
传入的 matchCb 用于过滤资源,handlerCb 对资源进行处理

这里 handlerCb 可直接使用 workbox-strategies 包提供的缓存策略,如下使用缓存优先

import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';

registerRoute(
  staticNeedCache,  // 下文介绍
  new CacheFirst({
    cacheName: 'static-cache',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),  // 请求成功才缓存
      new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 7 * 24 * 60 * 60 }),  // 缓存条数、时间限制
    ],
  }),
);

资源过滤

项目里只需对图片、字体文件进行增量缓存,因此在 staticNeedCache 方法中进行过滤

const staticNeedCache = ({ request, url }) => {
  if (['image', 'font'].indexOf(request.destination) === -1) {
    return false;
  }
  return true;
};

这里的 request.destination 盲猜是通过 HTTP 请求头的 Accept 字段进行判断,无需等待响应头的 Content-Type

如果项目中有埋点需求,需注意对埋点 url 进行过滤
因为现流行的埋点方案大都通过模拟 1 像素点的图片请求来发出,以规避跨域等问题,如此发出的埋点请求头的 Accept 字段也含 image/*
若不对其进行过滤,实际使用可能会发现 staticCache 里莫名缓存了一堆埋点请求

资源清除

前面介绍的 precache 预缓存策略,因为能获知 webpack 构建资源,所以可精准清除过期资源

而 staticCache 进行增量缓存的内容具有较多不确定,尤其在每次版本更新后,无法判断旧缓存中哪些资源是后续不会再用到的
因此当有版本更新时,我选择在 activate 阶段将其全部清除

self.addEventListener('activate', (event) => {
  event.waitUntil(
    // 清除图片缓存
    // 不能直接用 ExpirationPlugin 的删除 API,否则删除的是新一轮的图片资源(此时还没有),而旧的不会被涉及
    caches
      .open('static-cache')
      .then((cache) => {
        cache.keys().then((cacheNames) => {
          cacheNames.forEach((item) => cache.delete(item));
        });
      })
      .then(() => {
        self.clients.claim();
      })
  );
});

其它考虑

本文只是介绍了利用 workbox 进行缓存的两个策略参考

另外还有些其它问题,推荐在完善 SW 功能时进行考虑:

  • 预缓存过滤
    若项目庞大,初次注册 SW 需要预缓存的资源可能很多,可在 sw.js 中对 precacheList 进行过滤
    (比如过滤图片、字体,因为后续 staticCache 也可缓存它们)

  • 兜底策略
    SW 是风险比较大的功能,因此最好给项目配置一个开关(如配置中心),在 SW 注册的逻辑前进行检测

  • 取消注册
    若线上出现问题,除了对已注册 SW 的客户端执行取消注册之外,记得也对缓存进行清除,可由客户端通过 postMessage 通知 SW 进行清除

  • 错误上报
    SW 提供了 errorunhandledrejection 两个事件进行错误捕获处理,捕获后可通过 client.postMessage 通知客户端进行上报

  • 不透明响应
    若项目里的图片等资源是从 cdn 服务器获取的,存在跨域情况,那需要注意缓存空间的大量占用导致超出配额,详见 DOMException: Quota exceeded 与 不透明响应

Tips

客户端注册 SW 的时机建议选在页面加载完成后,否则首次注册会有大量预缓存资源下载,可能影响页面的正常渲染和交互

模拟首次访问的绝佳方法,是在无痕式窗口中打开查看控制中的网络流量

想检测 SW 给页面加载带来的效果,推荐使用 Web Vitals 谷歌插件,对比有无 SW 情况下的 LCP 指标(尤其对比弱网条件下)

参考资源

Service Worker 调试

前端性能优化之-离线缓存实战

Workbox - Chrome Developer

神奇的 Workbox 3.0

Service Worker存储的限制是多少

谨慎处理 Service Worker 的更新