キャッシュを使用すると、パフォーマンスが大幅に向上し、帯域幅が節約され、サーバーのオーバーヘッドが削減されます。ただし、多くの Web サイトではキャッシュについてほとんど理解されていないため、相互依存するリソース間で競合状態が発生し、同時更新が妨げられます。
キャッシュを使用するためのベストプラクティスは、一般に次の 2 つのモードに要約できます:
Cache-Control: max-age=31536000
ページ: 「/script-v1.js」、「/styles-v1.css」、「/cats-v1.jpg」が必要です。 (10:24)
キャッシュ: ここには何もありません。 サーバー側、ありますか? (10:24)
サーバー: もちろん、あなたのためにです。ちなみに、これらのリソースをキャッシュして保存し、1 年以内に直接使用します。 (10:25)
キャッシュ: ありがとう! (10:25)
ページ: いいね! (10:25)
2 日目
ページ: こんにちは、今回は「/script-
v2.js」、「/styles- v2.css」、および「/cats」が必要です-v1.jpg」。 (08:14)
キャッシュ: 1 つしかありません。最初に使用しましょう。残りはありません。 サーバー側、見てみてください?サーバー: わかりました。新しい CSS と JS ファイルを差し上げます。ちなみに、キャッシュは1年間保存できます。 (08:15)
キャッシュ: 素晴らしい! (08:15)
ページ: ありがとうございます! (08:15)
後で
キャッシュ: そうですね、「/script-v1.js」と「/styles-v1.css」はしばらく使用されていませんでした。それらを削除することにしました。 (12:32)
このモードでは、URL のコンテンツは決して変更されず、URL 自体のみが変更されます:
<script src="/script-f93bca2c.js"><link rel="stylesheet" href="/styles-a837cb1e.css"><img src="/cats-0e9a2ef4.jpg" alt="…">
ただし、記事やブログの詳細などの HTML ページはこのモードには適していません。これらのページの URL にはバージョン番号を含めることはできず、ページのコンテンツは変更できる必要があります。特にスペルや文法上の誤りが見つかった場合は、迅速かつ頻繁に更新する必要があります。
モード 2: 可変コンテンツ、サーバー側検証が毎回実行されます
キャッシュ制御: no-cache
URL の対応するコンテンツは変更される可能性があるため、次のようになります:
キャッシング: わかりません。 サーバー側、見てみましょう? (11:32)
サーバー: はい、持っています、こちらにあります。 キャッシュ、自分でコピーを保存できますが、使用する前に私に問い合わせる必要があります。 (11:33)
キャッシュ: わかりました! (11:33)
ページ: ありがとう! (11:33)
2 日目
キャッシュ: 待ってください。 サーバー側では、ローカル コピーを直接使用できますか?私の場合、「/about/」は月曜日に最後に変更され、「/sw.js」は昨日に最後に変更されました。 (9:46)
サーバー: 「/sw.js」はそれ以来変更されていません。 (9:47)
キャッシュ: いいね! ページに「/sw.js」が表示されます。 (9:47)
サーバー: ただし、「/about/」は変更されています。最新バージョンを提供します。 キャッシュ、以前と同様に、自分でコピーを保存できますが、使用する前に私に問い合わせる必要があります ~ (9:47)
キャッシュ: わかりました! (9:47)
ページ: いいね! (9:47)
注: no-cache は「キャッシュがない」という意味ではなく、キャッシュを使用する前にサーバー上でリソースが更新されているかどうかをチェック (または検証) する必要があることを意味します。 no-store は、このリソースをまったくキャッシュしないようにブラウザに指示するために使用されます。同様に、must-revalidate は「毎回検証する」という意味ではなく、リソースが max-age で指定された長さよりも短い期間ローカルにキャッシュされている場合は、そのリソースを直接使用でき、それ以外の場合は検証が開始されることを意味します。これでわかった。
这种模式下,也可以给资源响应加上 ETag(资源的版本 ID)、 Last-Modified时间这两个头部。下次客户端请求这些资源时,会通过 If-None-Match或 If-Modified-Since这两个请求头带上之前的值,这样服务端就可以返回「直接用你之前缓存的版本吧,它们是最新的」,换成行话就是「HTTP 304」。
如果服务端没办法发送 ETag/ Last-Modified头部,那每次都需要发送完整的响应内容。
这种模式下,每次都会产生网络请求,所以它没有能节省网络请求的模式一好。
模式一被基础设施影响,模式二被网络请求影响,都是常见的事儿。所以又有了中间方案:给可变内容加上短一点的 max-age。这个折中方案太太太糟糕了。
不幸的是这种做法并不罕见,Github pages 当前就是这样。
假设这样三个资源:
都有这样的响应头:
Cache-Control: must-revalidate, max-age=600
页面:嘿,我需要 "/article/"、"/script.js" 和 "/styles.css"。(10:21)
缓存:我这里没有, 服务端?(10:21)
服务端:没问题,给你。对了 缓存,这些资源你可以存 10 分钟。(10:22)
缓存:明白!(10:22)
页面:多谢!(10:22)
页面:嘿,我又想要 "/article/"、"/script.js" 和 "/styles.css"。(10:28)
缓存:天哪,真抱歉,我把 "/styles.css" 给弄丢了,其它的都有,先给你。 服务端,你能再把 "/style.css" 发给我吗?(10:28)
服务端:当然可以,实际上它在你上次请求之后发生了改变。同样,你又可以把它缓存 10 分钟。(10:29)
缓存:没问题。(10:29)
页面:多谢!等等!彻底挂了!!发生什么啦?(10:29)
这种场景在测试环境可以构造出来,但在真实环境中难复现,也难追查。在上述例子中,实际上服务端同时更新了 HTML、CSS 和 JS,但是页面最终从缓存中拿到旧的 HTML 和 JS,并从服务端拿到最新的 CSS。版本不匹配导致功能异常。
通常,当我们对 HTML 改动很大时,很可能 CSS 需要为新结构作出调整,JS 也需要配合 CSS 和 HTML 改动而进行相应修改。这些资源相互依赖,但无法通过缓存头反映出来。最终页面可能会拿到一部分新资源,一部分旧资源。
max-age是响应时间的相对值,某个页面上的所有资源请求,会被设置在大致相同的时间后失效,但仍有小概率出现竞争。如果你有一些不包含 JS、或包含不同 CSS 的页面,过期时间可以不同步。更为糟糕的是,浏览器一直都在淘汰缓存的资源,它不可能知道某些 HTML、CSS 和 JS 相互有依赖,所以会出现部分淘汰的情况。综上所述,最终页面拿到版本不匹配的资源并非不可能发生。
对于用来来说,这会破坏页面布局和/或功能,从小问题到大事故都有可能发生。
谢天谢地,我们有个解决方案。。。
刷新页面,会让浏览器向服务端发起验证,忽略 max-age。所以如果用户对 max-age的这个问题很有经验的话,点击刷新按钮就能解决一切问题。当然,要求用户这样做会降低用户对你的信任,会让用户觉得你的网站很不稳定。
假设有下面这样的 service worker 代码:
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 里绕过 HTTP 缓存:
self.addEventListener('install', event => { event.waitUntil( caches.open(`static-${version}`) .then(cache => cache.addAll([ new Request('/styles.css', { cache: 'no-cache' }), new Request('/script.js', { cache: 'no-cache' }) ])) );});
不幸的是当前 Chrome/Opera都不支持 cache选项,只有 最新的 Firefox Nightly才支持,当然你也可以自己解决:
self.addEventListener('install', event => { event.waitUntil( caches.open(`static-${version}`) .then(cache => Promise.all( [ '/styles.css', '/script.js' ].map(url => { // 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); }) }) )) );});
上面代码通过加随机数的方式绕过了缓存,也可以更进一步,利用构建工具自动添加文件内容 MD5(类似于 sw-precache所做的工作)。这有点像在 JavaScript 中实现了模式一,但是只能让 service worker 受益,不包括浏览器和 CDN。
如你所见,我们可以用一些技巧来改善 service worker 中的缓存,但更好的做法是从源头解决问题。正确使用 HTTP 缓存不但可以简化 service worker 逻辑,还可以让那些不支持 service worker 的浏览器获益(Safari、IE/Edge),也能用好 CDN。
正确配置缓存响应头意味着可以大幅简化 service worker 的更新逻辑:
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' ])) );});
在这个例子中,我使用模式二(可变内容,每次都走服务端验证)缓存 HTML 页面;其它资源使用了模式一(不变内容 + 长时间 max-age)缓存。每次 service worker 更新都会触发 HTML 页面请求,而其它资源只有在 URL 发生变化时才会再次下载。这非常棒,无论是从上个版本还是上十个版本更新都能节省带宽、提高性能。
相比有细微改动整个二进制文件就要重新下载,或者要实现复杂的二进制 diff 的原生应用,这是一个巨大的优点。只需要少量下载,就可以更新大型 Web 应用。
service worker 最好用于局部增强而不是提供整套方案,它应该与 HTTP 缓存配合使用,而不是相互打架。
给经常改变的内容设置 max-age通常是错误的选择,但也不全是。现在你看到的这个页面(译者注:指原文页面)就设置了三分钟的 max-age。在这里竞态条件不是问题,因为这个页面不依赖任何使用同样缓存模式的资源(我的 CSS、JS 及图片都属于模式一:不变内容),也不被其它使用同样缓存模式的页面所依赖。
这种模式意味着,如果我足够幸运写了一篇大受欢迎的文章,我的 CDN(Cloudflare)会帮我的服务器抗住流量,只要我能接受修改文章要等三分钟才能被用户看到,我确实可以接受。
这种模式用起来也没那么容易。如果我给一篇文章增加了一段内容,再在另外一篇文章中指向它,我就在页面之间创建了会引入竞争的依赖关系。用户可能点击链接后看到的是不包含新增内容的缓存副本。要避免这种问题发生,我必须在更新文章后,去 Cloudflare 的控制台刷新这篇文章的缓存,等三分钟再在其它文章加上指向它的链接。是的,你必须非常小心地使用这种模式。
只要用法恰当,缓存能极大的提升性能、节省带宽。让不变内容可以轻松改变 URL,让可变内容走服务端验证。如果你很勇敢,当你能确认你的内容既不依赖别人也不被别人依赖时,才针对可变内容使用 max-age,因为它可能无法同步更新。