PWA 这种技术已经好几年了,几年前我也用过,但基本不缓存文件,因为我只需要能够在桌面上加一个入口就行。当然,我也用过 workbox 插件,结果是…….sw.js 也被缓存了,后来我也没改 nginx 配置。算了,一个扫雷应用,反正我也不更新了。
这次使用 PWA 是因为我需要缓存静态文件,避免每次都下载十几兆依赖。为什么依赖这么大?因为是图片识别,需要下载训练好的模型文件。
本来关于缓存管理我是自己写了个 IO 对象,然后请求依赖文件都通过这个 IO 对象,IO 对象内部实现缓存管理。比如大版本不一致,直接请求新文件,小版本不一致,依然尝试使用本地缓存,版本一直当然就直接使用缓存了。这样实现的好处是:
- 允许本地版本与远程版本不一致,比如几十兆的文件,远程可能就改了几个字,这种……本地直接用以前的就可以了,积累到大版本不一致再更新也不迟。
- 缓存空间充足。我用的 localforage 管理缓存,默认存储方式是 indexeddb。
- 避免涉及 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; }