service worker 缓存管理

PWA 这种技术已经好几年了,几年前我也用过,但基本不缓存文件,因为我只需要能够在桌面上加一个入口就行。当然,我也用过 workbox 插件,结果是…….sw.js 也被缓存了,后来我也没改 nginx 配置。算了,一个扫雷应用,反正我也不更新了。

这次使用 PWA 是因为我需要缓存静态文件,避免每次都下载十几兆依赖。为什么依赖这么大?因为是图片识别,需要下载训练好的模型文件。

本来关于缓存管理我是自己写了个 IO 对象,然后请求依赖文件都通过这个 IO 对象,IO 对象内部实现缓存管理。比如大版本不一致,直接请求新文件,小版本不一致,依然尝试使用本地缓存,版本一直当然就直接使用缓存了。这样实现的好处是:

  1. 允许本地版本与远程版本不一致,比如几十兆的文件,远程可能就改了几个字,这种……本地直接用以前的就可以了,积累到大版本不一致再更新也不迟。
  2. 缓存空间充足。我用的 localforage 管理缓存,默认存储方式是 indexeddb。
  3. 避免涉及 service-worker 层,徒增复杂度。

但不在我控制范围的依赖文件就没法处理了,比如 ml5 直接请求的模型文件。所以,我只能添加 PWA 模式,然后在 service-worker 管理。

我用的框架是 Quasar,根据文档,添加 PWA 也很简单,SSR 模式下,直接 quasar.config.js 配置文件的 ssr 部分添加 pwa: true 就可以了,框架会自动添加 ssr-pwa 文件夹及相关文件。

根据官网的指示,我将 pwa 配置里的 workboxMode 改成了 injectManifest,因为只有这样 custom-service-worker.ts 文件才生效。

还是先贴代码,然后解释。

const cacheName = 'fetch-cache'

interface CacheConfigItem {
  reg: RegExp
  head: boolean // 是否通过 head 请求比较文件,默认 true
}
const cacheConfig: CacheConfigItem[] = [
  // 二进制文件,比如谷歌模型文件
  {
    reg: /google.*\.bin$/,
    head: false,
  },
]

function matchCacheRule(url: string) {
  return cacheConfig.find((item) => item.reg.test(url))
}

async function getCache(url: string): Promise<Response | undefined> {
  return await (await caches.open(cacheName))?.match(url)
}

async function setCache(url: string, res: Response) {
  // ps: 不能用 localforage,'IDBObjectStore': Response object could not be cloned.
  if (res.status === 200) {
    const cache = await caches.open(cacheName)
    cache.put(url, res)
  }
}

// head 请求,用来判断资源是否变化
async function getHead(request: Request) {
  const req = new Request(request.url, {
    ...request.clone(),
    method: 'HEAD',
  })

  try {
    return await fetch(req)
  } catch (e) {
    return e
  }
}

// event.respondWith 必须同步执行,否则报错: Uncaught (in promise) DOMException: Failed to execute 'respondWith' on 'FetchEvent': The event handler is already finished.
function fetchHandler(e: Event) {
  const event = e as FetchEvent
  const urlObj = new URL(event.request.url)
  const url = urlObj.origin + urlObj.pathname
  let matched: CacheConfigItem | undefined
  if (event.request.method === 'GET' && (matched = matchCacheRule(url))) {
    // console.log('matched')
    event.respondWith(
      (async () => {
        const data = await getCache(url)
        if (data) {
          // 不需要比较文件,直接使用缓存
          if (matched?.head === false) {
            return data
          }

          const remoteData = await getHead(event.request)
          // 缓存有效
          if (
            remoteData instanceof Response &&
            remoteData.headers.get('ETag') === data.headers.get('Etag')
          ) {
            return data
          }
        }
        return fetchData()
      })()
    )
  } else {
    event.respondWith(fetch(event.request))
  }

  function fetchData() {
    return fetch(event.request).then((response) => {
      setCache(url, response)
      return response.clone()
    })
  }
}

export default function init() {
  self.addEventListener('fetch', fetchHandler)
}

首先,我不是所有的请求都需要缓存,所以定义了 cacheConfig 和 matchCacheRule 来检查是否需要缓存。cacheConfig 里每一条规则有一个正则和 head 选项。这个 head 选项指示缓存存在的情况下是否通过 head 请求对比服务器资源。

我们一般用的 http 请求为四种:put、get、post、delete,此外还有 head、options、trace、connect。这里的 head 请求与 get 的区别是不返回响应体。所以我们可以通过 head 请求获取响应头,进行资源信息的初步判断,与缓存不一致再发出 get 请求,拉取数据。

在 setCache 函数里有个注释,不能使用 localforage。因为 Response 对象无法结构化拷贝,无法存到 indexeddb 里。所以这里只能使用 caches,caches 的容量不及 indexeddb,比如苹果限制 50M。嗯……要我说还是都写 APP 去吧。要不干脆转行吧,反正互联网寒冬了。

fetchHandler 函数前面的注释也说得比较清楚了。respondWith 必须同步执行,如果一路 await 然后再执行 respondWith 就会报错。可能是因为,浏览器需要立即知道开发者有没有执行 respondWith 接管请求响应,如果开发者没有调用,则浏览器需要默认调用 respondWith。

  function fetchData() {
    return fetch(event.request).then((response) => {
      setCache(url, response)
      return response.clone()
    })
  }

上面这个函数需要注意的是,返回值调用 clone,如果返回值和保存值都是同一个 response 会报错:Response body is already used,因为 body 部分只能消费一次。

其他需要注意的地方还有如下:

  • quasar 的 workbox 配置默认会吧 public 文件夹都添加到 precache 缓存,但我不需要这样,因为 public 里有很多东西,其中部分我自己已经通过 IO 缓存管理了。为了去掉这些,需要在 quasar.config.js 的 pwa 部分进行配置 extendInjectManifestOptions 函数,以及 custom-service-worker.ts 里的 denylist 部分。两个地方都要配置,一开始我只配置了 quasar.config.js 文件,然后 third-party 内嵌的页面就 404 了。
// quasar.config.js
    pwa: {
      workboxMode: 'injectManifest', // 'generateSW' or 'injectManifest'
      injectPwaMetaTags: true,
      swFilename: 'sw.js',
      manifestFilename: 'manifest.json',
      useCredentialsForManifestTag: false,
      // useFilenameHashes: true,
      // extendGenerateSWOptions (cfg) {}
      extendInjectManifestOptions(cfg) {
        cfg.globIgnores.push(
          'BingSiteAuth.xml',
          'libs/**',
          'resources/**',
          'third-party/**'
        )
        return cfg
      },
      // extendManifestJson (json) {}
      // extendPWACustomSWConf (esbuildConf) {}
    },

// custom-service-worker.ts
if (process.env.MODE !== 'ssr' || process.env.PROD) {
  registerRoute(
    new NavigationRoute(
      createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML),
      {
        denylist: [
          /sw\.js$/,
          /workbox-(.)*\.js$/,
          /BingSiteAuth.xml/,
          /libs.*/,
          /resources.*/,
          /third-party.*/,
        ],
      }
    )
  )
}
  • public 下的 icons 里的图片需要替换成自己的网站 logo。
  • nginx 配置 sw.js 文件不缓存。
location /sw.js {
    # ……
    add_header Cache-Control no-cache;
}