正确使用缓存可以带来巨大的性能优势,节省宽带,并降低服务器成本,但许多网站并不重视缓存,造成竞争条件,导致相互依赖的资源不同步。
绝大多数最佳实践缓存属于以下两种模式之一:
- • 模式一:不可变(immutable)内容 长
max-age
- • 模式二:可变(mutable)内容,始终由服务器验证
模式一:不可变内容 长 max-age
代码语言:javascript复制Cache-Control:max-age=31536000
适用以下情况:
- • 此 URL 上的内容永远不会改变。
- • 浏览器/CDN 可以将此资源缓存一年没有问题。
- • 可以使用小于
max-age
几秒的缓存内容,无需咨询服务器。
在这个模式下,您永远不会更改特定 URL 的内容,而是更改 URL:
代码语言:javascript复制<script src="/script-f93bca2c.js"></script>
<link rel="stylesheet" href="/styles-a837cb1e.css" />
<img src="/cats-0e9a2ef4.jpg" alt="…" />
每个 URL 包含的信息都会随之改变,它可以是版本号、修改日期或内容的哈希值。
大多数服务器端框架都自带工具来简化这一过程(我使用 Django 的 ManifestStaticFilesStorage),还有一些较小的 Node.js 库也能实现同样的功能,例如 gulp-rev。
不过,这种模式不适用于文章和博文等内容,它们的 URL 无法版本化,内容也必须能够更改。说真的,鉴于我经常会犯一些基本的拼写和语法错误,我需要能够快速、频繁地更新内容。
模式二:可变内容,始终由服务器验证
代码语言:javascript复制Cache-Control: no-cache
适用以下情况:
- • 此 URL 上的内容可能会更改
- • 未经服务器许可,任何本地缓存版本都不可信
注意:no-cache
并不意味着 "不缓存",而是指在使用缓存资源前必须与服务器进行检验(或称为 "重新验证")。此外,must-revalidate
并不意味着 "必须重新验证",而是说如果本地资源的时效小于所提供的 max-age
,就可以使用,否则就必须重新验证。
在这种模式下,可以在响应中添加 ETag
(你选择的版本 ID)或 Last-Modified
日期标头。下一次客户端获取资源时,就会分别通过 If-None-Match
和 If-Modified-Since
回传已有内容的值,从而允许服务器说 "就用你已有的吧,它是最新的",或者正如它的拼写那样 "HTTP 304"。
如果无法发送 ETag/Last-Modified
,服务器将始终发送完整内容。
这种模式总是需要通过网络获取,因此不如模式一那样可以完全绕过网络。
模式一所需的基础设施让人望而却步,而模式二所需的网络请求又让人同样望而却步,因此,人们往往会选择介于两者之间的模式:较小的 max-age
和可变内容,这是一个糟糕的折中方案。
可变内容的 max-age
通常是错误的选择
遗憾的是,这种情况并不少见,例如在 Github 页面上就会发生。
想象一下
- •
/article/
- •
/styles.css
- •
/script.js
所有服务:
代码语言:javascript复制Cache-Control: must-revalidate, max-age=600
包含以下场景:
- • URL 内容更改
- • 如果浏览器有不到 10 分钟的缓存版本,则使用该版本,无需询问服务器
- • 否则,进行网络获取,如果可用,使用
If-Modified-Since
或If-None-Match
这种模式在测试中似乎有效,但在实际场景中却会造成故障,而且很难追查。在上面的例子中,服务器实际上已经更新了 HTML、CSS 和 JS,但页面最终使用的是缓存中的旧 HTML 和 JS,以及服务器上更新的 CSS。版本不匹配导致了问题的出现。
通常情况下,当我们对 HTML 进行重大修改时,很可能也会修改 CSS 以反映新的结构,并更新 JS 以适应样式和内容的变化。这些资源是相互依存的,但缓存标头无法表达这一点。用户最终可能会使用其中一个/两个资源的新版本,而使用另一个/多个资源的旧版本。
max-age
是相对于响应时间而言的,因此如果上述所有资源都是作为同一导航的一部分被请求的,那么它们将被设置为在大致相同的时间过期,但仍然存在竞争的可能性。
如果有些页面不包含 JS,或包含不同的 CSS,过期日期就会不同步。更糟糕的是,浏览器经常会从缓存中删除一些内容,而它并不知道 HTML、CSS 和 JS 是相互依存的,所以它会很乐意删除其中一个,而不删除其他的。将这些因素相乘,最终出现这些资源版本不匹配的情况也就不是不可能了。
对于用户来说,这可能会导致布局和/或功能被破坏,从细微的故障到完全无法使用的内容。
值得庆幸的是,用户有一个逃生通道...
刷新有时可以解决
如果页面是作为刷新的一部分加载的,浏览器总是会与服务器重新验证,而忽略 max-age
。因此,如果用户遇到的问题是由于 max-age
导致的,点击刷新就能解决一切问题。当然,强迫用户这样做会降低信任度,因为这会让人觉得你的网站很不稳定。
Service Worker 线程可以延长这些错误的寿命
假设您有以下 Service Worker:
代码语言:javascript复制const version = '2';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(`static-${version}`)
.then((cache) => cache.addAll(['/styles.css', '/script.js']))
)
})
self.addEventListener('activate', (event) => {
// delete old caches...
})
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => response || fetch(event.request))
)
})
这个 Service Worker 线程...
- • 预先缓存脚本和样式
- • 如果匹配,则从缓存中提供服务,否则通过网络提供服务
如果我们更改了 CSS/JS,我们就会提升 version
,使 Service Worker 的字节不同,从而触发更新。不过,由于 addAll
是通过 HTTP 缓存获取的(几乎所有的获取都是这样),我们可能会遇到 max-age
竞争条件,并缓存到不兼容的 CSS 和 JS 版本。
一旦它们被缓存,在下次更新 Service Worker 之前,我们将一直提供不兼容的 CSS 和 JS。
您可以绕过 Service Worker 中的缓存:
代码语言:javascript复制self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(`static-${version}`)
.then((catch) => {
cache.addAll([
new Request('/styles.css', { cache: 'no-cache' }),
new Request('/script.js', { cache: 'no-cache' })
])
})
)
})
遗憾的是,Chrome/Opera 尚不支持缓存选项,而 Firefox Nightly 最近才支持缓存选项,不过你也可以自己尝试一下:
代码语言:javascript复制self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(`static-${version}`).then((cache) => {
return Promise.all(
['/styles.css', '/script.js'].map(() => {
// cache-bust using a random query string
return fetch(`${url}?${Math.random()}`).then((response) => {
// fail on 404, 500 etc
if(!response.ok) throw Error('Not ok');
return cache.put(url, response);
})
})
)
})
)
})
在上文中,我使用随机数来破坏缓存,但您可以更进一步,使用构建步骤来添加内容的哈希值(类似于 sw-precache 的做法)。这有点像在 JavaScript 中重新实现模式一(不可变内容),但只是为了 Service Worker 用户的利益,而不是所有浏览器和 CDN 的利益。
Service Worker 和 HTTP 缓存可以很好地合作,不要让它们打架!
正如您所看到的,您可以解决 Service Worker 中的糟糕的缓存问题,但最好还是解决问题的根源。正确设置缓存可以使 Service Worker 领域的工作变得更轻松,而且也有利于不支持 Service Worker 的浏览器(Safari、IE/Edge)受益,并让您最大限度地利用 CDN。
正确的缓存标头意味着您还可以大幅简化 Service Worker 的更新:
代码语言:javascript复制const version = '23';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(`static-${version}`)
.then((cache) => {
cache.addAll([
'/',
'/script-f93bca2c.js',
'/styles-a837cb1e.css',
'/cats-0e9a2ef4.jpg'
])
})
)
})
在这里,我会使用模式二(服务器重新验证)缓存根页面,使用模式一(不可变内容)缓存其他资源。每次 Service Worker 更新都会触发对根页面的请求,但其他资源只有在 URL 发生变化时才会被下载。这样做非常好,因为无论从上一版本还是 10 个版本更新,都能节省带宽并提高性能。
与本地程序相比,这是一个巨大的优势,在本地程序中,即使是很小的改动也要下载整个二进制文件,或者涉及复杂的二进制差异,在这里,我们只需相对较少的下载就能更新一个大型网络应用程序。
Service Worker 的最佳工作方式是增强而不是变通,因此与其与缓存对抗,不如与它合作!
谨慎使用 max-age 和可变内容可带来益处
在可变内容上使用 max-age
通常是错误的选择,但并非总是如此。
例如,本页面的 max-age
为三分钟,这里并不存在竞争条件的问题,因为该页面没有任何依赖项遵循相同的缓存模式(我的 CSS、JS 和图片 URL 都遵循模式一 ——不可变内容),而且该页面的任何依赖项都不遵循相同的模式。
这种模式意味着,如果我有幸写了一篇受欢迎的文章,我的 CDN(Cloudflare)可以为我的服务器分担热量,只要我可以忍受文章更新需要三分钟才能被用户看到,而我现在就是这样。
这种模式不能随便使用,如果我在一篇文章中添加了一个新的部分,并在另一篇文章中进行了链接,那么我就创建了一个可能会发生竞争的依赖关系。用户点击链接后,可能会进入一篇没有引用部分的文章。如果我想避免这种情况,我会更新第一篇文章,使用 Cloudflare 的用户界面刷新 Cloudflare 的缓存副本,等待三分钟,然后在另一篇文章中添加链接。是的......使用这种模式必须非常小心。
正确使用缓存可以大大提高性能和节省带宽。对于任何容易改变的 URL,最好使用不可变内容,否则就使用服务器重新验证。只有当你觉得自己很勇敢,并且确信你的内容没有依赖关系或可能不同步的依赖关系时,才会混合使用 max-age
和可变内容。
原文链接:https://jakearchibald.com/2016/caching-best-practices/