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;
}